From 69c645ba17116987213ecfa128cb7fba2cbfa25d Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 7 Aug 2026 01:54:32 +0000
Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[=EC=A0=91?=
=?UTF-8?q?=EA=B7=BC=EC=84=B1]=20=ED=9A=8C=EC=9D=98=20=EC=A1=B0=EC=9C=A8?=
=?UTF-8?q?=20=EC=A0=9C=EC=95=88=20=EB=B2=84=ED=8A=BC=20=EB=A7=A5=EB=9D=BD?=
=?UTF-8?q?=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.Jules/palette.md | 3 +++
.../components/calendar/CalendarCoordinationView.tsx | 12 ++++++------
2 files changed, 9 insertions(+), 6 deletions(-)
create mode 100644 .Jules/palette.md
diff --git a/.Jules/palette.md b/.Jules/palette.md
new file mode 100644
index 000000000..3dba0b50c
--- /dev/null
+++ b/.Jules/palette.md
@@ -0,0 +1,3 @@
+## 2024-08-07 - Add aria-labels to meeting proposal buttons
+**Learning:** Buttons that only have generic text like "제안하기" (Propose) or numbered options like "1안" without context can be unclear to screen reader users when navigating by buttons. Using a screen-reader-only (`sr-only`) span provides clear and actionable context while preserving the reading order of the visible date and time.
+**Action:** Always ensure that buttons within complex components (like meeting proposals) have explicit context (e.g., using `sr-only` spans) if the visible text alone isn't sufficiently descriptive out of context.
diff --git a/frontend/src/components/calendar/CalendarCoordinationView.tsx b/frontend/src/components/calendar/CalendarCoordinationView.tsx
index 41bf3b893..fd5fb9d98 100644
--- a/frontend/src/components/calendar/CalendarCoordinationView.tsx
+++ b/frontend/src/components/calendar/CalendarCoordinationView.tsx
@@ -7,23 +7,23 @@ export function CalendarCoordinationView() {
-
1안
-
+
1안
+
1안 제안하기:
5월 23일 (목) 14:00 - 15:00
모든 참석자 참석 가능
-
제안하기
+
제안하기
-
2안
-
+
2안
+
2안 제안하기:
5월 24일 (금) 10:00 - 11:00
1명(김개발) 불참 예상
-
제안하기
+
제안하기
From 874cd83f2e97188acd3bdc6c45fe3deb3487178c Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 7 Aug 2026 02:24:07 +0000
Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[=EC=A0=91?=
=?UTF-8?q?=EA=B7=BC=EC=84=B1]=20=ED=9A=8C=EC=9D=98=20=EC=A1=B0=EC=9C=A8?=
=?UTF-8?q?=20=EC=A0=9C=EC=95=88=20=EB=B2=84=ED=8A=BC=20=EB=A7=A5=EB=9D=BD?=
=?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(=EB=A6=AC=EB=B7=B0=20=EB=B0=98?=
=?UTF-8?q?=EC=98=81)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.Jules/palette.md | 3 -
.jules/bolt.md | 4 ++
CHANGELOG.md | 4 ++
backend/api/emails.py | 6 +-
docs/doctoring/calendar-a11y.md | 13 ++++
.../CalendarCoordinationView.test.tsx | 64 +++++++++++++++++++
.../calendar/CalendarCoordinationView.tsx | 6 +-
7 files changed, 92 insertions(+), 8 deletions(-)
delete mode 100644 .Jules/palette.md
create mode 100644 docs/doctoring/calendar-a11y.md
create mode 100644 frontend/src/components/calendar/CalendarCoordinationView.test.tsx
diff --git a/.Jules/palette.md b/.Jules/palette.md
deleted file mode 100644
index 3dba0b50c..000000000
--- a/.Jules/palette.md
+++ /dev/null
@@ -1,3 +0,0 @@
-## 2024-08-07 - Add aria-labels to meeting proposal buttons
-**Learning:** Buttons that only have generic text like "제안하기" (Propose) or numbered options like "1안" without context can be unclear to screen reader users when navigating by buttons. Using a screen-reader-only (`sr-only`) span provides clear and actionable context while preserving the reading order of the visible date and time.
-**Action:** Always ensure that buttons within complex components (like meeting proposals) have explicit context (e.g., using `sr-only` spans) if the visible text alone isn't sufficiently descriptive out of context.
diff --git a/.jules/bolt.md b/.jules/bolt.md
index d5fcbd53e..d0b0a9997 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -15,3 +15,7 @@
**Learning:** `dict.setdefault(key, []).append(value)` evaluates the empty-list default on every iteration, including when the key already exists. In grouping loops, `defaultdict(list)` avoids those transient unused list allocations while preserving insertion order.
**Action:** Use `defaultdict(list)` when missing keys are intentionally initialized with lists. Keep `setdefault` when its eager-default behavior or an ordinary `dict` is part of the required contract, and benchmark before claiming a material end-to-end improvement.
+## 2026-07-20 - Set Membership Over Dictionary Truthiness
+
+**Learning:** When using a dictionary purely to track the presence of keys (e.g. `has_sent_message[key] = True`), checking for presence with `.get(key, False)` carries unnecessary semantic and memory overhead. Sets in Python provide a cleaner `key in set_name` syntax for boolean presence checks and slightly reduced memory footprint, while maintaining O(1) time complexity.
+**Action:** When tracking unique occurrences or boolean presence of items where the value itself doesn't carry additional information, use a `set` and its `.add()` and `in` operators instead of a `dict` mapping to `True` or `False`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a06003d8f..47850022f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2718,3 +2718,7 @@
- **Note:** CI opencode-review 잡 실행 중 타임아웃 오류(The action 'Run OpenCode PR Review model pool' has timed out after 350 minutes)가 발생했습니다. 이는 외부 AI 검토 모델 서버(github-models 등)의 응답 지연에 기인한 일시적 인프라 문제로 판단되며, 코드 변경 자체의 결함은 아니므로 그대로 재제출하여 파이프라인 재실행을 시도합니다.
- **Note:** CI opencode-review 잡 실행 중 타임아웃 오류(The action 'Run OpenCode PR Review model pool' has timed out after 350 minutes)가 발생했습니다. 반복되는 외부 인프라 타임아웃 문제를 해결하기 위해, 마지막으로 재제출을 시도합니다.
- **Note:** 추가적인 코드 변경은 없으며, PR 내 자동 분석 커멘트에 대한 답변(CI 실패가 본 PR이 아닌 develop의 기존 이슈임을 인지함)을 남기고 현재 워크플로우를 완료합니다.
+
+## [Unreleased]
+### Added
+- [UX 개선] 캘린더 회의 조율 화면 제안 버튼의 접근성 향상 (스크린 리더 사용자를 위한 sr-only 텍스트 추가 및 불필요한 중복 텍스트 숨김)
diff --git a/backend/api/emails.py b/backend/api/emails.py
index 223ebf040..5cfa77a77 100644
--- a/backend/api/emails.py
+++ b/backend/api/emails.py
@@ -322,7 +322,7 @@ async def get_emails(
reply_counts = defaultdict(int)
thread_messages = defaultdict(list)
- has_sent_message = {}
+ has_sent_message = set()
if grouped:
thread_lookup: set[str] = set()
@@ -347,13 +347,13 @@ async def get_emails(
reply_counts[group_key] += 1
if is_sent_folder and group_key not in has_sent_message:
if message_is_from_user(email, user_addresses):
- has_sent_message[group_key] = True
+ has_sent_message.add(group_key)
if is_sent_folder:
visible_groups = [
email
for group_key, email in grouped.items()
- if has_sent_message.get(group_key, False)
+ if group_key in has_sent_message
]
else:
visible_groups = list(grouped.values())
diff --git a/docs/doctoring/calendar-a11y.md b/docs/doctoring/calendar-a11y.md
new file mode 100644
index 000000000..0bd441861
--- /dev/null
+++ b/docs/doctoring/calendar-a11y.md
@@ -0,0 +1,13 @@
+# Calendar Coordination View Accessibility
+
+In `CalendarCoordinationView.tsx`, the proposal buttons were initially designed with visual content representing numbered options (e.g., "1안"), date/time, attendance status, and a generic action text ("제안하기").
+
+**Accessibility Problem:**
+Using an `aria-label` directly on the `` element completely overrides its accessible name, discarding all the descendant text content that might be crucial for context. If we used `aria-label="1안 제안하기"`, a screen reader user would miss the date, time, and attendance status.
+
+**Solution:**
+Instead of `aria-label`, we use a visually hidden element (`1안 제안하기: `) inside the button structure alongside the visible text. Furthermore, we add `aria-hidden="true"` to purely decorative or redundant visual elements (like the visible "1안" badge and the generic "제안하기" label).
+
+This ensures the computed accessible name sequentially combines the `sr-only` context and the essential visible date and attendance information, conforming with Web Content Accessibility Guidelines (WCAG) 2.2 for accessible names and focus indicators (buttons retain `focus-visible` styles).
+
+Reference: W3C Web Accessibility Initiative. (2023). Web Content Accessibility Guidelines (WCAG) 2.2. W3C.
diff --git a/frontend/src/components/calendar/CalendarCoordinationView.test.tsx b/frontend/src/components/calendar/CalendarCoordinationView.test.tsx
new file mode 100644
index 000000000..97825f4c8
--- /dev/null
+++ b/frontend/src/components/calendar/CalendarCoordinationView.test.tsx
@@ -0,0 +1,64 @@
+/* @vitest-environment jsdom */
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vitest";
+import { CalendarCoordinationView } from "./CalendarCoordinationView";
+
+describe("CalendarCoordinationView", () => {
+ let container: HTMLDivElement | null = null;
+ let root: Root | null = null;
+
+ afterEach(() => {
+ if (root && container) {
+ act(() => {
+ root!.unmount();
+ });
+ container.remove();
+ }
+ container = null;
+ root = null;
+ });
+
+ it("renders buttons with distinct accessible names including date and attendance", () => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ act(() => {
+ root!.render( );
+ });
+
+ const buttons = container.querySelectorAll("button");
+ expect(buttons).toHaveLength(2);
+
+ // Assert focus class
+ buttons.forEach((btn) => {
+ expect(btn.className).toContain("focus-visible:ring-2");
+ });
+
+ // Check sr-only span content within buttons to ensure computed accessible name contains it
+ const button1 = buttons[0];
+ const button2 = buttons[1];
+
+ expect(button1.textContent).toContain("1안 제안하기:");
+ expect(button1.textContent).toContain("5월 23일 (목) 14:00 - 15:00");
+ expect(button1.textContent).toContain("모든 참석자 참석 가능");
+
+ expect(button2.textContent).toContain("2안 제안하기:");
+ expect(button2.textContent).toContain("5월 24일 (금) 10:00 - 11:00");
+ expect(button2.textContent).toContain("1명(김개발) 불참 예상");
+
+ // Check aria-hidden on decorative elements
+ const ariaHiddenElements = container.querySelectorAll('[aria-hidden="true"]');
+ // There are 2 option badges (1안, 2안) + 2 propose labels (제안하기) = 4
+ expect(ariaHiddenElements).toHaveLength(4);
+
+ // verify option labels are aria-hidden
+ expect(Array.from(ariaHiddenElements).some(el => el.textContent === '1안')).toBe(true);
+ expect(Array.from(ariaHiddenElements).some(el => el.textContent === '2안')).toBe(true);
+
+ // verify propose labels are aria-hidden
+ const proposeLabels = Array.from(ariaHiddenElements).filter(el => el.textContent === '제안하기');
+ expect(proposeLabels).toHaveLength(2);
+ });
+});
diff --git a/frontend/src/components/calendar/CalendarCoordinationView.tsx b/frontend/src/components/calendar/CalendarCoordinationView.tsx
index fd5fb9d98..014581449 100644
--- a/frontend/src/components/calendar/CalendarCoordinationView.tsx
+++ b/frontend/src/components/calendar/CalendarCoordinationView.tsx
@@ -8,7 +8,8 @@ export function CalendarCoordinationView() {
1안
-
1안 제안하기:
+
+
1안 제안하기:
5월 23일 (목) 14:00 - 15:00
모든 참석자 참석 가능
@@ -18,7 +19,8 @@ export function CalendarCoordinationView() {
2안
-
2안 제안하기:
+
+
2안 제안하기:
5월 24일 (금) 10:00 - 11:00
1명(김개발) 불참 예상
From c13c79733bfbe2fabeaada27eda87feedde118c1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 7 Aug 2026 15:04:43 +0900
Subject: [PATCH 3/5] ci(pr-1263): finalize calendar accessibility evidence
---
.../pr-1263-finalize-calendar-a11y.yml | 213 ++++++++++++++++++
1 file changed, 213 insertions(+)
create mode 100644 .github/workflows/pr-1263-finalize-calendar-a11y.yml
diff --git a/.github/workflows/pr-1263-finalize-calendar-a11y.yml b/.github/workflows/pr-1263-finalize-calendar-a11y.yml
new file mode 100644
index 000000000..3f5aee558
--- /dev/null
+++ b/.github/workflows/pr-1263-finalize-calendar-a11y.yml
@@ -0,0 +1,213 @@
+name: PR 1263 finalize calendar accessibility
+
+on:
+ push:
+ branches:
+ - palette-ux-calendar-a11y-16466429643166483098
+ paths:
+ - .github/workflows/pr-1263-finalize-calendar-a11y.yml
+
+concurrency:
+ group: pr-1263-finalize-calendar-a11y
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ finalize:
+ if: github.actor != 'github-actions[bot]'
+ runs-on: ubuntu-latest
+ timeout-minutes: 75
+ permissions:
+ contents: write
+ env:
+ DISABLE_BACKGROUND_WORKERS: "1"
+ POSTCSS_WORKERS: "1"
+ DISABLE_POSTCSS_WORKERS: "true"
+ steps:
+ - name: Harden the runner
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
+ with:
+ egress-policy: audit
+
+ - name: Checkout the exact pull-request branch
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: palette-ux-calendar-a11y-16466429643166483098
+ fetch-depth: 0
+ persist-credentials: true
+
+ - name: Repair changelog and evidence documentation
+ run: |
+ python - <<'PY'
+ from pathlib import Path
+
+ changelog_path = Path("CHANGELOG.md")
+ changelog = changelog_path.read_text(encoding="utf-8")
+ bullet = "- [UX 개선] 캘린더 회의 조율 화면 제안 버튼의 접근성 향상 (스크린 리더 사용자를 위한 sr-only 텍스트 추가 및 불필요한 중복 텍스트 숨김)"
+ duplicate = f"\n## [Unreleased]\n### Added\n{bullet}"
+ if changelog.count(duplicate) != 1:
+ raise SystemExit("Expected exactly one duplicate Unreleased accessibility block")
+ changelog = changelog.replace(duplicate, f"\n{bullet}")
+ changelog_path.write_text(changelog, encoding="utf-8")
+ if changelog.count("## [Unreleased]") != 1:
+ raise SystemExit("CHANGELOG must contain exactly one Unreleased section")
+ PY
+
+ cat > docs/doctoring/calendar-a11y.md <<'EOF'
+ # Calendar Coordination View Accessibility
+
+ `CalendarCoordinationView.tsx` presents numbered meeting proposals with date, time, attendance status, and a visible `제안하기` action label.
+
+ ## Accessibility problem
+
+ A short `aria-label` on the button would replace the descendant-derived accessible name and could omit the date, time, or attendance information needed to distinguish the proposals. Purely visual repetition can also make screen-reader output unnecessarily noisy.
+
+ ## Implemented pattern
+
+ Each button keeps its essential visible text in the accessibility tree and adds a visually hidden contextual prefix such as `1안 제안하기: `. The duplicated visual option badge and trailing action label use `aria-hidden="true"`. The native button role and existing `focus-visible` ring remain intact.
+
+ This component pattern aligns with WCAG 2.2 Success Criterion 4.1.2, **Name, Role, Value**, and Success Criterion 2.4.7, **Focus Visible**. This scoped implementation statement does **not** establish conformance of the whole Naruon product.
+
+ ## Research note
+
+ Lazar et al. (2007) studied 100 blind web users and identified confusing screen-reader feedback and poorly designed or unlabeled controls among the leading sources of frustration. The proposal-button pattern therefore preserves task-specific context in the computed accessible name instead of relying on visual grouping alone.
+
+ ## References
+
+ Lazar, J., Allen, A., Kleinman, J., & Malarkey, C. (2007). What frustrates screen reader users on the web: A study of 100 blind users. *International Journal of Human–Computer Interaction, 22*(3), 247–269. https://doi.org/10.1080/10447310709336964
+
+ World Wide Web Consortium. (2023a). *Understanding Success Criterion 2.4.7: Focus visible*. https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html
+
+ World Wide Web Consortium. (2023b). *Understanding Success Criterion 4.1.2: Name, role, value*. https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html
+ EOF
+ sed -i 's/^ //' docs/doctoring/calendar-a11y.md
+
+ - name: Add the accessible-name query dependency
+ run: |
+ corepack enable pnpm
+ corepack prepare pnpm@11.5.3 --activate
+ cd frontend
+ pnpm add --save-dev --save-exact @testing-library/dom@10.4.1 --lockfile-only
+ pnpm install --frozen-lockfile
+ test "$(pnpm exec node -p \"require('@testing-library/dom/package.json').version\")" = "10.4.1"
+ pnpm why @testing-library/dom
+
+ - name: Replace implementation-detail assertions with accessible-name queries
+ run: |
+ cat > frontend/src/components/calendar/CalendarCoordinationView.test.tsx <<'EOF'
+ /* @vitest-environment jsdom */
+ import { getByRole } from "@testing-library/dom";
+ import React, { act } from "react";
+ import { createRoot, type Root } from "react-dom/client";
+ import { afterEach, describe, expect, it } from "vitest";
+ import { CalendarCoordinationView } from "./CalendarCoordinationView";
+
+ describe("CalendarCoordinationView", () => {
+ let container: HTMLDivElement | null = null;
+ let root: Root | null = null;
+
+ afterEach(() => {
+ if (root && container) {
+ act(() => {
+ root!.unmount();
+ });
+ container.remove();
+ }
+ container = null;
+ root = null;
+ });
+
+ it("exposes distinct proposal context through each computed accessible name", () => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ act(() => {
+ root!.render( );
+ });
+
+ const firstButton = getByRole(container, "button", {
+ name: /^1안 제안하기:\s+5월 23일 \(목\) 14:00 - 15:00\s+모든 참석자 참석 가능$/,
+ });
+ const secondButton = getByRole(container, "button", {
+ name: /^2안 제안하기:\s+5월 24일 \(금\) 10:00 - 11:00\s+1명\(김개발\) 불참 예상$/,
+ });
+
+ expect(firstButton).not.toBe(secondButton);
+ expect(firstButton.className).toContain("focus-visible:ring-2");
+ expect(secondButton.className).toContain("focus-visible:ring-2");
+
+ const ariaHiddenElements = container.querySelectorAll(
+ '[aria-hidden="true"]',
+ );
+ expect(ariaHiddenElements).toHaveLength(4);
+ expect(
+ Array.from(ariaHiddenElements).some(
+ (element) => element.textContent === "1안",
+ ),
+ ).toBe(true);
+ expect(
+ Array.from(ariaHiddenElements).some(
+ (element) => element.textContent === "2안",
+ ),
+ ).toBe(true);
+ expect(
+ Array.from(ariaHiddenElements).filter(
+ (element) => element.textContent === "제안하기",
+ ),
+ ).toHaveLength(2);
+ });
+ });
+ EOF
+ sed -i 's/^ //' frontend/src/components/calendar/CalendarCoordinationView.test.tsx
+
+ - name: Verify accessibility implementation and evidence
+ run: |
+ cd frontend
+ pnpm exec vitest run src/components/calendar/CalendarCoordinationView.test.tsx
+ pnpm run lint
+ pnpm run typecheck
+ pnpm run test
+ pnpm run coverage
+ pnpm run build
+ cd ..
+ python - <<'PY'
+ from pathlib import Path
+
+ changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
+ documentation = Path("docs/doctoring/calendar-a11y.md").read_text(encoding="utf-8")
+ test_source = Path(
+ "frontend/src/components/calendar/CalendarCoordinationView.test.tsx"
+ ).read_text(encoding="utf-8")
+
+ assert changelog.count("## [Unreleased]") == 1
+ assert "WCAG 2.2 Success Criterion 4.1.2" in documentation
+ assert "Success Criterion 2.4.7" in documentation
+ assert "does **not** establish conformance" in documentation
+ assert "10.1080/10447310709336964" in documentation
+ assert "getByRole(container, \"button\"" in test_source
+ assert ".textContent).toContain" not in test_source
+ PY
+ git diff --check
+
+ - name: Commit only verified permanent changes and remove this workflow
+ run: |
+ rm .github/workflows/pr-1263-finalize-calendar-a11y.yml
+ git diff --check
+ test "$(git status --short | wc -l)" -eq 6
+ git status --short | grep -F ' M CHANGELOG.md'
+ git status --short | grep -F ' M docs/doctoring/calendar-a11y.md'
+ git status --short | grep -F ' M frontend/package.json'
+ git status --short | grep -F ' M frontend/pnpm-lock.yaml'
+ git status --short | grep -F ' M frontend/src/components/calendar/CalendarCoordinationView.test.tsx'
+ git status --short | grep -F ' D .github/workflows/pr-1263-finalize-calendar-a11y.yml'
+ git config user.name github-actions[bot]
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ git add -A -- CHANGELOG.md docs/doctoring/calendar-a11y.md frontend/package.json frontend/pnpm-lock.yaml frontend/src/components/calendar/CalendarCoordinationView.test.tsx .github/workflows/pr-1263-finalize-calendar-a11y.yml
+ git diff --cached --check
+ git commit -m "fix(a11y): verify calendar proposal accessible names"
+ git push origin HEAD:palette-ux-calendar-a11y-16466429643166483098
+
+# Exact-head repair trigger; the workflow deletes itself after verified publication.
From f04303d593a210054cfc7aeac62d875947ad3595 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 7 Aug 2026 06:23:13 +0000
Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[=EC=A0=91?=
=?UTF-8?q?=EA=B7=BC=EC=84=B1]=20=ED=9A=8C=EC=9D=98=20=EC=A1=B0=EC=9C=A8?=
=?UTF-8?q?=20=EC=A0=9C=EC=95=88=20=EB=B2=84=ED=8A=BC=20=EB=A7=A5=EB=9D=BD?=
=?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(=EB=A6=AC=EB=B7=B0=20=EB=B0=98?=
=?UTF-8?q?=EC=98=81=20=EB=B0=8F=20CI=20=EC=88=98=EC=A0=95)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/dependabot.yml | 18 ++
.../pr-1263-finalize-calendar-a11y.yml | 213 ------------------
CHANGELOG.md | 2 -
docs/doctoring/calendar-a11y.md | 26 ++-
4 files changed, 37 insertions(+), 222 deletions(-)
delete mode 100644 .github/workflows/pr-1263-finalize-calendar-a11y.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 237e6bf29..2fe5fa832 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,6 +4,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 7
groups:
github-actions:
patterns:
@@ -13,6 +15,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 7
groups:
docker-base-images:
patterns:
@@ -22,6 +26,8 @@ updates:
directory: "/frontend"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 7
groups:
frontend-docker-base-images:
patterns:
@@ -31,6 +37,8 @@ updates:
directory: "/backend"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 7
groups:
backend-python:
patterns:
@@ -40,6 +48,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 7
groups:
ci-python:
patterns:
@@ -49,6 +59,8 @@ updates:
directory: "/frontend"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 7
groups:
frontend-npm:
patterns:
@@ -58,13 +70,19 @@ updates:
directory: /connector
schedule:
interval: daily
+ cooldown:
+ default-days: 7
- package-ecosystem: pip
directory: /connector
schedule:
interval: daily
+ cooldown:
+ default-days: 7
- package-ecosystem: npm
directory: /
schedule:
interval: daily
+ cooldown:
+ default-days: 7
diff --git a/.github/workflows/pr-1263-finalize-calendar-a11y.yml b/.github/workflows/pr-1263-finalize-calendar-a11y.yml
deleted file mode 100644
index 3f5aee558..000000000
--- a/.github/workflows/pr-1263-finalize-calendar-a11y.yml
+++ /dev/null
@@ -1,213 +0,0 @@
-name: PR 1263 finalize calendar accessibility
-
-on:
- push:
- branches:
- - palette-ux-calendar-a11y-16466429643166483098
- paths:
- - .github/workflows/pr-1263-finalize-calendar-a11y.yml
-
-concurrency:
- group: pr-1263-finalize-calendar-a11y
- cancel-in-progress: false
-
-permissions:
- contents: read
-
-jobs:
- finalize:
- if: github.actor != 'github-actions[bot]'
- runs-on: ubuntu-latest
- timeout-minutes: 75
- permissions:
- contents: write
- env:
- DISABLE_BACKGROUND_WORKERS: "1"
- POSTCSS_WORKERS: "1"
- DISABLE_POSTCSS_WORKERS: "true"
- steps:
- - name: Harden the runner
- uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
- with:
- egress-policy: audit
-
- - name: Checkout the exact pull-request branch
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- ref: palette-ux-calendar-a11y-16466429643166483098
- fetch-depth: 0
- persist-credentials: true
-
- - name: Repair changelog and evidence documentation
- run: |
- python - <<'PY'
- from pathlib import Path
-
- changelog_path = Path("CHANGELOG.md")
- changelog = changelog_path.read_text(encoding="utf-8")
- bullet = "- [UX 개선] 캘린더 회의 조율 화면 제안 버튼의 접근성 향상 (스크린 리더 사용자를 위한 sr-only 텍스트 추가 및 불필요한 중복 텍스트 숨김)"
- duplicate = f"\n## [Unreleased]\n### Added\n{bullet}"
- if changelog.count(duplicate) != 1:
- raise SystemExit("Expected exactly one duplicate Unreleased accessibility block")
- changelog = changelog.replace(duplicate, f"\n{bullet}")
- changelog_path.write_text(changelog, encoding="utf-8")
- if changelog.count("## [Unreleased]") != 1:
- raise SystemExit("CHANGELOG must contain exactly one Unreleased section")
- PY
-
- cat > docs/doctoring/calendar-a11y.md <<'EOF'
- # Calendar Coordination View Accessibility
-
- `CalendarCoordinationView.tsx` presents numbered meeting proposals with date, time, attendance status, and a visible `제안하기` action label.
-
- ## Accessibility problem
-
- A short `aria-label` on the button would replace the descendant-derived accessible name and could omit the date, time, or attendance information needed to distinguish the proposals. Purely visual repetition can also make screen-reader output unnecessarily noisy.
-
- ## Implemented pattern
-
- Each button keeps its essential visible text in the accessibility tree and adds a visually hidden contextual prefix such as `1안 제안하기: `. The duplicated visual option badge and trailing action label use `aria-hidden="true"`. The native button role and existing `focus-visible` ring remain intact.
-
- This component pattern aligns with WCAG 2.2 Success Criterion 4.1.2, **Name, Role, Value**, and Success Criterion 2.4.7, **Focus Visible**. This scoped implementation statement does **not** establish conformance of the whole Naruon product.
-
- ## Research note
-
- Lazar et al. (2007) studied 100 blind web users and identified confusing screen-reader feedback and poorly designed or unlabeled controls among the leading sources of frustration. The proposal-button pattern therefore preserves task-specific context in the computed accessible name instead of relying on visual grouping alone.
-
- ## References
-
- Lazar, J., Allen, A., Kleinman, J., & Malarkey, C. (2007). What frustrates screen reader users on the web: A study of 100 blind users. *International Journal of Human–Computer Interaction, 22*(3), 247–269. https://doi.org/10.1080/10447310709336964
-
- World Wide Web Consortium. (2023a). *Understanding Success Criterion 2.4.7: Focus visible*. https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html
-
- World Wide Web Consortium. (2023b). *Understanding Success Criterion 4.1.2: Name, role, value*. https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html
- EOF
- sed -i 's/^ //' docs/doctoring/calendar-a11y.md
-
- - name: Add the accessible-name query dependency
- run: |
- corepack enable pnpm
- corepack prepare pnpm@11.5.3 --activate
- cd frontend
- pnpm add --save-dev --save-exact @testing-library/dom@10.4.1 --lockfile-only
- pnpm install --frozen-lockfile
- test "$(pnpm exec node -p \"require('@testing-library/dom/package.json').version\")" = "10.4.1"
- pnpm why @testing-library/dom
-
- - name: Replace implementation-detail assertions with accessible-name queries
- run: |
- cat > frontend/src/components/calendar/CalendarCoordinationView.test.tsx <<'EOF'
- /* @vitest-environment jsdom */
- import { getByRole } from "@testing-library/dom";
- import React, { act } from "react";
- import { createRoot, type Root } from "react-dom/client";
- import { afterEach, describe, expect, it } from "vitest";
- import { CalendarCoordinationView } from "./CalendarCoordinationView";
-
- describe("CalendarCoordinationView", () => {
- let container: HTMLDivElement | null = null;
- let root: Root | null = null;
-
- afterEach(() => {
- if (root && container) {
- act(() => {
- root!.unmount();
- });
- container.remove();
- }
- container = null;
- root = null;
- });
-
- it("exposes distinct proposal context through each computed accessible name", () => {
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
-
- act(() => {
- root!.render( );
- });
-
- const firstButton = getByRole(container, "button", {
- name: /^1안 제안하기:\s+5월 23일 \(목\) 14:00 - 15:00\s+모든 참석자 참석 가능$/,
- });
- const secondButton = getByRole(container, "button", {
- name: /^2안 제안하기:\s+5월 24일 \(금\) 10:00 - 11:00\s+1명\(김개발\) 불참 예상$/,
- });
-
- expect(firstButton).not.toBe(secondButton);
- expect(firstButton.className).toContain("focus-visible:ring-2");
- expect(secondButton.className).toContain("focus-visible:ring-2");
-
- const ariaHiddenElements = container.querySelectorAll(
- '[aria-hidden="true"]',
- );
- expect(ariaHiddenElements).toHaveLength(4);
- expect(
- Array.from(ariaHiddenElements).some(
- (element) => element.textContent === "1안",
- ),
- ).toBe(true);
- expect(
- Array.from(ariaHiddenElements).some(
- (element) => element.textContent === "2안",
- ),
- ).toBe(true);
- expect(
- Array.from(ariaHiddenElements).filter(
- (element) => element.textContent === "제안하기",
- ),
- ).toHaveLength(2);
- });
- });
- EOF
- sed -i 's/^ //' frontend/src/components/calendar/CalendarCoordinationView.test.tsx
-
- - name: Verify accessibility implementation and evidence
- run: |
- cd frontend
- pnpm exec vitest run src/components/calendar/CalendarCoordinationView.test.tsx
- pnpm run lint
- pnpm run typecheck
- pnpm run test
- pnpm run coverage
- pnpm run build
- cd ..
- python - <<'PY'
- from pathlib import Path
-
- changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
- documentation = Path("docs/doctoring/calendar-a11y.md").read_text(encoding="utf-8")
- test_source = Path(
- "frontend/src/components/calendar/CalendarCoordinationView.test.tsx"
- ).read_text(encoding="utf-8")
-
- assert changelog.count("## [Unreleased]") == 1
- assert "WCAG 2.2 Success Criterion 4.1.2" in documentation
- assert "Success Criterion 2.4.7" in documentation
- assert "does **not** establish conformance" in documentation
- assert "10.1080/10447310709336964" in documentation
- assert "getByRole(container, \"button\"" in test_source
- assert ".textContent).toContain" not in test_source
- PY
- git diff --check
-
- - name: Commit only verified permanent changes and remove this workflow
- run: |
- rm .github/workflows/pr-1263-finalize-calendar-a11y.yml
- git diff --check
- test "$(git status --short | wc -l)" -eq 6
- git status --short | grep -F ' M CHANGELOG.md'
- git status --short | grep -F ' M docs/doctoring/calendar-a11y.md'
- git status --short | grep -F ' M frontend/package.json'
- git status --short | grep -F ' M frontend/pnpm-lock.yaml'
- git status --short | grep -F ' M frontend/src/components/calendar/CalendarCoordinationView.test.tsx'
- git status --short | grep -F ' D .github/workflows/pr-1263-finalize-calendar-a11y.yml'
- git config user.name github-actions[bot]
- git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- git add -A -- CHANGELOG.md docs/doctoring/calendar-a11y.md frontend/package.json frontend/pnpm-lock.yaml frontend/src/components/calendar/CalendarCoordinationView.test.tsx .github/workflows/pr-1263-finalize-calendar-a11y.yml
- git diff --cached --check
- git commit -m "fix(a11y): verify calendar proposal accessible names"
- git push origin HEAD:palette-ux-calendar-a11y-16466429643166483098
-
-# Exact-head repair trigger; the workflow deletes itself after verified publication.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47850022f..3c1d8e544 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2719,6 +2719,4 @@
- **Note:** CI opencode-review 잡 실행 중 타임아웃 오류(The action 'Run OpenCode PR Review model pool' has timed out after 350 minutes)가 발생했습니다. 반복되는 외부 인프라 타임아웃 문제를 해결하기 위해, 마지막으로 재제출을 시도합니다.
- **Note:** 추가적인 코드 변경은 없으며, PR 내 자동 분석 커멘트에 대한 답변(CI 실패가 본 PR이 아닌 develop의 기존 이슈임을 인지함)을 남기고 현재 워크플로우를 완료합니다.
-## [Unreleased]
-### Added
- [UX 개선] 캘린더 회의 조율 화면 제안 버튼의 접근성 향상 (스크린 리더 사용자를 위한 sr-only 텍스트 추가 및 불필요한 중복 텍스트 숨김)
diff --git a/docs/doctoring/calendar-a11y.md b/docs/doctoring/calendar-a11y.md
index 0bd441861..a78d17cb2 100644
--- a/docs/doctoring/calendar-a11y.md
+++ b/docs/doctoring/calendar-a11y.md
@@ -1,13 +1,25 @@
# Calendar Coordination View Accessibility
-In `CalendarCoordinationView.tsx`, the proposal buttons were initially designed with visual content representing numbered options (e.g., "1안"), date/time, attendance status, and a generic action text ("제안하기").
+`CalendarCoordinationView.tsx` presents numbered meeting proposals with date, time, attendance status, and a visible `제안하기` action label.
-**Accessibility Problem:**
-Using an `aria-label` directly on the `` element completely overrides its accessible name, discarding all the descendant text content that might be crucial for context. If we used `aria-label="1안 제안하기"`, a screen reader user would miss the date, time, and attendance status.
+## Accessibility problem
-**Solution:**
-Instead of `aria-label`, we use a visually hidden element (`1안 제안하기: `) inside the button structure alongside the visible text. Furthermore, we add `aria-hidden="true"` to purely decorative or redundant visual elements (like the visible "1안" badge and the generic "제안하기" label).
+A short `aria-label` on the button would replace the descendant-derived accessible name and could omit the date, time, or attendance information needed to distinguish the proposals. Purely visual repetition can also make screen-reader output unnecessarily noisy.
-This ensures the computed accessible name sequentially combines the `sr-only` context and the essential visible date and attendance information, conforming with Web Content Accessibility Guidelines (WCAG) 2.2 for accessible names and focus indicators (buttons retain `focus-visible` styles).
+## Implemented pattern
-Reference: W3C Web Accessibility Initiative. (2023). Web Content Accessibility Guidelines (WCAG) 2.2. W3C.
+Each button keeps its essential visible text in the accessibility tree and adds a visually hidden contextual prefix such as `1안 제안하기: `. The duplicated visual option badge and trailing action label use `aria-hidden="true"`. The native button role and existing `focus-visible` ring remain intact.
+
+This component pattern aligns with WCAG 2.2 Success Criterion 4.1.2, **Name, Role, Value**, and Success Criterion 2.4.7, **Focus Visible**. This scoped implementation statement does **not** establish conformance of the whole Naruon product.
+
+## Research note
+
+Lazar et al. (2007) studied 100 blind web users and identified confusing screen-reader feedback and poorly designed or unlabeled controls among the leading sources of frustration. The proposal-button pattern therefore preserves task-specific context in the computed accessible name instead of relying on visual grouping alone.
+
+## References
+
+Lazar, J., Allen, A., Kleinman, J., & Malarkey, C. (2007). What frustrates screen reader users on the web: A study of 100 blind users. *International Journal of Human–Computer Interaction, 22*(3), 247–269. https://doi.org/10.1080/10447310709336964
+
+World Wide Web Consortium. (2023a). *Understanding Success Criterion 2.4.7: Focus visible*. https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html
+
+World Wide Web Consortium. (2023b). *Understanding Success Criterion 4.1.2: Name, role, value*. https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html
From e0be20369acacffce2b64ade13db9d3f8f0e963e Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:26:47 +0000
Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[=EC=A0=91?=
=?UTF-8?q?=EA=B7=BC=EC=84=B1]=20=ED=9A=8C=EC=9D=98=20=EC=A1=B0=EC=9C=A8?=
=?UTF-8?q?=20=EC=A0=9C=EC=95=88=20=EB=B2=84=ED=8A=BC=20=EB=A7=A5=EB=9D=BD?=
=?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(=EB=A6=AC=EB=B7=B0=20=EB=B0=98?=
=?UTF-8?q?=EC=98=81=20=EB=B0=8F=20CI=20=EC=88=98=EC=A0=95)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/app-ci.yml | 13 ++++++----
.github/workflows/bandit.yml | 8 ++++---
.github/workflows/dependency-review.yml | 2 +-
.github/workflows/deploy.yml | 2 +-
.github/workflows/docker-publish.yml | 10 +++++---
.github/workflows/mail-smoke.yml | 6 +++--
CHANGELOG.md | 8 +++++++
backend/tests/test_release_governance.py | 22 +++++++++++++++--
.../bandit-b506-false-positive-disposition.md | 24 +++++++++++++++++++
9 files changed, 78 insertions(+), 17 deletions(-)
create mode 100644 docs/doctoring/bandit-b506-false-positive-disposition.md
diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml
index d6a17f49a..e8f445748 100644
--- a/.github/workflows/app-ci.yml
+++ b/.github/workflows/app-ci.yml
@@ -35,10 +35,12 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Set up Python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
cache: pip
@@ -84,14 +86,15 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
-
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Install pnpm
run: corepack enable pnpm
- name: Set up Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: pnpm
diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml
index 58c486b5b..c5c613c08 100644
--- a/.github/workflows/bandit.yml
+++ b/.github/workflows/bandit.yml
@@ -22,10 +22,12 @@ jobs:
with:
egress-policy: audit
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Set up Python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
@@ -38,7 +40,7 @@ jobs:
- name: Upload SARIF file
if: ${{ always() }}
- uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
+ uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
sarif_file: bandit-results.sarif
category: bandit
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 27fde0dc0..c303d1e61 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -29,7 +29,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 7ea854dc1..8d0ca1ed8 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -20,7 +20,7 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 54652af73..879b906ec 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -54,7 +54,9 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
@@ -178,7 +180,9 @@ jobs:
egress-policy: audit
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Read release version
id: version
@@ -248,7 +252,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
diff --git a/.github/workflows/mail-smoke.yml b/.github/workflows/mail-smoke.yml
index cfa94f5a0..6a3a3fdc9 100644
--- a/.github/workflows/mail-smoke.yml
+++ b/.github/workflows/mail-smoke.yml
@@ -30,10 +30,12 @@ jobs:
${{ vars.MAIL_SMOKE_ALLOWED_ENDPOINTS }}
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Set up Python
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: pip
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c1d8e544..3e217792b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2720,3 +2720,11 @@
- **Note:** 추가적인 코드 변경은 없으며, PR 내 자동 분석 커멘트에 대한 답변(CI 실패가 본 PR이 아닌 develop의 기존 이슈임을 인지함)을 남기고 현재 워크플로우를 완료합니다.
- [UX 개선] 캘린더 회의 조율 화면 제안 버튼의 접근성 향상 (스크린 리더 사용자를 위한 sr-only 텍스트 추가 및 불필요한 중복 텍스트 숨김)
+
+### 변경 사항 (Changes)
+
+- `backend/tests/test_release_governance.py` 파일의 394번째 줄에서 `yaml.load` 함수 사용 시 발생하는 Bandit B506 오탐지를 억제하기 위해 `# nosec B506` 주석을 추가했습니다. 해당 코드는 `yaml.SafeLoader`를 상속받은 `UniqueKeyLoader`를 사용하므로 실제로는 안전합니다. 이 변경은 보안 취약점 픽스가 아닌, 정적 분석 툴의 오탐지를 처리하기 위한 조치입니다.
+
+### 문서 (Documentation)
+
+- `yaml.load()`와 관련해 발생한 Bandit B506 항목에 대해 규칙 한정적 오탐지(false-positive) 판정 및 처분 근거(disposition)를 담은 `docs/doctoring/bandit-b506-false-positive-disposition.md` 문서를 추가했습니다. 이는 제품의 실제 취약점 패치가 아니며, PyYAML의 `SafeLoader`를 명시적으로 사용하는 사용자 정의 로더에 대해 오탐지를 억제하는 조건과 롤백 기준을 테스트 증거와 함께 기록한 문서입니다.
diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py
index 56ef2bff4..efe0acd0e 100644
--- a/backend/tests/test_release_governance.py
+++ b/backend/tests/test_release_governance.py
@@ -388,10 +388,28 @@ def construct_mapping(
construct_mapping,
)
+ # Verify that UniqueKeyLoader is strictly a subclass of SafeLoader so that `# nosec B506`
+ # suppression is genuinely justified according to PyYAML safety contracts.
+ assert issubclass(UniqueKeyLoader, yaml.SafeLoader), (
+ "UniqueKeyLoader must inherit from SafeLoader to suppress B506"
+ )
+ # Ensure that Python object instantiation tags (like !!python/object) are safely
+ # rejected rather than executed.
+ with pytest.raises(yaml.constructor.ConstructorError):
+ yaml.load("!!python/object/apply:os.system ['echo pwned']", Loader=UniqueKeyLoader) # nosec B506
+ # Ensure normal valid YAML loading still works
+ assert yaml.load("a: 1\nb: 2", Loader=UniqueKeyLoader) == {"a": 1, "b": 2} # nosec B506
+ # Ensure the duplicate key prevention still works
+ with pytest.raises(AssertionError, match="duplicate mapping key 'a'"):
+ yaml.load("a: 1\na: 2", Loader=UniqueKeyLoader) # nosec B506
+
duplicates: list[str] = []
for workflow_path in governed_workflows:
try:
- yaml.load(workflow_path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader)
+ # We explicitly pass UniqueKeyLoader (which inherits from SafeLoader).
+ # Bandit B506 blindly flags yaml.load() regardless of the Loader argument.
+ # This is a verified false positive.
+ yaml.load(workflow_path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) # nosec B506
except AssertionError as exc:
duplicates.append(f"{workflow_path.relative_to(REPO_ROOT)}: {exc}")
@@ -716,7 +734,7 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_
== 2
)
assert (
- "docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0"
+ "docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0"
in workflow
)
assert (
diff --git a/docs/doctoring/bandit-b506-false-positive-disposition.md b/docs/doctoring/bandit-b506-false-positive-disposition.md
new file mode 100644
index 000000000..28d0e9099
--- /dev/null
+++ b/docs/doctoring/bandit-b506-false-positive-disposition.md
@@ -0,0 +1,24 @@
+# False Positive Disposition: Bandit B506 (`yaml.load`)
+
+## Context and Evidence
+Bandit reports a Medium severity B506 issue on `yaml.load()` calls because using the default loader can permit the instantiation of arbitrary Python objects, posing a security risk (PyCQA, 2024). However, in `backend/tests/test_release_governance.py`, `yaml.load` is explicitly invoked with `Loader=UniqueKeyLoader`.
+
+The local implementation explicitly defines `UniqueKeyLoader` as a subclass of `yaml.SafeLoader`:
+```python
+class UniqueKeyLoader(yaml.SafeLoader):
+ pass
+```
+
+Because `UniqueKeyLoader` inherits from `yaml.SafeLoader`, it automatically inherits all safety constraints, explicitly rejecting unsafe tags (e.g., `!!python/object/apply`). Tests in `test_release_governance.py` verify that `issubclass(UniqueKeyLoader, yaml.SafeLoader)` is true and that malicious YAML payloads are correctly rejected via `yaml.constructor.ConstructorError` rather than being executed (PyYAML, 2024).
+
+Therefore, this finding is a verified false positive caused by a limitation in Bandit's static analysis, which triggers on the `yaml.load` function name without evaluating the inheritance chain of the provided `Loader` argument.
+
+## Resolution
+The `yaml.load` call has been annotated with `# nosec B506` to suppress the false positive locally. We retain this suppression strictly under the condition that `UniqueKeyLoader` remains a subclass of `yaml.SafeLoader` and is explicitly provided to `yaml.load`.
+
+## Rollback Criteria
+If the YAML loader implementation is modified to inherit from an unsafe loader, or if `yaml.load` is used without explicitly providing the safe custom loader, this disposition must be revoked and the `# nosec B506` annotation removed.
+
+## References
+PyCQA. (2024). *B506: Test for use of yaml load*. Bandit Documentation. https://bandit.readthedocs.io/en/latest/plugins/b506_yaml_load.html
+PyYAML. (2024). *PyYAML Documentation: Loading YAML safely*. https://pyyaml.org/wiki/PyYAMLDocumentation#loading-yaml-safely