diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 08ea600538..246e66f661 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -13,6 +13,9 @@ on: # Default-branch-only retry entrypoint; no caller-selected workflow ref. repository_dispatch: types: [noema-review] + # Organizations outside ContextualWisdomLab call this from a pull_request_target + # workflow; OIDC job_workflow_ref still names this exact file (noema ADR-0019). + workflow_call: concurrency: # Workflow-level admission is required: a queued run cannot reach a job-level @@ -63,13 +66,15 @@ jobs: NOEMA_GITHUB_APP_PRIVATE_KEY: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY || '' }} run: | set -euo pipefail - if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + if ! [[ "$TARGET_REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]+$ ]] || ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "::error::Noema metadata credential rejected malformed target PR/head metadata." exit 1 fi echo "repository=${TARGET_REPOSITORY#*/}" >>"$GITHUB_OUTPUT" + # The App installation on the target owner is the consent (noema ADR-0019). + echo "owner=${TARGET_REPOSITORY%%/*}" >>"$GITHUB_OUTPUT" if [ -n "${METADATA_TOKEN:-}" ]; then echo "source=pat" >>"$GITHUB_OUTPUT" elif [ -n "${NOEMA_GITHUB_APP_CLIENT_ID:-}" ] && [ -n "${NOEMA_GITHUB_APP_PRIVATE_KEY:-}" ]; then @@ -85,7 +90,7 @@ jobs: with: client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }} - owner: ContextualWisdomLab + owner: ${{ steps.noema_metadata_credential.outputs.owner }} repositories: ${{ steps.noema_metadata_credential.outputs.repository }} permission-contents: read permission-metadata: read @@ -98,7 +103,7 @@ jobs: run: | set -euo pipefail echo "admitted=false" >>"$GITHUB_OUTPUT" - if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + if ! [[ "$TARGET_REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]+$ ]] || ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "::error::Noema admission rejected malformed pull request metadata." @@ -509,13 +514,15 @@ jobs: NOEMA_GITHUB_APP_PRIVATE_KEY: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY || '' }} run: | set -euo pipefail - if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + if ! [[ "$TARGET_REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]+$ ]] || ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "::error::Noema metadata credential rejected malformed target PR/head metadata." exit 1 fi echo "repository=${TARGET_REPOSITORY#*/}" >>"$GITHUB_OUTPUT" + # The App installation on the target owner is the consent (noema ADR-0019). + echo "owner=${TARGET_REPOSITORY%%/*}" >>"$GITHUB_OUTPUT" if [ -n "${METADATA_TOKEN:-}" ]; then echo "source=pat" >>"$GITHUB_OUTPUT" elif [ -n "${NOEMA_GITHUB_APP_CLIENT_ID:-}" ] && [ -n "${NOEMA_GITHUB_APP_PRIVATE_KEY:-}" ]; then @@ -531,7 +538,7 @@ jobs: with: client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }} - owner: ContextualWisdomLab + owner: ${{ steps.noema_metadata_credential.outputs.owner }} repositories: ${{ steps.noema_metadata_credential.outputs.repository }} permission-contents: read permission-metadata: read @@ -642,12 +649,13 @@ jobs: run: | set -euo pipefail - if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]]; then - echo "::error::Noema target repository must belong to ContextualWisdomLab; observed ${TARGET_REPOSITORY:-}." + if [[ ! "$TARGET_REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::Noema target repository must be a valid owner/name; observed ${TARGET_REPOSITORY:-}." exit 1 fi repository_name="${TARGET_REPOSITORY#*/}" echo "repository=$repository_name" >>"$GITHUB_OUTPUT" + echo "owner=${TARGET_REPOSITORY%%/*}" >>"$GITHUB_OUTPUT" if [ -n "${NOEMA_REVIEW_TOKEN:-}" ]; then echo "source=pat" >>"$GITHUB_OUTPUT" @@ -677,7 +685,7 @@ jobs: with: client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }} - owner: ContextualWisdomLab + owner: ${{ steps.noema_credential.outputs.owner }} repositories: ${{ steps.noema_credential.outputs.repository }} permission-actions: read permission-checks: read @@ -965,7 +973,7 @@ jobs: with: client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }} - owner: ContextualWisdomLab + owner: ${{ steps.noema_credential.outputs.owner }} repositories: ${{ steps.noema_credential.outputs.repository }} permission-actions: read permission-checks: read @@ -1027,7 +1035,7 @@ jobs: set -euo pipefail if { [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ] && [ "$TARGET_REPOSITORY" != "$GITHUB_REPOSITORY" ]; } || - ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$TARGET_REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]+$ ]] || ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || ! [[ "$EXPECTED_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then diff --git a/tests/test_noema_native_metadata_credentials.py b/tests/test_noema_native_metadata_credentials.py index a69fff03f5..b526e796bd 100644 --- a/tests/test_noema_native_metadata_credentials.py +++ b/tests/test_noema_native_metadata_credentials.py @@ -78,9 +78,14 @@ def test_native_metadata_selection_precedes_reads_and_preserves_pat_priority(tmp "NOEMA_GITHUB_APP_CLIENT_ID": "synthetic-client" if source in {"app", "pat"} else "", "NOEMA_GITHUB_APP_PRIVATE_KEY": "synthetic-key" if source in {"app", "pat"} else "", "TOKEN_EXCHANGE_URL": "https://fixture.invalid/exchange" if source != "workflow" else ""}) - if source in {"foreign", "malformed"}: + if source == "malformed": assert result.returncode != 0 assert not output.exists() + elif source == "foreign": + # Another owner is admitted; its App installation is the consent (noema ADR-0019). + assert result.returncode == 0, result.stderr + assert "owner=OtherOwner" in output.read_text() + assert "repository=example" in output.read_text() else: assert result.returncode == 0, result.stderr assert f"source={'github-app' if source == 'app' else 'workflow' if source == 'oidc' else source}" in output.read_text() diff --git a/tests/test_noema_review_installed_owner.py b/tests/test_noema_review_installed_owner.py new file mode 100644 index 0000000000..8eea726249 --- /dev/null +++ b/tests/test_noema_review_installed_owner.py @@ -0,0 +1,36 @@ +"""Noema review targets any owner that installed the App, not a fixed organization.""" + +from pathlib import Path +import re + +WORKFLOW = Path(".github/workflows/noema-review.yml") + + +def test_target_owner_is_not_a_fixed_organization() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + # The workflow's own identity stays pinned; only targets are opened up. + assert "^ContextualWisdomLab/[A-Za-z0-9_.-]+$" not in workflow + assert "owner: ContextualWisdomLab" not in workflow + assert 'if trusted_repository != "ContextualWisdomLab/.github":' in workflow + pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]+$") + assert "^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]+$" in workflow + assert pattern.match("HYOSUNG-ITX-AI-Business-Department/llm-gateway-console") + assert not pattern.match("-bad/owner") + assert not pattern.match("owner/with/extra") + + +def test_every_app_token_owner_comes_from_the_validated_target() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + owners = re.findall(r"^\s+owner: (.+)$", workflow, re.MULTILINE) + assert owners and set(owners) <= { + "${{ steps.noema_metadata_credential.outputs.owner }}", + "${{ steps.noema_credential.outputs.owner }}", + } + assert workflow.count('echo "owner=${TARGET_REPOSITORY%%/*}" >>"$GITHUB_OUTPUT"') == 3 + + +def test_other_organizations_can_call_the_central_review() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + triggers = workflow.split("\nconcurrency:", 1)[0] + assert "\n workflow_call:\n" in triggers + assert "\n pull_request_target:\n" in triggers diff --git a/tests/test_noema_reviewer_token_lifetime.py b/tests/test_noema_reviewer_token_lifetime.py index 7081171257..38578a96ff 100644 --- a/tests/test_noema_reviewer_token_lifetime.py +++ b/tests/test_noema_reviewer_token_lifetime.py @@ -45,7 +45,7 @@ def test_publication_step_uses_fresh_app_token_without_authority_fallback() -> N refresh = _step_block(workflow, "Refresh repository-scoped Noema GitHub App token for publication") publish = _step_block(workflow, "Publish prepared Noema verdict on the exact live head") - assert "owner: ContextualWisdomLab" in refresh + assert "owner: ${{ steps.noema_credential.outputs.owner }}" in refresh assert "repositories: ${{ steps.noema_credential.outputs.repository }}" in refresh assert "permission-pull-requests: write" in refresh assert "permission-contents: read" in refresh diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index a24b3c7d0c..485bc2be4d 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -1418,7 +1418,7 @@ def test_noema_review_mints_a_least_privilege_github_app_token() -> None: ) assert "client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }}" in workflow assert "private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }}" in workflow - assert "owner: ContextualWisdomLab" in workflow + assert "owner: ${{ steps.noema_credential.outputs.owner }}" in workflow assert "repositories: ${{ steps.noema_credential.outputs.repository }}" in workflow for permission in ( "permission-actions: read",