diff --git a/.github/workflows/hourly-commercial-maintenance.yml b/.github/workflows/hourly-commercial-maintenance.yml new file mode 100644 index 00000000..a4be5391 --- /dev/null +++ b/.github/workflows/hourly-commercial-maintenance.yml @@ -0,0 +1,36 @@ +name: Hourly Commercial Maintenance + +on: + schedule: + - cron: "41 * * * *" + workflow_dispatch: + inputs: + dry_run: + description: Inspect the queue without dispatching a repair + required: false + default: false + type: boolean + +concurrency: + group: hourly-commercial-maintenance-${{ github.repository }} + cancel-in-progress: true + +permissions: {} + +jobs: + review-repair: + permissions: + actions: read + contents: read + id-token: write + issues: read + pull-requests: read + statuses: read + uses: ContextualWisdomLab/.github/.github/workflows/nvidia-nim-pr-maintenance.yml@2ebda8063d3c541e2552dc474c3c8601ddc986f8 + with: + target_repository: ContextualWisdomLab/newsdom-api + base_branch: develop + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + max_prs: "50" + max_dispatches: "1" + retry_hours: "1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 437c75fe..3c50e87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OpenAPI 제목/설명, README, `ArticleNode.headline` 문서를 일반 문서용 (section heading) 표현으로 재구성하여 특정 언어/신문 가정을 소비자에게 노출하지 않도록 함. 응답 스키마 필드는 하위 호환을 위해 변경하지 않음. ### Added +- 매시간 조직 중앙의 immutable NVIDIA NIM OpenCode 유지보수 workflow를 호출하여 열린 PR의 file-scoped 리뷰 수정 작업을 한 번에 하나씩 재시도하는 상용 유지보수 루프를 추가했습니다. Leaf workflow는 `COPILOT_GITHUB_TOKEN`이나 모델 키를 보유하지 않으며, 모든 수정 head는 기존 독립 리뷰와 필수 Checks를 다시 통과해야 합니다. - [UX/DX] OpenAPI Swagger 문서 이해도 향상을 위해 `ParseQuality` 및 `HealthResponse` 모델에 구체적인 `json_schema_extra` 예제 추가. (Palette) - [CLI] 단일 NewsDOM JSON 파일을 페이지 단위로 분리하는 `tools/split_dom.py` 도구를 추가했습니다. - [CLI] NewsDOM JSON 파일의 모든 텍스트 내용을 마스킹하여 익명화하는 `tools/anonymize_dom.py` 도구를 추가했습니다. diff --git a/docs/operations/hourly-commercial-maintenance.md b/docs/operations/hourly-commercial-maintenance.md new file mode 100644 index 00000000..2269080d --- /dev/null +++ b/docs/operations/hourly-commercial-maintenance.md @@ -0,0 +1,56 @@ +# Hourly commercial maintenance + +## Purpose + +NewsDOM invokes the organization-owned pull-request maintenance control plane once per hour. The leaf repository supplies only its repository identity, `develop` integration branch, cadence, and bounded queue limits. Review parsing, exact-head selection, repair dispatch, credential separation, and OpenCode model configuration remain owned by `ContextualWisdomLab/.github`. + +## Schedule and single-flight behavior + +- Schedule: minute 41 of every hour. +- Maximum inspected pull requests: 50. +- Maximum repair dispatches per run: 1. +- Same-head repair retry floor: 1 hour. +- Concurrency: one active run per repository; a newer scheduler invocation cancels an older scheduler invocation before another repair can be dispatched. +- Manual mode: `workflow_dispatch` exposes `dry_run`, which reads the queue and records decisions without dispatching a worker. + +The central worker uses OpenCode with the organization secret `NVIDIA_NIM_API_KEY`. The leaf workflow never receives that secret and never uses `COPILOT_GITHUB_TOKEN`. GitHub transport authorization is obtained centrally through the existing OpenCode App OIDC exchange or an explicitly declared maintenance token fallback. The read-only OpenCode and Noema review workflows are unchanged. + +## Merge and safety boundaries + +The hourly workflow does not merge a pull request. It only dispatches a conservative repair when the current head has actionable, file-scoped review evidence or meets the central conflict-resolution contract. A repair: + +1. revalidates the live base and head SHAs; +2. accepts only same-repository heads; +3. gives OpenCode access only to the approved paths; +4. denies model shell, task, network, LSP, and external-directory access; +5. refuses a push if the head moves; +6. produces a new head that must pass all repository checks and independent review; and +7. cannot approve, publish, release, or weaken branch protection. + +## MSA boundary + +This workflow is a leaf adapter, not a second implementation. It pins the reusable central workflow by full commit SHA. Other CWL components can adopt the same central component while supplying their own target repository, default branch, and schedule. NewsDOM continues to operate independently as a FastAPI PDF-to-DOM service and as a naruon-compatible sidecar. + +## Operational prerequisites + +- The organization OpenCode GitHub App must be able to read and write the target pull-request head. +- The central `.github` repository must expose the pinned reusable workflow and worker commit. +- `NVIDIA_NIM_API_KEY` must be available to the central worker repository. +- Required checks and independent-review rules remain active. + +If the central app-token exchange is unavailable and no optional maintenance token is configured, cross-repository dispatch fails closed. It does not fall back to Copilot or another inference provider. + +## Verification + +Before enabling the schedule on `develop`, verify: + +- the caller pins exactly one 40-character central commit SHA; +- no model or Copilot credential appears in the leaf workflow; +- the workflow grants no `contents: write` or `pull-requests: write` permission; +- static contracts pass under the repository's 100% coverage gate; +- the central workflow passes its own unit, security, and independent-review gates; and +- one manual dry run records a bounded no-write queue decision. + +## Rollback + +Delete or disable `.github/workflows/hourly-commercial-maintenance.yml`. This does not affect application runtime, existing review workflows, branch protection, or manually initiated maintenance. Revert only the leaf adapter; the central component can remain available to other repositories. diff --git a/tests/test_hourly_commercial_maintenance.py b/tests/test_hourly_commercial_maintenance.py new file mode 100644 index 00000000..3c58de5b --- /dev/null +++ b/tests/test_hourly_commercial_maintenance.py @@ -0,0 +1,99 @@ +"""Contracts for NewsDOM's hourly commercial-maintenance caller.""" + +from __future__ import annotations + +from pathlib import Path +import re + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/hourly-commercial-maintenance.yml" + + +def _workflow_text() -> str: + """Return the tracked hourly workflow text.""" + + return WORKFLOW_PATH.read_text(encoding="utf-8") + + +def test_hourly_cadence_is_single_flight() -> None: + """The repository should request one non-overlapping maintenance run each hour.""" + + workflow = _workflow_text() + + assert 'cron: "41 * * * *"' in workflow + assert "hourly-commercial-maintenance-${{ github.repository }}" in workflow + assert "cancel-in-progress: true" in workflow + assert 'max_dispatches: "1"' in workflow + assert 'retry_hours: "1"' in workflow + + +def test_caller_targets_newsdom_develop_through_immutable_central_source() -> None: + """The leaf workflow must retain only local routing and pin central behavior by SHA.""" + + workflow = _workflow_text() + match = re.search( + r"uses: ContextualWisdomLab/\.github/\.github/workflows/" + r"nvidia-nim-pr-maintenance\.yml@([0-9a-f]{40})", + workflow, + ) + + assert match is not None + assert match.group(1) == "2ebda8063d3c541e2552dc474c3c8601ddc986f8" + assert "target_repository: ContextualWisdomLab/newsdom-api" in workflow + assert "base_branch: develop" in workflow + assert "uses: ./.github/workflows/" not in workflow + assert "@main" not in workflow + + +def test_caller_grants_only_read_and_oidc_permissions() -> None: + """The leaf token must be read-only except for the OIDC identity exchange.""" + + workflow = _workflow_text() + + assert "permissions: {}" in workflow + required_permissions = ( + "actions: read", + "contents: read", + "id-token: write", + "issues: read", + "pull-requests: read", + "statuses: read", + ) + for permission in required_permissions: + assert permission in workflow + + forbidden_permissions = ( + "actions: write", + "contents: write", + "issues: write", + "pull-requests: write", + "statuses: write", + ) + for permission in forbidden_permissions: + assert permission not in workflow + + assert "secrets: inherit" not in workflow + + +def test_caller_never_uses_copilot_or_model_credentials() -> None: + """Inference credentials belong to the central NIM worker, not the leaf scheduler.""" + + workflow = _workflow_text() + + assert "COPILOT_GITHUB_TOKEN" not in workflow + assert "NVIDIA_NIM_API_KEY" not in workflow + assert "NVIDIA_API_KEY" not in workflow + assert "STRIX_GITHUB_MODELS_TOKEN" not in workflow + assert "models.github.ai" not in workflow + + +def test_manual_dry_run_is_available_without_changing_schedule_behavior() -> None: + """Operators should be able to inspect the queue without dispatching repairs.""" + + workflow = _workflow_text() + + assert "workflow_dispatch:" in workflow + assert "dry_run:" in workflow + assert "type: boolean" in workflow + assert "github.event_name == 'workflow_dispatch'" in workflow diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py index d582e3fc..7b8b414e 100644 --- a/tests/test_workflow_runtime_env.py +++ b/tests/test_workflow_runtime_env.py @@ -9,12 +9,15 @@ def _workflow_paths() -> list[Path]: ) -def test_each_workflow_job_forces_javascript_actions_to_node24(): +def test_each_direct_workflow_job_forces_javascript_actions_to_node24(): for workflow_path in _workflow_paths(): data = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) for job_name, job_data in data["jobs"].items(): if workflow_path.name in {"scorecards.yml", "gh-pages.yml"}: continue + if "uses" in job_data: + assert "env" not in job_data, (workflow_path, job_name) + continue assert job_data["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] is True, ( workflow_path, job_name,