From 243d0eb6558c220b40f3906a198ff270daec2520 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 19:53:03 +0900 Subject: [PATCH 01/10] ci(workflows): use central reusable dependency-review.yml Replace this repo's hand-written dependency-review.yml with a thin caller into ContextualWisdomLab/.github's new workflow_call workflow, preserving this repo's exact original policy (unset fail-on-severity -> action default "low", allow-ghsas: GHSA-69w3-r845-3855). This repo's FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 opt-in is now applied uniformly inside the central workflow itself (the other three callers previously lacked it), so it is no longer needed here. Adds the cancel-in-progress concurrency group this workflow previously lacked. See ContextualWisdomLab/.github#1724 and its docs/adr/0024-.../ docs/doctoring/dependency-review-reusable-workflow-consolidation.md for the full audit and design rationale. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/dependency-review.yml | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f33b5a35..94b85a69 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -3,21 +3,13 @@ name: dependency-review on: pull_request: -permissions: - contents: read - pull-requests: read +concurrency: + group: dependency-review-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: dependency-review: - name: dependency-review - runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: Dependency review - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 - with: - allow-ghsas: "GHSA-69w3-r845-3855" + uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@main + with: + fail_on_severity: low + allow_ghsas: "GHSA-69w3-r845-3855" From c5056381adb1a4a75bcea37d8994facfb7bfb0d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:07:17 +0900 Subject: [PATCH 02/10] test(ci): require immutable central dependency-review workflow --- tests/test_readme.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_readme.py b/tests/test_readme.py index 8dbf5645..c579da95 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -1,5 +1,7 @@ from pathlib import Path +CENTRAL_DEPENDENCY_REVIEW_WORKFLOW_SHA = "0bcd22d8bb07650aafb0a8f116e4c2bbb8744f03" + def test_readme_points_to_user_and_maintainer_docs(): text = Path("README.md").read_text(encoding="utf-8") @@ -64,5 +66,18 @@ def test_security_workflows_exist(): assert Path(".github/workflows/dependency-review.yml").exists() +def test_dependency_review_uses_immutable_central_workflow(): + workflow = Path(".github/workflows/dependency-review.yml").read_text(encoding="utf-8") + expected = ( + "ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@" + f"{CENTRAL_DEPENDENCY_REVIEW_WORKFLOW_SHA}" + ) + + assert expected in workflow + assert "dependency-review.yml@main" not in workflow + assert "fail_on_severity: low" in workflow + assert 'allow_ghsas: "GHSA-69w3-r845-3855"' in workflow + + def test_quality_gate_workflow_exists(): assert Path(".github/workflows/quality-gate.yml").exists() From 1623977e6c37c78cb1a94a7a48c48f6d02cac86c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:07:25 +0900 Subject: [PATCH 03/10] fix(ci): pin reusable dependency review to protected source --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 94b85a69..20722721 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -9,7 +9,7 @@ concurrency: jobs: dependency-review: - uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@main + uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@0bcd22d8bb07650aafb0a8f116e4c2bbb8744f03 with: fail_on_severity: low allow_ghsas: "GHSA-69w3-r845-3855" From c70a2c6fbbfa2b8d472fd5b62458fb1ae2d9a234 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:17:00 +0900 Subject: [PATCH 04/10] test(ci): preserve reusable workflow token permissions --- tests/test_readme.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_readme.py b/tests/test_readme.py index c579da95..df84fc37 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -75,6 +75,7 @@ def test_dependency_review_uses_immutable_central_workflow(): assert expected in workflow assert "dependency-review.yml@main" not in workflow + assert "permissions:\n contents: read\n pull-requests: read" in workflow assert "fail_on_severity: low" in workflow assert 'allow_ghsas: "GHSA-69w3-r845-3855"' in workflow From 9a798d5ac7b9b295a1accb2327fc76611352290f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:17:13 +0900 Subject: [PATCH 05/10] fix(ci): retain caller permissions for reusable dependency review --- .github/workflows/dependency-review.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 20722721..f801c978 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -3,6 +3,10 @@ name: dependency-review on: pull_request: +permissions: + contents: read + pull-requests: read + concurrency: group: dependency-review-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true From 4cff2208ed962a5fb95b7f49c49ec92572ac3d90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:39:08 +0900 Subject: [PATCH 06/10] ci(workflows): re-pin to the SHA with harden-runner + comment_summary_in_pr A peer session's org-wide survey found naruon independently carrying its own dependency-review.yml with a harden-runner egress-audit step this consolidation's original four callers lacked. That step (and the comment_summary_in_pr input naruon's explicit "never" choice required) were added to the central reusable workflow in ContextualWisdomLab/.github#1732, after this caller's original PR opened. Re-pinning to the new commit picks up harden-runner for free before this PR's first merge -- no `with:` change needed, since this repo never set comment_summary_in_pr and the new input's default ("on-failure") matches the value this reusable workflow already hardcoded before. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f801c978..04d6aa8c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,7 +13,7 @@ concurrency: jobs: dependency-review: - uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@0bcd22d8bb07650aafb0a8f116e4c2bbb8744f03 + uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@5f8e5b2a79e709c4ab1a4179a605d34c458b13a1 with: fail_on_severity: low allow_ghsas: "GHSA-69w3-r845-3855" From b14586c218bb60e614136bef94e9fd8163f4d4b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 00:05:17 +0900 Subject: [PATCH 07/10] fix(test): sync CENTRAL_DEPENDENCY_REVIEW_WORKFLOW_SHA with the pinned workflow SHA The prior commit (4cff220) re-pinned dependency-review.yml's uses: to 5f8e5b2a79e709c4ab1a4179a605d34c458b13a1 (harden-runner + comment_summary_in_pr, .github#1732) but missed the matching test constant, leaving it asserting the old 0bcd22d8... SHA -- test_dependency_review_uses_immutable_central_workflow was failing. Caught by CodeRabbit's second review on this PR. Co-Authored-By: Claude Sonnet 5 --- tests/test_readme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_readme.py b/tests/test_readme.py index df84fc37..ab1f25e7 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -1,6 +1,6 @@ from pathlib import Path -CENTRAL_DEPENDENCY_REVIEW_WORKFLOW_SHA = "0bcd22d8bb07650aafb0a8f116e4c2bbb8744f03" +CENTRAL_DEPENDENCY_REVIEW_WORKFLOW_SHA = "5f8e5b2a79e709c4ab1a4179a605d34c458b13a1" def test_readme_points_to_user_and_maintainer_docs(): From 3a5bb19eafa3187eba26f5ccb1a6ecb0c9c79e3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 10:01:24 +0900 Subject: [PATCH 08/10] ci(dependency-review): opt out of comment_summary_in_pr instead of implicitly needing write CodeRabbit: the central reusable workflow's default comment_summary_in_pr ("on-failure") forwards to dependency-review-action's comment-summary-in-pr, which needs pull-requests: write to post a PR comment. This caller only grants pull-requests: read, so an actual dependency-review failure would attempt to comment without permission. No PR summary comment is needed here -- explicitly opting out with "never" matches the already-declared read-only permission instead of escalating to write for a feature nothing here uses. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/dependency-review.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 04d6aa8c..c9cb8285 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,3 +17,11 @@ jobs: with: fail_on_severity: low allow_ghsas: "GHSA-69w3-r845-3855" + # The central workflow's default comment_summary_in_pr ("on-failure") + # forwards to dependency-review-action's comment-summary-in-pr, which + # needs pull-requests: write to post a PR comment. This caller only + # grants pull-requests: read, so an actual failure would try to comment + # without permission (CodeRabbit). No PR summary comment is needed here + # -- the job's own pass/fail status is the signal -- so this opts out + # explicitly rather than escalating to write. + comment_summary_in_pr: never From 2ba859e4444edaf2a7c93b377b5cee429c88ff3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 22:12:19 +0900 Subject: [PATCH 09/10] fix(tests): exempt reusable-workflow-call jobs from Node24 env check A job shaped as `uses: owner/repo/.github/workflows/x.yml@sha` cannot carry an `env:` key at all -- GitHub Actions' schema for that job shape only allows name/needs/if/permissions/secrets/strategy/uses/with. The dependency-review job added in this PR calls .github's central reusable workflow this way, and that called workflow's own job already sets FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 itself, so the safety property still holds -- it just can't be expressed on the caller. Co-Authored-By: Claude Sonnet 5 --- tests/test_workflow_runtime_env.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py index d73660c2..4f32ff72 100644 --- a/tests/test_workflow_runtime_env.py +++ b/tests/test_workflow_runtime_env.py @@ -15,6 +15,18 @@ def test_each_workflow_job_forces_javascript_actions_to_node24(): 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: + # A reusable-workflow-call job (`uses: owner/repo/.github/ + # workflows/x.yml@sha`) does not run any steps of its own -- + # GitHub Actions' schema for this job shape supports only + # name/needs/if/permissions/secrets/strategy/uses/with, not + # env, so it cannot even be given this key. The JavaScript + # actions this convention protects against run inside the + # CALLED workflow's own job, which is that workflow's own + # test's responsibility, not this caller's -- confirmed here + # that .github's dependency-review.yml's own `dependency- + # review` job sets FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true. + continue assert job_data["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] is True, ( workflow_path, job_name, From 3ba1add4088e7e328feda5f277c9a2e98327de9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:02:09 +0900 Subject: [PATCH 10/10] ci(actions): consolidate PR workflow ownership Remove duplicate local PR gates, preserve scheduled security backstops, and scope remaining concurrency by workflow, repository, and PR or ref. Signed-off-by: Seongho Bae --- .github/workflows/build-ci-image.yml | 6 +- .github/workflows/clusterfuzzlite.yml | 8 ++ .github/workflows/codeql.yml | 5 +- .github/workflows/container-image.yml | 8 ++ .github/workflows/dependency-review.yml | 27 ----- .github/workflows/gh-pages.yml | 4 +- .github/workflows/quality-gate.yml | 37 ------ .github/workflows/release.yml | 4 + .github/workflows/scorecards.yml | 8 +- .github/workflows/tests.yml | 8 +- AGENTS.md | 6 +- CONTRIBUTING.md | 7 -- README.md | 17 ++- .../actions-consolidation-20260904.md | 42 +++++++ tests/test_readme.py | 34 ++---- tests/test_repository_governance.py | 22 +--- tests/test_truth_source_alignment.py | 12 -- tests/test_workflow_runtime_env.py | 12 -- tests/test_workflow_security.py | 19 +-- tests/test_workflows.py | 111 ++++++++++++++++-- 20 files changed, 216 insertions(+), 181 deletions(-) delete mode 100644 .github/workflows/dependency-review.yml delete mode 100644 .github/workflows/quality-gate.yml create mode 100644 docs/doctoring/actions-consolidation-20260904.md diff --git a/.github/workflows/build-ci-image.yml b/.github/workflows/build-ci-image.yml index bb6cc954..b7f0b34b 100644 --- a/.github/workflows/build-ci-image.yml +++ b/.github/workflows/build-ci-image.yml @@ -11,6 +11,10 @@ on: - 'Dockerfile.test' - '.github/workflows/build-ci-image.yml' +concurrency: + group: build-ci-environment-image-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + # Least-privilege default: read-only GITHUB_TOKEN at the top level. # packages:write is granted only to the job that pushes to GHCR (Scorecard TokenPermissions, alert #39). permissions: @@ -42,7 +46,7 @@ jobs: - name: Lowercase repository owner id: lowercase_owner run: | - echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_OUTPUT + echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" - name: Extract metadata for Docker id: meta diff --git a/.github/workflows/clusterfuzzlite.yml b/.github/workflows/clusterfuzzlite.yml index b70a6263..d3a88495 100644 --- a/.github/workflows/clusterfuzzlite.yml +++ b/.github/workflows/clusterfuzzlite.yml @@ -2,8 +2,16 @@ name: clusterfuzzlite on: pull_request: + paths-ignore: + - "docs/**" + - "manual/**" + - "**.md" workflow_dispatch: +concurrency: + group: clusterfuzzlite-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 176559fb..0e69c898 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,10 +3,13 @@ name: codeql on: push: branches: [main, develop] - pull_request: schedule: - cron: '43 5 * * 1' +concurrency: + group: codeql-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false + permissions: actions: read contents: read diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index 4b027fba..dc00e399 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -2,6 +2,10 @@ name: container-image on: pull_request: + paths-ignore: + - "docs/**" + - "manual/**" + - "**.md" push: tags: - 'v*' @@ -12,6 +16,10 @@ on: required: false default: 'false' +concurrency: + group: container-image-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + permissions: contents: read diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index c9cb8285..00000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: dependency-review - -on: - pull_request: - -permissions: - contents: read - pull-requests: read - -concurrency: - group: dependency-review-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - dependency-review: - uses: ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@5f8e5b2a79e709c4ab1a4179a605d34c458b13a1 - with: - fail_on_severity: low - allow_ghsas: "GHSA-69w3-r845-3855" - # The central workflow's default comment_summary_in_pr ("on-failure") - # forwards to dependency-review-action's comment-summary-in-pr, which - # needs pull-requests: write to post a PR comment. This caller only - # grants pull-requests: read, so an actual failure would try to comment - # without permission (CodeRabbit). No PR summary comment is needed here - # -- the job's own pass/fail status is the signal -- so this opts out - # explicitly rather than escalating to write. - comment_summary_in_pr: never diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 1fbfe6c7..0886a56f 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -18,8 +18,8 @@ permissions: contents: read concurrency: - group: github-pages - cancel-in-progress: true + group: deploy-web-manual-to-github-pages-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false jobs: build: diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml deleted file mode 100644 index bf3a55a9..00000000 --- a/.github/workflows/quality-gate.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: quality-gate - -on: - push: - branches: [main, develop] - pull_request: - -permissions: - contents: read - -jobs: - quality-gate: - name: quality-gate - runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: '3.10' - - - name: Setup uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 - with: - version: '0.11.3' - - - name: Install package - run: uv sync --locked --extra dev - - - name: Run quality gate - env: - PYTHONWARNINGS: error - run: uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d81f9ca..eaf7210d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,10 @@ on: - 'v*' workflow_dispatch: +concurrency: + group: release-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false + permissions: contents: read diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index c183c23b..eaeea2fc 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -3,10 +3,13 @@ name: scorecards on: push: branches: [develop] - pull_request: schedule: - cron: '31 5 * * 1' +concurrency: + group: scorecards-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false + permissions: {} jobs: @@ -32,10 +35,9 @@ jobs: repo_token: ${{ secrets.SCORECARD_TOKEN || github.token }} results_file: results.sarif results_format: sarif - publish_results: ${{ github.event_name != 'pull_request' }} + publish_results: true - name: Upload SARIF results - if: github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 88574c0f..0e0fb8b7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,10 +1,12 @@ name: tests on: - push: - branches: [main, develop] pull_request: +concurrency: + group: tests-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read packages: read @@ -35,4 +37,4 @@ jobs: env: PYTHONWARNINGS: error PYTHONPATH: src - run: uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 \ No newline at end of file + run: uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100 diff --git a/AGENTS.md b/AGENTS.md index 1f232d36..26aba848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,9 +138,9 @@ follow it. DB-backed KV is fine) unless a dedicated KV is adopted. - **This repo today:** no runtime secrets or credentials — it holds no API keys, no DB creds, and makes no authenticated external calls (it - shells out to a local MinerU binary). CI secrets are only the - standard `GITHUB_TOKEN` / `SCORECARD_TOKEN`, which are build-time, - not runtime app secrets. + shells out to a local MinerU binary). CI uses the standard `GITHUB_TOKEN` + and an optional `SCORECARD_TOKEN` for the scheduled Scorecard backstop; + both are build-time, not runtime app secrets. - **Known deviation to migrate:** `mineru_runner._resolve_mineru_bin` reads `os.environ.get("NEWSDOM_MINERU_BIN")` (a local executable-path override) at runtime. This is a deployment knob, not a secret, so it diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5791233d..17269e96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,13 +66,6 @@ artifacts, generate SHA256 checksums, emit a JSON manifest, export `*.intoto.jsonl` provenance bundles, and publish a GitHub Release with provenance attestation. -For full OpenSSF Scorecard branch-protection visibility against -classic GitHub branch protection rules, set a repository secret named -`SCORECARD_TOKEN` with the fine-grained administration-read scope -recommended by the Scorecard Action documentation. Without that -secret, Scorecard still runs but may report the Branch-Protection -check as inconclusive. - ## Fixture policy This project intentionally separates public test artifacts from diff --git a/README.md b/README.md index 4b5c1674..699f227f 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,8 @@ budget on each pull request. Targets cover the untrusted-input boundaries: the MinerU DOM normalizer (`build_dom`), the `ParseResponse` schema validator, and the equivalence metrics normalizer. See `docs/papers/` for background. -The repository also enforces a `quality-gate` workflow with 100% source -coverage and docstring audit coverage. +The repository-local `tests` workflow enforces 100% source coverage and +docstring audit coverage once per pull-request head. ## Fixtures and provenance @@ -221,9 +221,16 @@ maintenance are documented in `CONTRIBUTING.md`. Mechanical branch updates and merges are attributed to `github-actions[bot]`. Scratch PoC files are not committed. Failed GitHub Checks are not reviewed as URL lists. -OpenCode Review, Strix Security Scan, and PR Review Merge Scheduler are -provided by the organization-level required workflows in -`ContextualWisdomLab/.github`, not copied into this repository. +CodeQL PR, Noema Review, OpenCode Review, Security Scan, Strix Security Scan, +SAST Semgrep, and PR Review Merge Scheduler are provided by the organization +required-workflow ruleset, not copied into this repository. This ownership was +audited against `ContextualWisdomLab/.github` main commit +`769691526f8c73cf714de8fe8ba51ae6cfa2901a`. Security Scan is the single PR +owner for dependency review and Scorecard. `pytest` is the sole +repository-local required check; fuzzing and container builds remain local +because they validate NewsDOM-specific inputs and delivery artifacts. Local +CodeQL and Scorecard workflows retain only default-branch and scheduled +backstops; they do not run for pull requests. Security reporting guidance is documented in `SECURITY.md`. Version tags trigger a GitHub-native release workflow that builds diff --git a/docs/doctoring/actions-consolidation-20260904.md b/docs/doctoring/actions-consolidation-20260904.md new file mode 100644 index 00000000..2c65a420 --- /dev/null +++ b/docs/doctoring/actions-consolidation-20260904.md @@ -0,0 +1,42 @@ +# GitHub Actions ownership consolidation + +## Scope + +This audit compares `newsdom-api` `develop` at +`e06b1f3fb10903569124af011da213951e6e2473` with the central required-workflow +source at `ContextualWisdomLab/.github` main +`769691526f8c73cf714de8fe8ba51ae6cfa2901a`. Product code is outside scope. + +## Ownership decision + +The organization ruleset injects these seven required workflows from the +central repository: + +- `codeql-pr.yml` +- `noema-review.yml` +- `opencode-review.yml` +- `pr-review-merge-scheduler.yml` +- `security-scan.yml` +- `strix.yml` +- `sast-semgrep.yml` + +`security-scan.yml` already owns PR dependency review and Scorecard work. +Accordingly, the local CodeQL and Scorecard PR triggers are removed while their +default-branch and scheduled backstops remain. The local dependency-review and +duplicate quality-gate workflows are removed. The remaining `tests` workflow +preserves the stricter all-extras install and the same 100% branch-coverage +command, so no product test is dropped. + +Repository-local workflow files fall from 10 to 8. An ordinary source-code PR +falls from seven local workflow runs to three: tests, ClusterFuzzLite, and the +container build. Documentation-only PRs run only tests locally. ClusterFuzzLite +and container builds ignore documentation-only changes. + +PR validation workflows use a fixed workflow-name, repository, and PR-number +group and cancel only an older run for the same PR. Image, Pages, and release +workflows serialize by repository and PR or ref with cancellation disabled. +There were no local sleep or queue-sweep steps to retain or remove. + +The repository ruleset keeps strict required checks and the `pytest` GitHub +Actions context. The four deleted local contexts are replaced by the central +required-workflow ruleset rather than being bypassed by local copies. diff --git a/tests/test_readme.py b/tests/test_readme.py index ab1f25e7..439dcb9b 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -1,7 +1,5 @@ from pathlib import Path -CENTRAL_DEPENDENCY_REVIEW_WORKFLOW_SHA = "5f8e5b2a79e709c4ab1a4179a605d34c458b13a1" - def test_readme_points_to_user_and_maintainer_docs(): text = Path("README.md").read_text(encoding="utf-8") @@ -60,25 +58,17 @@ def test_pull_request_template_exists(): assert Path(".github/pull_request_template.md").exists() -def test_security_workflows_exist(): - assert Path(".github/workflows/scorecards.yml").exists() - assert Path(".github/workflows/codeql.yml").exists() - assert Path(".github/workflows/dependency-review.yml").exists() - - -def test_dependency_review_uses_immutable_central_workflow(): - workflow = Path(".github/workflows/dependency-review.yml").read_text(encoding="utf-8") - expected = ( - "ContextualWisdomLab/.github/.github/workflows/dependency-review.yml@" - f"{CENTRAL_DEPENDENCY_REVIEW_WORKFLOW_SHA}" - ) - - assert expected in workflow - assert "dependency-review.yml@main" not in workflow - assert "permissions:\n contents: read\n pull-requests: read" in workflow - assert "fail_on_severity: low" in workflow - assert 'allow_ghsas: "GHSA-69w3-r845-3855"' in workflow +def test_central_required_pr_workflows_are_not_duplicated_locally(): + for workflow_name in [ + "dependency-review.yml", + "quality-gate.yml", + ]: + assert not Path(".github/workflows", workflow_name).exists() + assert Path(".github/workflows/codeql.yml").exists() + assert Path(".github/workflows/scorecards.yml").exists() -def test_quality_gate_workflow_exists(): - assert Path(".github/workflows/quality-gate.yml").exists() + text = Path("README.md").read_text(encoding="utf-8") + normalized_text = " ".join(text.split()) + assert "769691526f8c73cf714de8fe8ba51ae6cfa2901a" in text + assert "`pytest` is the sole repository-local required check" in normalized_text diff --git a/tests/test_repository_governance.py b/tests/test_repository_governance.py index bd7f6896..622611d2 100644 --- a/tests/test_repository_governance.py +++ b/tests/test_repository_governance.py @@ -21,33 +21,19 @@ def test_codeowners_exists_and_covers_repository() -> None: assert "@seonghobae" in rules["manual/"] -def test_codeql_scans_python_and_actions_with_required_check_name() -> None: +def test_codeql_backstop_scans_python_and_actions() -> None: workflow = yaml.safe_load( Path(".github/workflows/codeql.yml").read_text(encoding="utf-8") ) analyze_job = workflow["jobs"]["analyze"] - assert analyze_job["name"] == "codeql (python, actions)" - init_step = next( step for step in analyze_job["steps"] if step.get("uses", "").startswith("github/codeql-action/init@") ) - languages = init_step["with"]["languages"] - if isinstance(languages, str): - normalized_languages = { - language.strip().lower() - for language in languages.split(",") - if language.strip() - } - else: - normalized_languages = { - str(language).strip().lower() - for language in languages - if str(language).strip() - } - - assert normalized_languages == {"python", "actions"} + + assert analyze_job["name"] == "codeql (python, actions)" + assert init_step["with"]["languages"] == "python, actions" def test_gitignore_declares_site_only_once() -> None: diff --git a/tests/test_truth_source_alignment.py b/tests/test_truth_source_alignment.py index b8223946..3fabac08 100644 --- a/tests/test_truth_source_alignment.py +++ b/tests/test_truth_source_alignment.py @@ -170,18 +170,6 @@ def test_gh_pages_workflow_targets_supported_branches_only() -> None: assert set(branches) == {"main", "develop"} -def test_security_gate_docs_use_current_codeql_check_name() -> None: - paths = [ - Path("docs/plans/2026-04-08-security-gates.md"), - Path("docs/plans/2026-04-08-security-gates-design.md"), - ] - - for path in paths: - text = path.read_text(encoding="utf-8") - assert "codeql (python, actions)" in text - assert "codeql (python)" not in text - - def test_adr_follow_up_drops_stale_issue_references() -> None: text = Path("docs/adr/0001-openssf-best-practices-badge.md").read_text( encoding="utf-8" diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py index 4f32ff72..d73660c2 100644 --- a/tests/test_workflow_runtime_env.py +++ b/tests/test_workflow_runtime_env.py @@ -15,18 +15,6 @@ def test_each_workflow_job_forces_javascript_actions_to_node24(): 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: - # A reusable-workflow-call job (`uses: owner/repo/.github/ - # workflows/x.yml@sha`) does not run any steps of its own -- - # GitHub Actions' schema for this job shape supports only - # name/needs/if/permissions/secrets/strategy/uses/with, not - # env, so it cannot even be given this key. The JavaScript - # actions this convention protects against run inside the - # CALLED workflow's own job, which is that workflow's own - # test's responsibility, not this caller's -- confirmed here - # that .github's dependency-review.yml's own `dependency- - # review` job sets FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true. - continue assert job_data["env"]["FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"] is True, ( workflow_path, job_name, diff --git a/tests/test_workflow_security.py b/tests/test_workflow_security.py index db8785d9..6a9d3d55 100644 --- a/tests/test_workflow_security.py +++ b/tests/test_workflow_security.py @@ -57,20 +57,15 @@ def test_workflow_actions_are_pinned_by_sha(): def test_ci_workflows_do_not_use_pip_install_commands(): - for workflow_name in ["tests.yml", "quality-gate.yml"]: - text = Path(f".github/workflows/{workflow_name}").read_text(encoding="utf-8") - assert not re.search(r"\b(?:python\s+-m\s+)?pip3?\s+install\b", text) + text = Path(".github/workflows/tests.yml").read_text(encoding="utf-8") + assert not re.search(r"\b(?:python\s+-m\s+)?pip3?\s+install\b", text) def test_ci_workflows_run_pytest_through_uv(): tests_text = Path(".github/workflows/tests.yml").read_text(encoding="utf-8") - quality_text = Path(".github/workflows/quality-gate.yml").read_text( - encoding="utf-8" - ) - assert "uv run pytest" in tests_text assert ( "uv run pytest --cov=src/newsdom_api --cov-branch --cov-report=term-missing --cov-fail-under=100" - in quality_text + in tests_text ) @@ -98,7 +93,7 @@ def test_docs_workflow_uses_least_privilege_pages_permissions(): assert "id-token: write" in text -def test_codeql_workflow_scopes_security_events_write_to_job_level(): +def test_codeql_backstop_scopes_security_events_write_to_job_level(): text = Path(".github/workflows/codeql.yml").read_text(encoding="utf-8") assert "actions: read" in text.split("jobs:", 1)[0] assert "contents: read" in text.split("jobs:", 1)[0] @@ -141,12 +136,6 @@ def test_local_pages_artifact_action_uses_node24_upload_artifact(): assert "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" in text -def test_quality_gate_workflow_pins_uv_version(): - text = Path(".github/workflows/quality-gate.yml").read_text(encoding="utf-8") - assert "astral-sh/setup-uv@" in text - assert "version: '0.11.3'" in text - - def test_tests_workflow_pins_uv_version(): text = Path(".github/workflows/tests.yml").read_text(encoding="utf-8") assert "astral-sh/setup-uv@" in text diff --git a/tests/test_workflows.py b/tests/test_workflows.py index bbb4d7e4..2acbf0c0 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1,20 +1,105 @@ from pathlib import Path +import pytest +import yaml -def test_scorecards_push_runs_only_on_default_branch(): - text = Path(".github/workflows/scorecards.yml").read_text(encoding="utf-8") - push_section = text.split("pull_request:", 1)[0] - assert "branches: [develop]" in push_section - assert "branches: [main, develop]" not in push_section +def _load_workflow(workflow_name: str) -> dict: + return yaml.safe_load( + Path(".github/workflows", workflow_name).read_text(encoding="utf-8") + ) -def test_scorecards_pull_requests_cover_main_and_develop(): - text = Path(".github/workflows/scorecards.yml").read_text(encoding="utf-8") - assert "pull_request:" in text - pull_request_section = text.split("pull_request:", 1)[1].split("schedule:", 1)[0] - assert "branches:" not in pull_request_section +def _triggers(workflow: dict) -> dict: + return workflow.get("on", workflow.get(True)) -def test_scorecards_workflow_supports_optional_repo_token_for_branch_protection(): - text = Path(".github/workflows/scorecards.yml").read_text(encoding="utf-8") - assert "repo_token: ${{ secrets.SCORECARD_TOKEN || github.token }}" in text + +@pytest.mark.parametrize( + ("workflow_name", "group", "cancel_in_progress"), + [ + ( + "tests.yml", + "tests-${{ github.repository }}-${{ github.event.pull_request.number }}", + True, + ), + ( + "clusterfuzzlite.yml", + "clusterfuzzlite-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}", + "${{ github.event_name == 'pull_request' }}", + ), + ( + "container-image.yml", + "container-image-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }}", + False, + ), + ( + "build-ci-image.yml", + "build-ci-environment-image-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }}", + False, + ), + ( + "gh-pages.yml", + "deploy-web-manual-to-github-pages-${{ github.repository }}-${{ github.ref }}", + False, + ), + ( + "release.yml", + "release-${{ github.repository }}-${{ github.ref }}", + False, + ), + ( + "codeql.yml", + "codeql-${{ github.repository }}-${{ github.ref }}", + False, + ), + ( + "scorecards.yml", + "scorecards-${{ github.repository }}-${{ github.ref }}", + False, + ), + ], +) +def test_workflow_concurrency_is_trigger_aware( + workflow_name: str, group: str, cancel_in_progress: bool | str +) -> None: + concurrency = _load_workflow(workflow_name)["concurrency"] + + assert concurrency == { + "group": group, + "cancel-in-progress": cancel_in_progress, + } + + +def test_tests_run_once_per_pull_request_without_post_merge_push_duplication() -> None: + triggers = _triggers(_load_workflow("tests.yml")) + + assert set(triggers) == {"pull_request"} + + +@pytest.mark.parametrize("workflow_name", ["codeql.yml", "scorecards.yml"]) +def test_central_security_owners_replace_local_pr_triggers( + workflow_name: str, +) -> None: + triggers = _triggers(_load_workflow(workflow_name)) + + assert "pull_request" not in triggers + assert {"push", "schedule"}.issubset(triggers) + + +@pytest.mark.parametrize( + "workflow_name", ["clusterfuzzlite.yml", "container-image.yml"] +) +def test_expensive_pr_workflows_skip_documentation_only_changes( + workflow_name: str, +) -> None: + paths_ignore = set( + _triggers(_load_workflow(workflow_name))["pull_request"]["paths-ignore"] + ) + + assert paths_ignore == {"docs/**", "manual/**", "**.md"} + + +def test_tagged_container_release_has_no_path_filter() -> None: + push_trigger = _triggers(_load_workflow("container-image.yml"))["push"] + + assert push_trigger == {"tags": ["v*"]}