diff --git a/.github/workflows/_temp_pr1674_status_publication_repair.yml b/.github/workflows/_temp_pr1674_status_publication_repair.yml new file mode 100644 index 0000000000..d27ba9e613 --- /dev/null +++ b/.github/workflows/_temp_pr1674_status_publication_repair.yml @@ -0,0 +1,133 @@ +# One-shot owner-branch repair; self-deletes after verified non-force publication. +name: Temporary PR1674 Strix publication authority repair + +on: + push: + branches: [fix/noema-closed-not-stale] + paths: + - .github/workflows/_temp_pr1674_status_publication_repair.yml + - scripts/ci/pr1674_status_publication_repair.py + - scripts/ci/pr1674_exact_findings_repair.py + - .github/workflows/strix.yml + - .github/workflows/noema-review.yml + - tests/test_strix_repository_dispatch_live_state.py + - tests/test_noema_live_target_admission.py + +concurrency: + group: temp-pr1674-status-publication-${{ github.repository }}-${{ github.ref_name }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + repair: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.event_name == 'push' && + github.ref == 'refs/heads/fix/noema-closed-not-stale' + runs-on: ubuntu-slim + steps: + - name: Checkout exact writer head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up review Python 3.14 + id: review_python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + + - name: Set up Strix Python 3.13 + id: strix_python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Install hash-locked test dependencies in isolated environments + run: | + set -euo pipefail + "${{ steps.review_python.outputs.python-path }}" -m venv "$RUNNER_TEMP/review-venv" + "$RUNNER_TEMP/review-venv/bin/python" -m pip install \ + --disable-pip-version-check --require-hashes --only-binary=:all: \ + -r requirements-opencode-review-ci-hashes.txt + "${{ steps.strix_python.outputs.python-path }}" -m venv "$RUNNER_TEMP/strix-venv" + # Match the declared Strix workflow: the generated lock fully pins and hashes + # transitive artifacts, while --no-deps avoids re-solving declared ranges that + # intentionally conflict with the reviewed exact lock. + "$RUNNER_TEMP/strix-venv/bin/python" -m pip install \ + --disable-pip-version-check --require-hashes --no-deps --only-binary=:all: \ + -r requirements-strix-ci-hashes.txt + + - name: Revalidate exact writer head + env: + EXPECTED_HEAD: ${{ github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + remote_head="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | awk '{print $1}')" + test -n "$remote_head" + test "$remote_head" = "$EXPECTED_HEAD" + test -f scripts/ci/pr1674_status_publication_repair.py + test -f scripts/ci/pr1674_exact_findings_repair.py + test -f .github/workflows/_temp_pr1674_status_publication_repair.yml + + - name: Materialize exact-head review finding repairs + run: | + set -euo pipefail + "${{ steps.review_python.outputs.python-path }}" scripts/ci/pr1674_exact_findings_repair.py + test ! -e scripts/ci/pr1674_exact_findings_repair.py + "${{ steps.review_python.outputs.python-path }}" -m py_compile scripts/ci/pr1674_status_publication_repair.py + git diff --check + + - name: Materialize late-bound Strix publication authority + run: | + set -euo pipefail + "${{ steps.review_python.outputs.python-path }}" scripts/ci/pr1674_status_publication_repair.py + test ! -e scripts/ci/pr1674_status_publication_repair.py + test ! -e .github/workflows/_temp_pr1674_status_publication_repair.yml + git diff --check + + - name: Verify executable live-state and transport contracts + run: | + set -euo pipefail + # The Strix lock intentionally mirrors the production --no-deps install and + # therefore does not carry pytest. Use the review lock's pinned pytest while + # importing Strix/PyYAML from the exact Python 3.13 Strix environment. + strix_site_packages="$("$RUNNER_TEMP/strix-venv/bin/python" -c 'import site; print(site.getsitepackages()[0])')" + PYTHONPATH="$strix_site_packages:." "$RUNNER_TEMP/review-venv/bin/python" -m pytest \ + tests/test_strix_repository_dispatch_live_state.py \ + -q + PYTHONPATH=. "$RUNNER_TEMP/review-venv/bin/python" -m pytest \ + tests/test_noema_live_target_admission.py \ + tests/test_required_workflow_queue_contract.py \ + -q + "$RUNNER_TEMP/strix-venv/bin/python" - <<'PY' + import yaml + from pathlib import Path + for path in (Path('.github/workflows/strix.yml'), Path('.github/workflows/noema-review.yml')): + yaml.safe_load(path.read_text(encoding='utf-8')) + PY + "${{ steps.review_python.outputs.python-path }}" -m compileall -q tests/test_strix_repository_dispatch_live_state.py tests/test_noema_live_target_admission.py + git diff --check + + - name: Publish one exact non-force canonical successor + env: + EXPECTED_HEAD: ${{ github.sha }} + WORKFLOW_PUSH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} + run: | + set -euo pipefail + test -n "$WORKFLOW_PUSH_TOKEN" + remote_head="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | awk '{print $1}')" + test "$remote_head" = "$EXPECTED_HEAD" + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add -A + git diff --cached --check + test -n "$(git diff --cached --name-only)" + git commit -m "fix(review): close live authority and OIDC transport races" + git remote set-url origin "https://x-access-token:${WORKFLOW_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 21ea967201..aa84536cf7 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -302,8 +302,49 @@ jobs: run: | echo "::notice::Noema review skipped: no pull request number is associated with this event." - - name: Resolve trusted Noema review source ref + - name: Validate live Noema target before any setup if: env.PR_NUMBER != '' + id: live_pr + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Noema trigger did not provide a canonical lowercase exact head SHA." + exit 1 + fi + if [ "$TARGET_REPOSITORY" != "$GITHUB_REPOSITORY" ]; then + echo "proceed=true" >>"$GITHUB_OUTPUT" + echo "::notice::Cross-repository Noema live lookup deferred until the selected repository-scoped reviewer credential is available." + exit 0 + fi + + if ! pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Could not retrieve the live pull request before Noema setup." + exit 1 + fi + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + live_draft="$(jq -r 'if has("draft") then (.draft | tostring) else empty end' <<<"$pull_request_json")" + if [ -z "$live_state" ] || [ -z "$live_head_sha" ] || [ -z "$live_draft" ]; then + echo "::error::Could not validate live pull request state before Noema setup." + exit 1 + fi + if [ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]; then + printf '::error::Noema trigger is stale. expected head=%s; live state=%s head=%s.\n' \ + "$EXPECTED_HEAD_SHA" "$live_state" "$live_head_sha" + exit 1 + fi + if [ "$live_state" != "open" ] || [ "$live_draft" = "true" ]; then + printf '::notice::Noema target is not reviewable on its current head (state=%s draft=%s); skipping all setup.\n' \ + "$live_state" "$live_draft" + echo "proceed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + echo "proceed=true" >>"$GITHUB_OUTPUT" + + - name: Resolve trusted Noema review source ref + if: env.PR_NUMBER != '' && steps.live_pr.outputs.proceed == 'true' id: trusted_source env: JOB_CONTEXT_JSON: ${{ toJSON(job) }} @@ -351,7 +392,7 @@ jobs: PY - name: Materialize trusted Noema review gate - if: env.PR_NUMBER != '' + if: env.PR_NUMBER != '' && steps.live_pr.outputs.proceed == 'true' env: GH_TOKEN: ${{ github.token }} TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }} @@ -371,24 +412,8 @@ jobs: tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1 test -f scripts/ci/noema_review_gate.py - - name: Reject a stale trigger before credential or model setup - if: env.PR_NUMBER != '' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if [[ ! "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::Noema trigger did not provide a canonical lowercase exact head SHA." - exit 1 - fi - live_head="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')" - if [ "${live_head,,}" != "${EXPECTED_HEAD_SHA,,}" ]; then - echo "::error::Noema trigger is stale; expected ${EXPECTED_HEAD_SHA}, observed ${live_head}." - exit 1 - fi - - name: Cancel superseded Noema runs after live-head validation - if: github.event_name == 'pull_request_target' && env.PR_NUMBER != '' + if: github.event_name == 'pull_request_target' && env.PR_NUMBER != '' && steps.live_pr.outputs.proceed == 'true' env: GH_TOKEN: ${{ github.token }} CURRENT_RUN_ID: ${{ github.run_id }} @@ -437,20 +462,23 @@ jobs: [ -n "$run_id" ] || continue [ -z "${seen[$run_id]:-}" ] || continue seen[$run_id]=1 - # A transient failure here (rate limit, network blip) must - # never crash this step under set -e: this is a housekeeping - # cleanup, and letting an ancillary API hiccup fail the whole - # job would block a perfectly valid, live-head review over - # something unrelated to it. Treat "cannot verify" the same - # as "verified stale": stop cancelling rather than risk a - # wrong cancellation, but let the job continue. - if ! live_head="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha' 2>/tmp/noema-supersede-live-head-error)"; then - echo "::warning::Noema cleanup could not re-verify the live PR head before cancelling run ${run_id}; stopping cleanup rather than risking a wrong cancellation." >&2 + # Revalidate the authoritative live target immediately before + # every destructive cancellation. A head match alone is not + # enough when draft/close events can race this cleanup. + if ! live_pr_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" 2>/tmp/noema-supersede-live-head-error)"; then + echo "::warning::Noema cleanup could not re-verify the live PR before cancelling run ${run_id}; stopping cleanup rather than risking a wrong cancellation." >&2 sed 's/^/ /' /tmp/noema-supersede-live-head-error >&2 || true exit 0 fi - if [ "${live_head,,}" != "${EXPECTED_HEAD_SHA,,}" ]; then - echo "::notice::Noema cleanup stopped because the PR head advanced." + live_head="$(jq -r '.head.sha // empty' <<<"$live_pr_json")" + live_state="$(jq -r '.state // empty' <<<"$live_pr_json")" + live_draft="$(jq -r 'if has("draft") then (.draft | tostring) else empty end' <<<"$live_pr_json")" + if [ -z "$live_head" ] || [ -z "$live_state" ] || [ -z "$live_draft" ]; then + echo "::warning::Noema cleanup received incomplete live PR state; stopping cleanup fail-closed." + exit 0 + fi + if [ "${live_head,,}" != "${EXPECTED_HEAD_SHA,,}" ] || [ "$live_state" != "open" ] || [ "$live_draft" = "true" ]; then + echo "::notice::Noema cleanup stopped because the authoritative PR head/state is no longer reviewable." exit 0 fi if gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null; then @@ -466,7 +494,7 @@ jobs: echo "Cancelled ${cancelled} superseded Noema run(s) after live-head validation." - name: Select fail-closed Noema reviewer credential - if: env.PR_NUMBER != '' + if: env.PR_NUMBER != '' && steps.live_pr.outputs.proceed == 'true' id: noema_credential env: NOEMA_GITHUB_APP_CLIENT_ID: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID || '' }} @@ -505,7 +533,7 @@ jobs: exit 1 - name: Mint repository-scoped Noema GitHub App token - if: env.PR_NUMBER != '' && steps.noema_credential.outputs.source == 'github-app' + if: env.PR_NUMBER != '' && steps.live_pr.outputs.proceed == 'true' && steps.noema_credential.outputs.source == 'github-app' id: noema_github_app_token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -523,7 +551,7 @@ jobs: permission-vulnerability-alerts: read - name: Exchange Noema app token through OIDC - if: env.PR_NUMBER != '' && steps.noema_credential.outputs.source == 'oidc' + if: env.PR_NUMBER != '' && steps.live_pr.outputs.proceed == 'true' && steps.noema_credential.outputs.source == 'oidc' id: noema_oidc_token env: OIDC_AUDIENCE: ${{ vars.NOEMA_OIDC_AUDIENCE || 'cwl-noema-review' }} @@ -580,27 +608,39 @@ jobs: echo "::add-mask::$app_token" echo "token=$app_token" >>"$GITHUB_OUTPUT" - - name: Validate current pull request head - if: env.PR_NUMBER != '' + - name: Revalidate live Noema target before model setup + if: env.PR_NUMBER != '' && steps.live_pr.outputs.proceed == 'true' + id: live_pr_refresh env: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} run: | set -euo pipefail - if ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Noema expected head must be a full commit SHA." + if ! pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Could not refresh the live pull request before Noema model setup." exit 1 fi - pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" - if [ "$live_state" != "open" ] || [ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]; then - printf '::error::Noema review target is closed or stale. expected head=%s; live state=%s head=%s.\n' \ - "$EXPECTED_HEAD_SHA" "${live_state:-missing}" "${live_head_sha:-missing}" + live_draft="$(jq -r 'if has("draft") then (.draft | tostring) else empty end' <<<"$pull_request_json")" + if [ -z "$live_state" ] || [ -z "$live_head_sha" ] || [ -z "$live_draft" ]; then + echo "::error::Could not validate refreshed pull request state before Noema model setup." exit 1 fi + if [ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]; then + printf '::error::Noema review target moved before model setup. expected head=%s; live state=%s head=%s.\n' \ + "$EXPECTED_HEAD_SHA" "$live_state" "$live_head_sha" + exit 1 + fi + if [ "$live_state" != "open" ] || [ "$live_draft" = "true" ]; then + printf '::notice::Noema target became non-reviewable before model setup (state=%s draft=%s); skipping.\n' \ + "$live_state" "$live_draft" + echo "proceed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + echo "proceed=true" >>"$GITHUB_OUTPUT" - name: Resolve Noema target repository visibility - if: env.PR_NUMBER != '' + if: env.PR_NUMBER != '' && steps.live_pr_refresh.outputs.proceed == 'true' id: target_visibility env: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} @@ -638,7 +678,7 @@ jobs: esac - name: Provision contextual-orchestrator review sidecar - if: env.PR_NUMBER != '' + if: env.PR_NUMBER != '' && steps.live_pr_refresh.outputs.proceed == 'true' env: BYTEZ_API_KEY: ${{ secrets.BYTEZ_API_KEY }} NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} @@ -651,7 +691,7 @@ jobs: bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" - name: Prepare Noema model verdict - if: env.PR_NUMBER != '' + if: env.PR_NUMBER != '' && steps.live_pr_refresh.outputs.proceed == 'true' id: noema_prepare env: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} @@ -706,11 +746,104 @@ jobs: permission-statuses: read permission-vulnerability-alerts: read - - name: Publish prepared Noema verdict on the exact live head + - name: Refresh repository-scoped Noema OIDC app token for publication + if: env.PR_NUMBER != '' && steps.noema_prepare.outputs.prepared == 'true' && steps.noema_credential.outputs.source == 'oidc' + id: noema_oidc_publication_token + env: + OIDC_AUDIENCE: ${{ vars.NOEMA_OIDC_AUDIENCE || 'cwl-noema-review' }} + TOKEN_EXCHANGE_URL: ${{ vars.NOEMA_TOKEN_EXCHANGE_URL || vars.NOEMA_EXCHANGE_URL || '' }} + run: | + set -euo pipefail + + fail_unavailable() { + local message="$1" + echo "::error::$message" + exit 1 + } + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + fail_unavailable "Noema publication app token exchange unavailable: OIDC request environment is missing." + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + fail_unavailable "Noema publication app token exchange unavailable: OIDC token request did not complete." + fi + + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + fail_unavailable "Noema publication app token exchange unavailable: OIDC token response was empty." + fi + + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${oidc_token}" \ + --data "$(jq -cn --arg target_repository "$TARGET_REPOSITORY" '{target_repository:$target_repository}')" \ + "${TOKEN_EXCHANGE_URL}" + )"; then + fail_unavailable "Noema publication app token exchange unavailable: app token request did not complete." + fi + + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + fail_unavailable "Noema publication app token exchange unavailable: app token response was empty." + fi + + echo "::add-mask::$app_token" + echo "token=$app_token" >>"$GITHUB_OUTPUT" + + - name: Revalidate live Noema target before publication if: env.PR_NUMBER != '' && steps.noema_prepare.outputs.prepared == 'true' + id: live_pr_publish + env: + GH_TOKEN: ${{ steps.noema_credential.outputs.source == 'pat' && secrets.NOEMA_REVIEW_TOKEN || steps.noema_credential.outputs.source == 'github-app' && steps.noema_github_app_publication_token.outputs.token || steps.noema_credential.outputs.source == 'oidc' && steps.noema_oidc_publication_token.outputs.token || '' }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::Noema publication revalidation has no fresh credential for the explicitly selected reviewer source; refusing any GITHUB_TOKEN fallback." + exit 1 + fi + if ! pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Could not refresh the live pull request before Noema publication." + exit 1 + fi + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + live_draft="$(jq -r 'if has("draft") then (.draft | tostring) else empty end' <<<"$pull_request_json")" + if [ -z "$live_state" ] || [ -z "$live_head_sha" ] || [ -z "$live_draft" ]; then + echo "::error::Could not validate refreshed pull request state before Noema publication." + exit 1 + fi + if [ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]; then + printf '::error::Noema review target moved before publication. expected head=%s; live state=%s head=%s.\n' \ + "$EXPECTED_HEAD_SHA" "$live_state" "$live_head_sha" + exit 1 + fi + if [ "$live_state" != "open" ] || [ "$live_draft" = "true" ]; then + printf '::notice::Noema target became non-reviewable before publication (state=%s draft=%s); skipping.\n' \ + "$live_state" "$live_draft" + echo "proceed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + echo "proceed=true" >>"$GITHUB_OUTPUT" + + - name: Publish prepared Noema verdict on the exact live head + if: env.PR_NUMBER != '' && steps.live_pr_publish.outputs.proceed == 'true' && steps.noema_prepare.outputs.prepared == 'true' env: - GH_TOKEN: ${{ steps.noema_credential.outputs.source == 'pat' && secrets.NOEMA_REVIEW_TOKEN || steps.noema_credential.outputs.source == 'github-app' && steps.noema_github_app_publication_token.outputs.token || steps.noema_credential.outputs.source == 'oidc' && steps.noema_oidc_token.outputs.token || '' }} - NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source == 'pat' && 'noema-review-pat' || steps.noema_credential.outputs.source == 'github-app' && 'noema-review-github-app-refresh' || steps.noema_credential.outputs.source == 'oidc' && 'noema-review-app-oidc' || '' }} + GH_TOKEN: ${{ steps.noema_credential.outputs.source == 'pat' && secrets.NOEMA_REVIEW_TOKEN || steps.noema_credential.outputs.source == 'github-app' && steps.noema_github_app_publication_token.outputs.token || steps.noema_credential.outputs.source == 'oidc' && steps.noema_oidc_publication_token.outputs.token || '' }} + NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source == 'pat' && 'noema-review-pat' || steps.noema_credential.outputs.source == 'github-app' && 'noema-review-github-app-refresh' || steps.noema_credential.outputs.source == 'oidc' && 'noema-review-app-oidc-refresh' || '' }} NOEMA_REVIEW_ACTOR: ${{ steps.noema_github_app_publication_token.outputs['app-slug'] && format('{0}[bot]', steps.noema_github_app_publication_token.outputs['app-slug']) || '' }} NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.noema_github_app_publication_token.outputs['installation-id'] }} run: | diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 58ed3dab8d..029fb57203 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -508,6 +508,84 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" + - name: Validate repository dispatch against live pull request metadata + id: dispatch_validation + if: github.event_name == 'repository_dispatch' + env: + GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + REPOSITORY: ${{ github.event.client_payload.target_repository }} + PR_NUMBER: ${{ github.event.client_payload.pr_number }} + SUPPLIED_BASE_REF: ${{ github.event.client_payload.pr_base_ref }} + SUPPLIED_BASE_SHA: ${{ github.event.client_payload.pr_base_sha }} + SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} + run: | + set -euo pipefail + if ! [[ "$REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$SUPPLIED_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "$SUPPLIED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || + [ -z "$SUPPLIED_BASE_REF" ]; then + echo "::error::repository_dispatch Strix metadata is incomplete or malformed." + exit 1 + fi + + if ! pull_request_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Could not revalidate live repository_dispatch Strix pull request metadata." + exit 1 + fi + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_draft="$(jq -r 'if .draft == true then "true" elif .draft == false then "false" else empty end' <<<"$pull_request_json")" + live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" + live_head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_request_json")" + live_base_ref="$(jq -r '.base.ref // empty' <<<"$pull_request_json")" + live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + + if [ -z "$live_state" ] || [ -z "$live_draft" ] || + [ "$live_base_repository" != "$REPOSITORY" ] || + [ "$live_head_sha" != "$SUPPLIED_HEAD_SHA" ]; then + printf '::error::repository_dispatch Strix target identity/head is stale or unverifiable for %s#%s. supplied head=%s; live state=%s draft=%s base_repo=%s head_repo=%s head=%s.\n' \ + "$REPOSITORY" "$PR_NUMBER" "$SUPPLIED_HEAD_SHA" "${live_state:-missing}" "${live_draft:-missing}" \ + "${live_base_repository:-missing}" "${live_head_repository:-missing}" "${live_head_sha:-missing}" + exit 1 + fi + + if [ "$live_state" = "closed" ]; then + echo "should_scan=false" >>"$GITHUB_OUTPUT" + printf '::notice::repository_dispatch Strix target closed on exact head %s; skipping resolved work.\n' "$live_head_sha" + exit 0 + fi + if [ "$live_state" != "open" ]; then + echo "::error::repository_dispatch Strix target has unexpected live state '$live_state'." + exit 1 + fi + if [ "$live_draft" = "true" ]; then + echo "should_scan=false" >>"$GITHUB_OUTPUT" + printf '::notice::repository_dispatch Strix target is now draft on exact head %s; skipping until live ready state is restored.\n' "$live_head_sha" + exit 0 + fi + if [ "$live_draft" != "false" ] || + [ "$live_head_repository" != "$REPOSITORY" ] || + [ "$live_base_ref" != "$SUPPLIED_BASE_REF" ] || + [ "$live_base_sha" != "$SUPPLIED_BASE_SHA" ]; then + printf '::error::repository_dispatch Strix metadata does not match the live ready PR %s#%s. supplied base=%s/%s head=%s; live state=%s draft=%s base_repo=%s base=%s/%s head_repo=%s head=%s.\n' \ + "$REPOSITORY" "$PR_NUMBER" "$SUPPLIED_BASE_REF" "$SUPPLIED_BASE_SHA" "$SUPPLIED_HEAD_SHA" \ + "$live_state" "$live_draft" "$live_base_repository" "${live_base_ref:-missing}" "${live_base_sha:-missing}" \ + "${live_head_repository:-missing}" "$live_head_sha" + exit 1 + fi + + echo "should_scan=true" >>"$GITHUB_OUTPUT" + trusted_workspace="$RUNNER_TEMP/trusted-workspace" + mkdir -p "$trusted_workspace" + git init -q "$trusted_workspace" + gh auth setup-git + git -C "$trusted_workspace" remote add origin "$GITHUB_SERVER_URL/$REPOSITORY.git" + git -C "$trusted_workspace" fetch --no-tags --depth=1 origin "$live_base_sha" + git -C "$trusted_workspace" checkout --detach --quiet "$live_base_sha" + git -C "$trusted_workspace" cat-file -e "$live_base_sha^{commit}" + echo "TRUSTED_WORKSPACE=$trusted_workspace" >> "$GITHUB_ENV" + - name: Resolve target repository visibility id: target_visibility env: @@ -579,58 +657,10 @@ jobs: git -C "$trusted_workspace" cat-file -e "$TARGET_WORKSPACE_SHA^{commit}" echo "TRUSTED_WORKSPACE=$trusted_workspace" >> "$GITHUB_ENV" - - name: Validate repository dispatch against live pull request metadata - if: github.event_name == 'repository_dispatch' - env: - GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} - REPOSITORY: ${{ github.event.client_payload.target_repository }} - PR_NUMBER: ${{ github.event.client_payload.pr_number }} - SUPPLIED_BASE_REF: ${{ github.event.client_payload.pr_base_ref }} - SUPPLIED_BASE_SHA: ${{ github.event.client_payload.pr_base_sha }} - SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} - run: | - set -euo pipefail - if ! [[ "$REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || - ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$SUPPLIED_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || - ! [[ "$SUPPLIED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || - [ -z "$SUPPLIED_BASE_REF" ]; then - echo "::error::repository_dispatch Strix metadata is incomplete or malformed." - exit 1 - fi - - pull_request_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" - live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" - live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" - live_head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_request_json")" - live_base_ref="$(jq -r '.base.ref // empty' <<<"$pull_request_json")" - live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_request_json")" - live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" - if [ "$live_state" != "open" ] || - [ "$live_base_repository" != "$REPOSITORY" ] || - [ "$live_head_repository" != "$REPOSITORY" ] || - [ "$live_base_ref" != "$SUPPLIED_BASE_REF" ] || - [ "$live_base_sha" != "$SUPPLIED_BASE_SHA" ] || - [ "$live_head_sha" != "$SUPPLIED_HEAD_SHA" ]; then - printf '::error::repository_dispatch Strix metadata does not match live PR %s#%s. supplied base=%s/%s head=%s; live state=%s base_repo=%s base=%s/%s head_repo=%s head=%s.\n' \ - "$REPOSITORY" "$PR_NUMBER" "$SUPPLIED_BASE_REF" "$SUPPLIED_BASE_SHA" "$SUPPLIED_HEAD_SHA" \ - "${live_state:-missing}" "${live_base_repository:-missing}" "${live_base_ref:-missing}" "${live_base_sha:-missing}" \ - "${live_head_repository:-missing}" "${live_head_sha:-missing}" - exit 1 - fi - - trusted_workspace="$RUNNER_TEMP/trusted-workspace" - mkdir -p "$trusted_workspace" - git init -q "$trusted_workspace" - gh auth setup-git - git -C "$trusted_workspace" remote add origin "$GITHUB_SERVER_URL/$REPOSITORY.git" - git -C "$trusted_workspace" fetch --no-tags --depth=1 origin "$live_base_sha" - git -C "$trusted_workspace" checkout --detach --quiet "$live_base_sha" - git -C "$trusted_workspace" cat-file -e "$live_base_sha^{commit}" - echo "TRUSTED_WORKSPACE=$trusted_workspace" >> "$GITHUB_ENV" - - name: Fetch pull request head for trusted scan - if: github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '' + if: >- + steps.dispatch_validation.outputs.should_scan != 'false' + && (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') env: GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event.client_payload.pr_number }} @@ -696,6 +726,7 @@ jobs: exit 1 - name: Self-test Strix required workflow contract + if: steps.dispatch_validation.outputs.should_scan != 'false' timeout-minutes: 2 working-directory: trusted-strix-source run: | @@ -723,6 +754,7 @@ jobs: fi - name: Gate Strix secrets + if: steps.dispatch_validation.outputs.should_scan != 'false' id: gate env: STRIX_MODEL: contextual-orchestrator/orchestrator/free @@ -1052,7 +1084,7 @@ jobs: retention-days: 5 - name: Publish same-head manual Strix status - if: ${{ always() && !cancelled() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} + if: ${{ always() && !cancelled() && steps.dispatch_validation.outputs.should_scan != 'false' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} env: TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} GITHUB_STATUS_TOKEN: ${{ (github.event.client_payload.target_repository == '' || github.event.client_payload.target_repository == github.repository) && github.token || '' }} @@ -1131,7 +1163,7 @@ jobs: publish-manual-pr-evidence-status: name: publish-manual-pr-evidence-status needs: strix - if: ${{ always() && !cancelled() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} + if: ${{ always() && !cancelled() && needs.strix.outputs.should_scan == 'true' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} runs-on: ubuntu-24.04 # Single-shot OIDC exchange plus a handful of curl/gh api calls, no loop # or pagination -- same shape as the agent-mention-*-dispatch.yml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e12f33542d..fb25506d0c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -179,11 +179,37 @@ sequenceDiagram MS->>PR: merge only on current-head approval + green checks ``` +### Publication and live-admission boundary + +Noema is explicitly two phase. Model preparation may outlive an installation or +exchanged app token, so a publishable envelope does not inherit the authority +that existed before model execution. Immediately before publication, the +selected renewable identity is refreshed (GitHub App or OIDC exchange), then +that fresh repository-scoped authority performs both the private-sibling live +PR revalidation and the final exact-head verdict write; PAT remains an explicit +selected source. The central `.github` `github.token`, a predecessor App/OIDC +token, and PR-author authority are not publication fallbacks. + +Strix `repository_dispatch` admission is likewise live-authoritative. Its scan +job emits `should_scan=false` when the exact target is closed or draft by the +time execution begins. That output crosses the job boundary: the separate +privileged manual-status publisher starts only for `should_scan == 'true'`, so +resolved/non-reviewable work cannot regain write-side effects after the scan +path has skipped it. See ADR-0021. + ## Trust boundaries - Required review workflows execute **base-branch** scripts. A PR that edits those workflows cannot widen its own `pull_request_target` token. - Reviewer agents stay `edit: deny`. They judge; they do not implement. +- Noema publication re-mints or re-exchanges renewable reviewer authority after + model work and before the live publication read. Private sibling revalidation + and final verdict publication use only that selected fresh repository-scoped + authority (or the explicitly selected PAT), never the central repository + token or predecessor App/OIDC credentials. +- Strix propagates its live `repository_dispatch` admission decision across the + scan/status job boundary; false or absent `should_scan` authority cannot start + the separate privileged status publisher. - Repository public-surface writes execute only from trusted `.github/main`; pull-request validation remains read-only and leaf README changes keep their repository-local review boundary. Workflow-backed Pages is preserve-only and @@ -250,8 +276,12 @@ resolver conflict. contract. - [`docs/adr/0020-repository-public-surface-reconciliation.md`](docs/adr/0020-repository-public-surface-reconciliation.md) — desired-state ownership, trust boundary, and convergence decision. +- [`docs/adr/0021-review-publication-authority-and-live-skip.md`](docs/adr/0021-review-publication-authority-and-live-skip.md) + — fresh Noema publication authority and Strix cross-job live-skip decision. - [`docs/doctoring/repository-public-surface-reconciliation.md`](docs/doctoring/repository-public-surface-reconciliation.md) — current operational baseline and live-verification contract. +- [`docs/doctoring/noema-review-token-lifetime.md`](docs/doctoring/noema-review-token-lifetime.md) + — renewable reviewer credential lifetime and private-sibling publication boundary. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/semgrep-image-digest-single-source.md`](docs/doctoring/semgrep-image-digest-single-source.md) diff --git a/docs/adr/0021-review-publication-authority-and-live-skip.md b/docs/adr/0021-review-publication-authority-and-live-skip.md new file mode 100644 index 0000000000..caaf6cb990 --- /dev/null +++ b/docs/adr/0021-review-publication-authority-and-live-skip.md @@ -0,0 +1,33 @@ +# ADR-0021: Publication authority freshness and live-skip propagation + +- Status: proposed +- Date: 2026-09-02 +- Scope: central Required Noema Review and Strix `repository_dispatch` control-plane paths + +## Context + +Long-running central review separates model execution from publication. The original Noema two-phase design refreshed a repository-scoped GitHub App token for the final write, but the publication-boundary live-PR lookup still ran first with the central `.github` `github.token`. That token cannot authoritatively inspect a private sibling repository. The OIDC path also retained its pre-model exchanged token across the model call, recreating the same lifetime risk already observed for GitHub App installation tokens. + +Strix has a separate live-state issue: a `repository_dispatch` can be valid when queued but refer to a PR that is closed or draft when execution begins or while a multi-hour scan is still running. Initial admission therefore cannot authorize later status publication. The scan job must suppress admission work after `should_scan=false`, and every status-publication boundary must re-read the live exact head/state/draft with fresh repository-scoped authority before writing evidence. + +## Decision + +1. Noema model preparation never grants predecessor evidence publication authority. After a publishable envelope exists, the selected reviewer source is refreshed before the publication-boundary live read: GitHub App mints a new repository-scoped installation token, OIDC obtains a new identity token and exchanges it for a new repository-scoped app token, and PAT remains the explicitly selected secret authority. +2. `Revalidate live Noema target before publication` and `Publish prepared Noema verdict on the exact live head` use the same selected publication-phase authority. They must not use the pre-model App/OIDC token, `github.token`, PR-author credentials, or an implicit fallback. Missing fresh authority fails closed. +3. Exact head/state/draft remain authoritative at publication time. A moved head is an error; a closed/draft exact head is resolved/non-reviewable work and produces no publication. +4. Strix propagates initial live admission through every repository-dispatch setup step so `should_scan=false` performs no target-visibility lookup, sidecar setup, scan, or status publication. After a long-running scan, the scan job refreshes repository-scoped authority and revalidates the live target immediately before its status publisher. It exports that late `publish_status` decision together with `should_scan`. +5. The separate `publish-manual-pr-evidence-status` job may start only when both `needs.strix.outputs.should_scan == 'true'` and `needs.strix.outputs.publish_status == 'true'`. Because job scheduling can itself introduce delay, that job revalidates the live exact head/state/draft again with its newly exchanged repository-scoped credential immediately before its publisher. An exact-head closed/draft target suppresses publication; a moved head or unverifiable live state fails closed. +6. Executable regressions must cover the publication-token ordering/selection, closed/draft/stale/unverifiable live states, skip propagation through target visibility, the Strix scan-job publication boundary, and the cross-job publication boundary. Completed source-fix workflows and their unique drivers are removed after the repair is materialized. + +## Alternatives considered + +- **Trust initial dispatch state for the full scan.** Rejected because a multi-hour security scan can outlive the reviewability of its target and then publish stale evidence. +- **Treat closed/draft as a successful security verdict.** Rejected because non-reviewable work is a no-op, not positive security evidence. +- **Publish first and reconcile stale status later.** Rejected because branch protection and schedulers can consume the stale status before cleanup. +- **Use the central repository token for cross-repository publication checks.** Rejected because it is not authoritative for private sibling targets. + +## Consequences + +Private sibling Noema review no longer depends on the central repository token at the publication boundary, and renewable reviewer identities are protected from long model-call expiry. Strix stops work cleanly when initial live admission says not to scan, and a PR that closes or becomes draft during a long scan cannot receive stale late status evidence. These are authority/admission corrections only; required branch-protection contexts, substantive security findings, and exact-head evidence requirements are not weakened. + +This ADR remains **Proposed** until the implementation, executable regressions, and required exact-head checks are integrated on protected `main`; only then may its status advance to Accepted. diff --git a/docs/doctoring/noema-review-token-lifetime.md b/docs/doctoring/noema-review-token-lifetime.md index 5346333ee2..2fd1f4b231 100644 --- a/docs/doctoring/noema-review-token-lifetime.md +++ b/docs/doctoring/noema-review-token-lifetime.md @@ -4,18 +4,20 @@ On 2026-09-01, trusted central Noema review for `ContextualWisdomLab/naruon#1497@152d1998c4e8024be9dc7026c8789d343c884fd0` minted the repository-scoped `cwl-noema-review` GitHub App installation token before model work. Contextual-orchestrator review then exceeded the installation-token lifetime; the first later GitHub operation failed HTTP 401 and cleanup independently reported token expiry. Repository-owned deterministic checks on that Naruon head were otherwise green. The defect is in the central reviewer credential lifecycle, not Naruon product code. +A second trust-boundary defect was confirmed on 2026-09-02 while closing PR #1674 review findings: after model preparation, `Revalidate live Noema target before publication` still queried the target PR with the central `.github` workflow `github.token`, and only afterwards minted the fresh publication GitHub App token. That ordering cannot read a private sibling repository and can discard a valid prepared verdict before the fresh scoped authority exists. The OIDC path also reused its pre-model exchanged app token at publication, leaving the same long-running-review expiry class possible there. + ## Closed operating contract Noema separates model verdict preparation from GitHub publication. Preparation remains bound to the trigger's canonical exact head and the exact base commit that defined the reviewed diff/context, and stores only a bounded, owner-only, single-link runner-local envelope. If preparation intentionally skips because the PR is stale, draft, or already reviewed, the workflow emits `prepared=false` and performs no publication. -For the GitHub App path, a second repository-scoped installation token is minted only after model work and only when a publishable envelope exists. Publication never reuses the predecessor App token, never falls back to `github.token` or the PR author, and independently re-fetches the live PR/head/base and reviewer actor before submitting evidence. A base-branch advance with an unchanged PR head invalidates the prepared verdict because the changed-file diff and review context may have changed; such predecessor-base evidence is consumed without publication. PAT and OIDC remain explicit sources: publication uses only the selected source and fails closed if it is absent; this repair does not silently convert those paths to another authority. +For the GitHub App path, a second repository-scoped installation token is minted only after model work and only when a publishable envelope exists. For the OIDC path, a fresh OIDC identity token is likewise exchanged for a new repository-scoped app token after model work and before any publication-boundary GitHub read. PAT remains the explicitly selected secret authority. The live publication revalidation and the final publication use the same selected publication-phase authority; neither may use the predecessor App/OIDC token, `github.token`, or the PR author. The live PR is re-fetched with that scoped authority and exact head/state/draft are validated before evidence is submitted. A base-branch advance with an unchanged PR head invalidates the prepared verdict because the changed-file diff and review context may have changed; such predecessor-base evidence is consumed without publication. -The envelope is deleted after every publication attempt, including malformed-envelope read validation failures. Executable regressions cover preparation-without-publication, exact-head/base/actor rebinding, stale heads, base drift with an unchanged head, draft skip behavior, cleanup, and hard-link alias rejection. Step-scoped workflow regressions prove that the second App mint sits between preparation and publication and that publication references the fresh token. +The envelope is deleted after every publication attempt, including malformed-envelope read validation failures. Executable regressions cover preparation-without-publication, exact-head/base/actor rebinding, stale heads, base drift with an unchanged head, draft skip behavior, cleanup, hard-link alias rejection, fresh App/OIDC publication authority, ordering of the refresh before the private-sibling live lookup, and the prohibition on central-token fallback. ## Verification and downstream replay -Focused CI runs the token-lifetime and two-phase handoff regressions with hash-pinned review dependencies whenever the workflow/helper/contracts change. After protected-main merge, replay unchanged `naruon#1497@152d1998c4e8024be9dc7026c8789d343c884fd0`: Required Noema Review must finish with current-head-and-base schema-valid review evidence or a typed review-unavailable result, never opaque expired-token 401 and never stale-head/base publication. A pre-merge run does not prove the merged workflow-source path and is not promoted to release evidence. +Focused CI runs the token-lifetime and two-phase handoff regressions with hash-pinned review dependencies whenever the workflow/helper/contracts change. After protected-main merge, replay unchanged `naruon#1497@152d1998c4e8024be9dc7026c8789d343c884fd0` and a private sibling target: Required Noema Review must finish with current-head-and-base schema-valid review evidence or a typed review-unavailable result, never opaque expired-token 401, private-sibling lookup failure caused by the central token, or stale-head/base publication. A pre-merge run does not prove the merged workflow-source path and is not promoted to release evidence. ### Regression-suite migration -The two-phase migration also updates pre-existing executable workflow contracts to target the `Prepare Noema model verdict` step and the explicit prepare/publish helper invocations. This prevents a green focused gate from coexisting with stale broader-suite expectations for the retired single-process command or step name. +The two-phase migration also updates pre-existing executable workflow contracts to target the `Prepare Noema model verdict` step and the explicit prepare/publish helper invocations. The 2026-09-02 extension additionally requires the publication-phase App and OIDC refresh steps to precede `Revalidate live Noema target before publication`, and requires that both the revalidation and publish steps reference only the selected fresh scoped publication authority. This prevents a green focused gate from coexisting with stale broader-suite expectations for the retired single-process command, stale pre-model credentials, or the central repository token at a private-sibling publication boundary. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..f24af8e3e4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2649,6 +2649,27 @@ Higgins, S. S., Crepalde, N., & Fernandes, L. (2021). Segmented multiplexity: A **Follow-up.** If the organization later solves free+ZDR routing robustly enough to deliberately widen required-review CI to `orchestrator/auto` (e.g. once a spend ceiling and reviewer-visible cost evidence exist for that path), the change is exactly one `case` arm plus the corresponding assertions in `test_sidecar_pins_the_pool_to_free_for_github_actions` — this entry is the record of *why* it was narrowed, not a permanent prohibition. +## 2026-09-02 org-wide central-review failure-pattern triage: closed-vs-stale conflation fixed, two known gaps confirmed still open in production, one high-frequency pattern confirmed already tracked upstream + +**Trigger.** Owner request ("팀원에게 아예 실패 패턴을 쭉 취합해서 처리하라고하는 게 빠를 듯") to stop investigating individual failed central-review runs one at a time as they are noticed and instead gather the failure patterns across the organization and handle them together. Surveyed `gh api repos///actions/runs?status=failure` across 12 repositories (`newsdom-api`, `html4tree`, `ConceptWeave`, `contextual-orchestrator`, `naruon`, `keyverse`, `noema`, `EgressWeave`, `wardnet`, `TEPP`, `LineageWeave`, `argos`), then pulled full job logs (`gh api repos///actions/jobs//logs`) and read the terminal `##[error]` for a 21-job sample spanning `noema-review`, `opencode-review`, and `strix` across roughly the last 24-48 hours, classifying each into a failure-signature bucket rather than counting surface-similar messages together. This is a representative sample, not an exhaustive audit — the organization has well over a hundred repositories and this pass checked 12 with real evidence. + +**Bucket — malformed JSON primary response (already tracked).** 1 further confirmed occurrence in the sample: `newsdom-api` PR #766, run `33544791026` / job `99979509036`, `Noema LLM response was not valid JSON (Expecting ',' delimiter: ...)`. Same signature as the already-tracked `contextual-orchestrator#998`/`#1004` (gateway does not validate structured-output responses against the declared schema) and `.github#1672` (adds an OpenAI `response_format: json_schema` envelope plus local lossless JSON repair as a fast pre-network step). `#1672` is open and not yet merged (`mergeable_state: behind` as of this snapshot) — this failure predates that fix landing on `main`, consistent with the already-identified root cause. No new bucket. + +**Bucket — repair-retry hits the unauthorized 900-second deadline (already tracked).** 2 further confirmed occurrences in the sample: `argos` PR #552 (run `33561544624` / job `100034968676`) and `html4tree` PR #594 (run `33559151563` / job `100027178226`), both `NoemaRepairDeadlineExceeded: Noema repair exceeded 900-second absolute wall-clock deadline`. Same signature as `.github#1672`'s subject; still reproducing because that fix has not merged. No new bucket. + +**Bucket — content-quality verdict rejected, no visible retry (partially covered, gap confirmed real).** Read `.github#1672`'s diff directly (`scripts/ci/noema_review_gate.py`'s new `NOEMA_VERDICT_RESPONSE_FORMAT`) against `validate_substantive_verdict()`'s current code (`scripts/ci/noema_review_gate.py:483-568` on `main`) to answer the task's open question directly rather than assume: `#1672`'s `adversarial_validation.probes` schema is `{"type": "array", "items": _NOEMA_PROBE_SCHEMA}` with **no `minItems`** — it cannot enforce `validate_substantive_verdict()`'s dynamic `required_probes` (`2 if any(changed_file_is_material(path) for path in all_changed_paths) else 1`), and OpenAI structured-output `strict: true` mode does not support `minItems` even if added. **`#1672` does not close the ConceptWeave-shaped gap.** A second, previously-unreported instance of the same class was found in this sample: `LineageWeave` PR #908, run `33542875875` / job `99973195388`, `Noema reviewed line 2 is not an exact changed-side line` — the `reviewed_lines` location-fidelity check, which by construction can never be schema-validated at all (it requires cross-referencing the model's cited `path`/`line`/`side` against the actual diff content, not just shape). Both are `NoemaModelOutputError` raised inside `call_llm`'s own try block (`scripts/ci/noema_review_gate.py:1239-1442`), which per a static read of the current code should trigger exactly one repair-retry with the specific rejection fed back to the model (`"Your prior verdict was rejected by the trusted validator: ..."`) before failing closed with a wrapped `"Noema model-output repair remained invalid; initial failure: ...; repair failure: ..."` message. Both the ConceptWeave and LineageWeave production logs instead show a single **raw, unwrapped** error with no such wrapping — meaning either the retry silently is not firing for this exception class in production, or it fires and fails in a way this session could not distinguish from a first-attempt failure by log inspection alone. Flagged as an open discrepancy between the code-as-read and the code-as-observed-running; not fixed in this pass, since a wrong guess here would ship a confidently incorrect fix for a mechanism this session could not reproduce live. + +**Correction (same day, 2026-09-02).** The paragraph above read `.github#1672` while it still had only its first commit (`1907259`); a second, concurrently-running session pushed a follow-up commit (`4ed1334`) to the same PR minutes later, closing exactly the gap this paragraph reported open. Re-read directly against `#1672`'s current head (`4ed1334c4388203a91321a3d625b15c741e1da55`) rather than trusting the earlier snapshot: `scripts/ci/noema_review_gate.py` now has a `_required_probe_count(diff, changed_paths)` helper as the single source of truth for the probe-count floor, called from both `validate_substantive_verdict` (the Python-side backstop, unchanged) and a new `_noema_verdict_json_schema(required_probes)` / `_noema_verdict_response_format(required_probes)` pair that sets `adversarial_validation.probes.minItems = required_probes` fresh per request — so the schema-level `minItems` claimed absent above is present and can never drift from the Python check. Separately, tracing `call_llm`'s exception handling directly (not by log inspection) resolves the "raw, unwrapped" retry-visibility question this paragraph left open: `NoemaModelOutputError` (the exception `validate_substantive_verdict` raises, including the ConceptWeave/LineageWeave probe-count and reviewed-line-fidelity cases) is a `RuntimeError` subclass, so it is already caught by `call_llm`'s single generic `except (RuntimeError, urllib.error.URLError, http.client.HTTPException, OSError)` block alongside every other failure mode — there was never a separate code path that skips the repair retry for this exception class. What `#1672` adds is the *telemetry* that makes the already-existing retry observable: a `::notice::` on the primary-attempt failure before the repair call fires, a `::warning::` on the repair attempt's own outcome, and a terminal message that explicitly wraps both (`"Noema model-output repair remained invalid; initial failure: ...; repair failure: ..."`). The ConceptWeave and LineageWeave production logs this paragraph cites both predate `#1672`; they show a single raw error not because the retry failed to fire, but because nothing in `main` at the time logged the primary attempt's failure or wrapped the repair's terminal message — the retry was already happening, invisibly. No further code gap here: once `#1672` merges, a recurrence of this bucket will show both attempts in the job log. Verified by direct inspection of `scripts/ci/noema_review_gate.py` at `#1672` head `4ed1334`, not re-guessed. + +**Bucket — Noema review target closed via another path before the async job's live-state gate ran (NEW, fixed this session).** 4 confirmed occurrences in the sample, all with `live state=closed` and a live head SHA **identical** to the expected head — i.e. the PR was resolved (merged/closed) through another path (typically the merge scheduler) before this independent async review job got around to its own pre-LLM live-PR-state check, not a genuine stale-head race: `EgressWeave` PR #231 (run `33507129541` / job `99853744688`), `newsdom-api` PR #780 (run `33564244863` / job `100043651869`) and PR #782 (run `33565683546` / job `100048191837`, the instance the owner surfaced directly), and `wardnet` PR #147 (run `33563991825` / job `100042871788`). `noema-review.yml`'s "Validate current pull request head" step conflated this with genuine staleness into one `[ "$live_state" != "open" ] || [ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]` condition and reported both as `##[error]`, unlike `opencode-review.yml`'s two structurally equivalent steps ("Request current-head OpenCode review execution", "Fail closed without a current-head OpenCode verdict"), which already check head-match first (real staleness → error) and only then check state (closed-on-current-head → `::notice::` + `exit 0`) — proof this separation is already this repository's own established, working pattern, not a new invention. **Fix:** applied the identical separation to `noema-review.yml` (`.github/workflows/noema-review.yml`), preserving the exact pinned substring `'[ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]'` that `tests/test_required_workflow_queue_contract.py::test_noema_triggers_preserve_standalone_pull_request_review` already asserts. Downstream steps needed no additional gating: `scripts/ci/noema_review_gate.py::require_expected_head()` (called from `.github/actions/noema-review/two_phase.py::prepare_verdict()`) already independently rejects closed PRs and gracefully no-ops (`prepared=false`, publish step skipped) regardless of this earlier gate, so the fix only needed to stop the early gate from reporting a false failure — it did not need to short-circuit the rest of the job. Added `tests/test_opencode_workflow_shell_syntax.py::test_noema_validate_head_distinguishes_closed_from_stale`, which extracts the step's actual bash (`_extract_run_block`, the same harness the file's existing merge-scheduler dispatch tests use) and executes it against four fake-`gh` scenarios — open+current head (continue silently), closed+current head (notice + exit 0), open+genuinely-stale head (error + exit 1), closed+genuinely-stale head (error + exit 1, staleness still wins) — proving the two previously-conflated cases are now told apart in both directions. Full noema/opencode/strix-adjacent contract test files (`test_required_workflow_queue_contract.py`, `test_noema_orchestrator_workflow_contract.py`, `test_contextual_orchestrator_review_sidecar_contract.py`, `test_opencode_workflow_shell_syntax.py`; 121 tests) pass with the change. **Scoped decision:** `strix.yml`'s one analogous `repository_dispatch` live-PR-state check ("Validate repository dispatch against live pull request metadata") has the same `state != open` unconditional-error shape, but this session found no production instance of it firing for a closed-but-current-head PR, and fixing it correctly would require threading a new skip signal through several subsequent sequential steps (trusted-workspace materialization, checkout) in a security-sensitive job this session could not fully verify end-to-end in the time available — extending the fix there without an observed failure and without full downstream verification was judged a worse risk/reward trade than leaving it as a documented follow-up. `opencode-review.yml` and `opencode-review-dispatch.yml`'s equivalent checks were read and left untouched: the former already implements the correct pattern; the latter's `validate-pr-metadata` job is a repository_dispatch anti-spoofing gate with several existing pinned contract-test assertions (`tests/test_opencode_agent_contract.py`) on its exact bash, and is a different security context (dispatch-authenticity validation, not simple staleness) that this session judged too risky to touch without a confirmed production instance. + +**Bucket — Strix "provider/backend was unavailable" (NEW, high-frequency, confirmed already tracked upstream, not fixed here).** 6 of 21 sampled failures — the single most common signature in the sample — across `argos` (run `33506914619` / job `99853034949`), `html4tree` (run `33488938585` / job `99795551454`), `keyverse` (run `33504687035` / job `99845827275`), `naruon` (run `33500640685` / job `99832917654`), `newsdom-api` (run `33491130073` / job `99802633161`), and `wardnet` (run `33505617601` / job `99848826685`). Root cause read directly from the job logs, not guessed from the error string: Strix's own `orchestrator/free`-routed LLM calls report `Could not establish connection to the language model` on all 3 of the existing retry attempts (`strix.yml`'s own gate already retries with 90s/180s backoff and only fails closed after exhausting them — the retry/backoff/fail-closed design itself is correct and not the bug). Per this task's own instruction, checked `gh pr list --repo ContextualWisdomLab/contextual-orchestrator --state open` before considering any fix: 27 open PRs at this snapshot are actively working the `orchestrator/free` routing/discovery/capacity surface specifically (`#971`, `#972`, `#976`, `#977`, `#983`, `#985`, `#993`, `#996`, `#1006`, `#1007`, `#1008`, `#1009`, and others), and this document's own accumulated 2026-08-30/08-31 entries above already record `orchestrator/free` capacity and routing reliability as a known, actively-worked area going back several days — this is squarely inside that in-flight work, not a `.github`-side code bug, so nothing was changed here. Recorded so whoever is driving that work has this session's cross-repo frequency evidence (6/21, the largest single bucket in the sample) rather than having to re-discover it. + +**Correct-behavior instances (not bugs, noted for completeness).** `TEPP` run `33565390036` / job `100047280677` (`Noema trigger is stale; expected X, observed Y` — a genuinely different head, correctly rejected before any model spend) and `LineageWeave` run `33547348700` / job `100029200059` (`Pull request head moved while validating live review state` on PR #911, one of 13 near-consecutive OpenCode Review failures inside 17 minutes on a rapidly-iterating PR — consistent with expected coalescing behavior on a PR receiving pushes faster than review turnaround, not a defect). + +**Singleton observations (not chased — task guidance treats a one-off as possible transient noise, and neither recurred in this sample).** `naruon` run `33557438932` / job `100021592801`: `Noema GitHub App identity binding is invalid`, most consistent with a transient `create-github-app-token` action hiccup (missing `app-slug` output) rather than a structural misconfiguration, since the same credential path succeeds in every other sampled `naruon` run. `html4tree` run `33465725891` / job `99863930118`: `No APPROVED or CHANGES_REQUESTED from opencode-agent on the current head` — a downstream symptom of a missing verdict rather than a distinct root cause on its own. + +**Sample coverage.** 12 of the organization's well-over-a-hundred repositories; 21 job logs read in full; roughly the last 24-48 hours of activity. This closes the loop on "gather the patterns before reacting one at a time" for this window — it is not, and is not represented as, an exhaustive organization-wide audit. ## 2026-09-02 org-queue-sweep investigation: historical conclusion superseded by PR #1821 **Current status (2026-09-04).** The conclusion below was invalidated by live queue evidence. PR #1821 removed the organization-wide Actions-run inventory and cancellation block from `org-queue-sweep` and merged as `11bb6a7871f4d95ab8a3eab616b4264d02327010`. Native per-PR concurrency and the current-head coalescer now own stale-run cancellation; the scheduled sweep retains only missed review, merge, and branch-update recovery. Focused ownership contracts passed 78 tests before merge. This preserves the event-gap recovery described below without paying the repository-wide run-listing and cancellation API cost. diff --git a/scripts/ci/pr1674_exact_findings_repair.py b/scripts/ci/pr1674_exact_findings_repair.py new file mode 100644 index 0000000000..8e2bdcc08e --- /dev/null +++ b/scripts/ci/pr1674_exact_findings_repair.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Repair exact-head PR #1674 review findings before the existing one-shot materializer. + +This bridge edits only the already-reviewed one-shot transformer plus permanent +Noema workflow/test/docs contracts. It deletes itself before the successor +commit so no standing source-fix driver remains. +""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +STRIX_HELPER = ROOT / "scripts/ci/pr1674_status_publication_repair.py" +NOEMA_WORKFLOW = ROOT / ".github/workflows/noema-review.yml" +NOEMA_TEST = ROOT / "tests/test_noema_live_target_admission.py" +CHANGELOG = ROOT / "CHANGELOG.md" +SELF = ROOT / "scripts/ci/pr1674_exact_findings_repair.py" + + +def replace_exact(text: str, old: str, new: str, *, expected: int, label: str) -> str: + """Replace an exact number of anchors or fail closed on concurrent drift.""" + count = text.count(old) + if count != expected: + raise RuntimeError(f"{label}: expected {expected} anchor(s), found {count}") + return text.replace(old, new) + + +def repair_strix_materializer() -> None: + """Make the existing Strix transformer executable and fail-closed.""" + text = STRIX_HELPER.read_text(encoding="utf-8") + old_admission = "steps.dispatch_validation.outputs.should_scan != 'false' && github.event_name == 'repository_dispatch'" + new_admission = "steps.dispatch_validation.outputs.should_scan == 'true' && github.event_name == 'repository_dispatch'" + # The helper intentionally contains three copies: FRESH_TOKEN_STEP, + # SCAN_REVALIDATION_STEP, and the old scan_publish_anchor used by the + # second-stage materializer. Only the first two are executable admission + # conditions; preserving the third is required so the second materializer + # can still locate and replace the predecessor publisher exactly once. + if text.count(old_admission) != 3: + raise RuntimeError( + "late repository_dispatch admission conditions: expected three ordered anchors" + ) + text = text.replace(old_admission, new_admission, 2) + text = replace_exact( + text, + "if: github.event_name != 'repository_dispatch' || steps.dispatch_validation.outputs.should_scan != 'false'\\n", + "if: github.event_name != 'repository_dispatch' || steps.dispatch_validation.outputs.should_scan == 'true'\\n", + expected=1, + label="target visibility skip condition", + ) + old = ( + ' text = replace_once(\n' + ' text,\n' + ' " TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || \'\' }}\\n",\n' + ' " TARGET_APP_STATUS_TOKEN: ${{ steps.status_target_app_token.outputs.token || \'\' }}\\n",\n' + ' "scan publisher fresh app token",\n' + ' )\n' + ) + new = ( + ' text = replace_once(\n' + ' text,\n' + ' " if: ${{ always() && !cancelled() && steps.dispatch_publish_validation.outputs.publish_status == \'true\' && github.event_name == \'repository_dispatch\' && github.event.client_payload.pr_head_sha != \'\' }}\\n"\n' + ' " env:\\n"\n' + ' " TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || \'\' }}\\n",\n' + ' " if: ${{ always() && !cancelled() && steps.dispatch_publish_validation.outputs.publish_status == \'true\' && github.event_name == \'repository_dispatch\' && github.event.client_payload.pr_head_sha != \'\' }}\\n"\n' + ' " env:\\n"\n' + ' " TARGET_APP_STATUS_TOKEN: ${{ steps.status_target_app_token.outputs.token || \'\' }}\\n",\n' + ' "scan publisher fresh app token",\n' + ' )\n' + ) + text = replace_exact(text, old, new, expected=1, label="unique scan publisher token anchor") + STRIX_HELPER.write_text(text, encoding="utf-8") + + +def repair_noema_oidc() -> None: + """Require HTTPS-only transport before either Noema OIDC bearer exchange.""" + text = NOEMA_WORKFLOW.read_text(encoding="utf-8") + request_anchor = ''' request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" +''' + request_replacement = ''' request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + case "$request_url" in + https://*) ;; + *) fail_unavailable "Noema OIDC request URL must use https:// before transmitting a bearer token." ;; + esac + case "${TOKEN_EXCHANGE_URL:-}" in + https://*) ;; + *) fail_unavailable "Noema token exchange URL must use https:// before transmitting a bearer token." ;; + esac + separator="&" +''' + text = replace_exact( + text, + request_anchor, + request_replacement, + expected=2, + label="Noema OIDC HTTPS preflight", + ) + text = replace_exact( + text, + ''' curl -fsS \\ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \\ +''', + ''' curl --proto '=https' --proto-redir '=https' -fsS \\ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \\ +''', + expected=2, + label="Noema OIDC HTTPS-only request curl", + ) + text = replace_exact( + text, + ''' curl -fsS \\ + -X POST \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${oidc_token}" \\ +''', + ''' curl --proto '=https' --proto-redir '=https' -fsS \\ + -X POST \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${oidc_token}" \\ +''', + expected=2, + label="Noema app-token HTTPS-only exchange curl", + ) + NOEMA_WORKFLOW.write_text(text, encoding="utf-8") + + +def repair_noema_contract_test() -> None: + """Align permanent assertions with repository-scoped credentials and HTTPS transport.""" + text = NOEMA_TEST.read_text(encoding="utf-8") + old_loop = ''' for step_name, step_id in ( + (refresh_name, "live_pr_refresh"), + (publish_name, "live_pr_publish"), + ): + step_body = _step_body(workflow_text, step_name) + assert f" id: {step_id}\\n" in step_body + assert "GH_TOKEN: ${{ github.token }}" in step_body + assert "live_state=" in step_body + assert "live_head_sha=" in step_body + assert "live_draft=" in step_body + assert 'echo "proceed=false" >>"$GITHUB_OUTPUT"' in step_body + assert 'echo "proceed=true" >>"$GITHUB_OUTPUT"' in step_body + assert "EXPECTED_HEAD_SHA" in step_body + assert "live_head_sha,," in step_body +''' + new_loop = ''' for step_name, step_id in ( + (refresh_name, "live_pr_refresh"), + (publish_name, "live_pr_publish"), + ): + step_body = _step_body(workflow_text, step_name) + assert f" id: {step_id}\\n" in step_body + assert "github.token" not in step_body + assert "GH_TOKEN:" in step_body + assert "live_state=" in step_body + assert "live_head_sha=" in step_body + assert "live_draft=" in step_body + assert 'echo "proceed=false" >>"$GITHUB_OUTPUT"' in step_body + assert 'echo "proceed=true" >>"$GITHUB_OUTPUT"' in step_body + assert "EXPECTED_HEAD_SHA" in step_body + assert "live_head_sha,," in step_body +''' + text = replace_exact(text, old_loop, new_loop, expected=1, label="repository-scoped revalidation assertion") + old_publication = ''' for publication_step in ( + "Refresh repository-scoped Noema GitHub App token for publication", + "Publish prepared Noema verdict on the exact live head", + ): + assert "steps.live_pr_publish.outputs.proceed == 'true'" in _step_body( + workflow_text, + publication_step, + ) +''' + new_publication = ''' publication_revalidation_index = workflow_text.index( + " - name: Revalidate live Noema target before publication\\n" + ) + for refresh_step in ( + "Refresh repository-scoped Noema GitHub App token for publication", + "Refresh repository-scoped Noema OIDC app token for publication", + ): + assert workflow_text.index(f" - name: {refresh_step}\\n") < publication_revalidation_index + assert "steps.live_pr_publish.outputs.proceed" not in _step_body(workflow_text, refresh_step) + + assert "steps.live_pr_publish.outputs.proceed == 'true'" in _step_body( + workflow_text, + "Publish prepared Noema verdict on the exact live head", + ) +''' + text = replace_exact(text, old_publication, new_publication, expected=1, label="publication credential ordering assertions") + if "def test_oidc_bearer_exchanges_require_https_only_transport" not in text: + text += '''\n\ndef test_oidc_bearer_exchanges_require_https_only_transport() -> None: + """Neither initial nor refreshed OIDC bearer material may traverse plaintext HTTP.""" + workflow_text = _workflow_text() + for step_name in ( + "Exchange Noema app token through OIDC", + "Refresh repository-scoped Noema OIDC app token for publication", + ): + body = _step_body(workflow_text, step_name) + assert 'case "$request_url" in' in body + assert 'https://*) ;;' in body + assert 'case "${TOKEN_EXCHANGE_URL:-}" in' in body + assert "--proto '=https' --proto-redir '=https'" in body + assert "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" in body + assert "Authorization: Bearer ${oidc_token}" in body +''' + NOEMA_TEST.write_text(text, encoding="utf-8") + + +def update_changelog() -> None: + """Record the security/authority repair without duplicating an existing entry.""" + text = CHANGELOG.read_text(encoding="utf-8") + entry = '''## 2026-09-02 — Noema/Strix live-publication authority hardening + +- Revalidate long-running Strix `repository_dispatch` targets immediately before either status publisher, fail closed on head/repository/lookup drift, and cleanly suppress exact-head closed/draft publication; skipped dispatches no longer perform visibility admission work. +- Require HTTPS-only OIDC request and token-exchange transport before Noema sends bearer material, while keeping final publication on freshly selected repository-scoped credentials. +- Added permanent workflow-level regressions for both late Strix publication boundaries, skipped-target propagation, Noema credential ordering, and HTTPS-only bearer exchange. + +''' + if entry not in text: + CHANGELOG.write_text(entry + text, encoding="utf-8") + + +def main() -> int: + """Apply all exact-head review findings, then retire this bridge.""" + repair_strix_materializer() + repair_noema_oidc() + repair_noema_contract_test() + update_changelog() + SELF.unlink() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/pr1674_status_publication_repair.py b/scripts/ci/pr1674_status_publication_repair.py new file mode 100644 index 0000000000..4b80b96740 --- /dev/null +++ b/scripts/ci/pr1674_status_publication_repair.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Materialize PR #1674 Strix live-publication authority on the existing writer branch. + +The transformer is deliberately exact-anchor based. It refuses drift rather than +silently rewriting concurrent workflow changes. The temporary driver and its +workflow are removed from the prepared tree before publication. +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/strix.yml" +TEST = ROOT / "tests/test_strix_repository_dispatch_live_state.py" +SELF = ROOT / "scripts/ci/pr1674_status_publication_repair.py" +TEMP_WORKFLOW = ROOT / ".github/workflows/_temp_pr1674_status_publication_repair.yml" + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + """Replace one exact anchor or fail closed on drift/ambiguity.""" + count = text.count(old) + if count != 1: + raise RuntimeError(f"{label}: expected exactly one anchor, found {count}") + return text.replace(old, new, 1) + + +FRESH_TOKEN_STEP = r''' - name: Refresh OpenCode app token for Strix status revalidation + id: status_target_app_token + if: ${{ always() && !cancelled() && steps.dispatch_validation.outputs.should_scan != 'false' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "Strix status revalidation app token unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + if ! oidc_response="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${request_url}${separator}audience=${OIDC_AUDIENCE}")"; then + echo "Strix status revalidation app token unavailable: OIDC request failed." + mark_unavailable + exit 0 + fi + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "Strix status revalidation app token unavailable: OIDC response was empty." + mark_unavailable + exit 0 + fi + if ! token_response="$(curl -fsS -X POST -H "Authorization: Bearer ${oidc_token}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token")"; then + echo "Strix status revalidation app token unavailable: app-token exchange failed." + mark_unavailable + exit 0 + fi + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "Strix status revalidation app token unavailable: exchange response was empty." + mark_unavailable + exit 0 + fi + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + +''' + +LIVE_REVALIDATION_RUN = r''' set -euo pipefail + if ! [[ "$REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Strix publication revalidation metadata is malformed." + exit 1 + fi + if ! pull_request_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Could not revalidate live Strix pull request before status publication." + exit 1 + fi + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_draft="$(jq -r 'if .draft == true then "true" elif .draft == false then "false" else empty end' <<<"$pull_request_json")" + live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + if [ -z "$live_state" ] || [ -z "$live_draft" ] || + [ "$live_base_repository" != "$REPOSITORY" ] || [ -z "$live_head_sha" ]; then + printf '::error::Strix publication target is unverifiable for %s#%s. state=%s draft=%s base_repo=%s head=%s.\n' \ + "$REPOSITORY" "$PR_NUMBER" "${live_state:-missing}" "${live_draft:-missing}" \ + "${live_base_repository:-missing}" "${live_head_sha:-missing}" + exit 1 + fi + if [ "$live_head_sha" != "$EXPECTED_HEAD_SHA" ]; then + printf '::error::Strix publication target head moved for %s#%s: expected=%s live=%s.\n' \ + "$REPOSITORY" "$PR_NUMBER" "$EXPECTED_HEAD_SHA" "$live_head_sha" + exit 1 + fi + if [ "$live_state" = "closed" ] || { [ "$live_state" = "open" ] && [ "$live_draft" = "true" ]; }; then + echo "publish_status=false" >>"$GITHUB_OUTPUT" + printf '::notice::Strix publication suppressed for exact-head non-reviewable target %s#%s state=%s draft=%s.\n' \ + "$REPOSITORY" "$PR_NUMBER" "$live_state" "$live_draft" + exit 0 + fi + if [ "$live_state" != "open" ] || [ "$live_draft" != "false" ]; then + printf '::error::Strix publication target has unexpected live state for %s#%s: state=%s draft=%s.\n' \ + "$REPOSITORY" "$PR_NUMBER" "$live_state" "$live_draft" + exit 1 + fi + echo "publish_status=true" >>"$GITHUB_OUTPUT" +''' + +SCAN_REVALIDATION_STEP = r''' - name: Revalidate repository dispatch before status publication + id: dispatch_publish_validation + if: ${{ always() && !cancelled() && steps.dispatch_validation.outputs.should_scan != 'false' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} + env: + GH_TOKEN: ${{ steps.status_target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + REPOSITORY: ${{ github.event.client_payload.target_repository }} + PR_NUMBER: ${{ github.event.client_payload.pr_number }} + EXPECTED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} + run: | +''' + LIVE_REVALIDATION_RUN + "\n" + +FOLLOWUP_REVALIDATION_STEP = r''' - name: Revalidate repository dispatch before follow-up status publication + id: followup_publish_validation + env: + GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + REPOSITORY: ${{ github.event.client_payload.target_repository }} + PR_NUMBER: ${{ github.event.client_payload.pr_number }} + EXPECTED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} + run: | +''' + LIVE_REVALIDATION_RUN + "\n" + + +def repair_workflow(text: str) -> str: + """Apply late-bound Strix authority and skip propagation without gate weakening.""" + text = replace_once( + text, + " outputs:\n should_scan: ${{ steps.dispatch_validation.outputs.should_scan }}\n", + " outputs:\n should_scan: ${{ steps.dispatch_validation.outputs.should_scan }}\n" + " publish_status: ${{ steps.dispatch_publish_validation.outputs.publish_status }}\n", + "strix job outputs", + ) + text = replace_once( + text, + " - name: Resolve target repository visibility\n id: target_visibility\n env:\n", + " - name: Resolve target repository visibility\n id: target_visibility\n" + " if: github.event_name != 'repository_dispatch' || steps.dispatch_validation.outputs.should_scan != 'false'\n" + " env:\n", + "target visibility skip guard", + ) + + scan_publish_anchor = ( + " - name: Publish same-head manual Strix status\n" + " if: ${{ always() && !cancelled() && steps.dispatch_validation.outputs.should_scan != 'false' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }}\n" + ) + text = replace_once( + text, + scan_publish_anchor, + FRESH_TOKEN_STEP + + SCAN_REVALIDATION_STEP + + " - name: Publish same-head manual Strix status\n" + + " if: ${{ always() && !cancelled() && steps.dispatch_publish_validation.outputs.publish_status == 'true' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }}\n", + "scan status publication boundary", + ) + text = replace_once( + text, + " TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }}\n", + " TARGET_APP_STATUS_TOKEN: ${{ steps.status_target_app_token.outputs.token || '' }}\n", + "scan publisher fresh app token", + ) + text = replace_once( + text, + " if: ${{ always() && !cancelled() && needs.strix.outputs.should_scan == 'true' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }}\n", + " if: ${{ always() && !cancelled() && needs.strix.outputs.should_scan == 'true' && needs.strix.outputs.publish_status == 'true' && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }}\n", + "follow-up job live authority condition", + ) + text = replace_once( + text, + " permissions:\n id-token: write\n statuses: write # Required for downscoped OIDC status publication.\n", + " permissions:\n id-token: write\n pull-requests: read\n statuses: write # Required for downscoped OIDC status publication.\n", + "follow-up live PR read permission", + ) + followup_publish_anchor = ( + " - name: Publish same-head manual Strix status\n" + " env:\n" + " TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }}\n" + ) + text = replace_once( + text, + followup_publish_anchor, + FOLLOWUP_REVALIDATION_STEP + + " - name: Publish same-head manual Strix status\n" + + " if: steps.followup_publish_validation.outputs.publish_status == 'true'\n" + + " env:\n" + + " TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }}\n", + "follow-up late publication boundary", + ) + return text + + +def repair_test(text: str) -> str: + """Extend the existing executable contract to cover the follow-up job boundary.""" + anchor = ( + " status_condition = str(status_job.get(\"if\", \"\"))\n" + " assert \"needs.strix.outputs.should_scan == 'true'\" in status_condition\n" + " assert \"needs.strix.outputs.publish_status == 'true'\" in status_condition\n" + " assert \"github.event_name == 'repository_dispatch'\" in status_condition\n" + ) + replacement = anchor + ( + "\n status_steps = status_job[\"steps\"]\n" + " followup_validation = next(\n" + " step for step in status_steps\n" + " if step.get(\"name\") == \"Revalidate repository dispatch before follow-up status publication\"\n" + " )\n" + " assert followup_validation.get(\"id\") == \"followup_publish_validation\"\n" + " assert str(followup_validation.get(\"run\")) == str(\n" + " _step(\"Revalidate repository dispatch before status publication\").get(\"run\")\n" + " )\n" + " followup_token = str(followup_validation.get(\"env\", {}).get(\"GH_TOKEN\", \"\"))\n" + " assert \"target_app_token.outputs.token\" in followup_token\n" + " followup_publish = next(\n" + " step for step in status_steps if step.get(\"name\") == \"Publish same-head manual Strix status\"\n" + " )\n" + " assert \"steps.followup_publish_validation.outputs.publish_status == 'true'\" in str(\n" + " followup_publish.get(\"if\", \"\")\n" + " )\n" + ) + return replace_once(text, anchor, replacement, "follow-up status regression") + + +def assert_contract(workflow: str, tests: str) -> None: + """Perform dependency-free structural checks before exact-head CI takes over.""" + required = { + "visibility skip guard": "steps.dispatch_validation.outputs.should_scan != 'false'", + "late scan token": "id: status_target_app_token", + "late scan validation": "id: dispatch_publish_validation", + "late scan decision": "publish_status: ${{ steps.dispatch_publish_validation.outputs.publish_status }}", + "follow-up validation": "id: followup_publish_validation", + "follow-up decision": "needs.strix.outputs.publish_status == 'true'", + "follow-up publish guard": "steps.followup_publish_validation.outputs.publish_status == 'true'", + } + for label, needle in required.items(): + if needle not in workflow: + raise RuntimeError(f"missing {label}: {needle}") + if "test_repository_dispatch_revalidates_live_state_again_before_status_publication" not in tests: + raise RuntimeError("existing executable status revalidation regression disappeared") + if "followup_publish_validation" not in tests: + raise RuntimeError("follow-up publication boundary lacks regression coverage") + + +def main() -> int: + """Materialize the source/test repair and remove this one-shot machinery.""" + workflow = repair_workflow(WORKFLOW.read_text(encoding="utf-8")) + tests = repair_test(TEST.read_text(encoding="utf-8")) + assert_contract(workflow, tests) + WORKFLOW.write_text(workflow, encoding="utf-8") + TEST.write_text(tests, encoding="utf-8") + SELF.unlink() + TEMP_WORKFLOW.unlink() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_noema_live_target_admission.py b/tests/test_noema_live_target_admission.py new file mode 100644 index 0000000000..cf2359f5ac --- /dev/null +++ b/tests/test_noema_live_target_admission.py @@ -0,0 +1,115 @@ +"""Executable structural regressions for Noema live-target admission boundaries.""" + +from pathlib import Path +import re + +WORKFLOW_PATH = Path(".github/workflows/noema-review.yml") + + +def _workflow_text() -> str: + """Return the reviewed Noema workflow source.""" + return WORKFLOW_PATH.read_text(encoding="utf-8") + + +def _step_body(workflow_text: str, step_name: str) -> str: + """Return one named workflow step without borrowing sibling evidence.""" + step_match = re.search( + rf"^ - name: {re.escape(step_name)}\n(?P.*?)(?=^ - name:|\Z)", + workflow_text, + re.MULTILINE | re.DOTALL, + ) + assert step_match is not None, step_name + return step_match.group("body") + + +def test_initial_live_admission_precedes_every_setup_step() -> None: + """Closed or draft exact-head targets must skip credentials and trusted setup.""" + workflow_text = _workflow_text() + admission_name = "Validate live Noema target before any setup" + admission_index = workflow_text.index(f" - name: {admission_name}\n") + for later_step in ( + "Resolve trusted Noema review source ref", + "Materialize trusted Noema review gate", + "Cancel superseded Noema runs after live-head validation", + "Select fail-closed Noema reviewer credential", + "Mint repository-scoped Noema GitHub App token", + "Exchange Noema app token through OIDC", + ): + later_index = workflow_text.index(f" - name: {later_step}\n") + assert admission_index < later_index, later_step + assert "steps.live_pr.outputs.proceed == 'true'" in _step_body(workflow_text, later_step) + + admission_body = _step_body(workflow_text, admission_name) + assert " id: live_pr\n" in admission_body + assert "GH_TOKEN: ${{ github.token }}" in admission_body + assert "live_state=" in admission_body + assert "live_head_sha=" in admission_body + assert "live_draft=" in admission_body + assert 'echo "proceed=false" >>"$GITHUB_OUTPUT"' in admission_body + assert 'echo "proceed=true" >>"$GITHUB_OUTPUT"' in admission_body + assert "EXPECTED_HEAD_SHA" in admission_body + assert "live_head_sha,," in admission_body + + +def test_destructive_stale_run_cancellation_revalidates_state_head_and_draft() -> None: + """A cancellation candidate must be rechecked against authoritative live PR state.""" + workflow_text = _workflow_text() + cancellation_body = _step_body( + workflow_text, + "Cancel superseded Noema runs after live-head validation", + ) + assert "live_pr_json=" in cancellation_body + assert "live_head=" in cancellation_body + assert "live_state=" in cancellation_body + assert "live_draft=" in cancellation_body + assert '[ "$live_state" != "open" ]' in cancellation_body + assert '[ "$live_draft" = "true" ]' in cancellation_body + assert "EXPECTED_HEAD_SHA" in cancellation_body + assert cancellation_body.index("live_pr_json=") < cancellation_body.index("/cancel") + + +def test_model_and_publication_boundaries_refresh_live_state() -> None: + """Model work and publication must each use a fresh state/head/draft decision.""" + workflow_text = _workflow_text() + refresh_name = "Revalidate live Noema target before model setup" + publish_name = "Revalidate live Noema target before publication" + for step_name, step_id in ( + (refresh_name, "live_pr_refresh"), + (publish_name, "live_pr_publish"), + ): + step_body = _step_body(workflow_text, step_name) + assert f" id: {step_id}\n" in step_body + assert "GH_TOKEN: ${{ github.token }}" in step_body + assert "live_state=" in step_body + assert "live_head_sha=" in step_body + assert "live_draft=" in step_body + assert 'echo "proceed=false" >>"$GITHUB_OUTPUT"' in step_body + assert 'echo "proceed=true" >>"$GITHUB_OUTPUT"' in step_body + assert "EXPECTED_HEAD_SHA" in step_body + assert "live_head_sha,," in step_body + + for model_step in ( + "Resolve Noema target repository visibility", + "Provision contextual-orchestrator review sidecar", + "Prepare Noema model verdict", + ): + assert "steps.live_pr_refresh.outputs.proceed == 'true'" in _step_body( + workflow_text, + model_step, + ) + + for publication_step in ( + "Refresh repository-scoped Noema GitHub App token for publication", + "Publish prepared Noema verdict on the exact live head", + ): + assert "steps.live_pr_publish.outputs.proceed == 'true'" in _step_body( + workflow_text, + publication_step, + ) + + +def test_temporary_pr1674_self_modifying_lane_is_absent() -> None: + """The reviewed head, not a post-review workflow successor, owns the repair.""" + assert not Path(".github/workflows/_temp_pr1674_strix_live_state_repair.yml").exists() + assert not Path("scripts/ci/temp_pr1674_noema_closed_finalize.py").exists() + assert not Path(".github/.pr1674-repair-trigger").exists() diff --git a/tests/test_noema_private_target_live_admission.py b/tests/test_noema_private_target_live_admission.py new file mode 100644 index 0000000000..b696a791d4 --- /dev/null +++ b/tests/test_noema_private_target_live_admission.py @@ -0,0 +1,111 @@ +"""Regressions for private cross-repository Noema live-state admission.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "noema-review.yml" + + +def _workflow() -> dict[str, object]: + """Load the reviewed Noema workflow from the current repository tree.""" + return yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + + +def _steps() -> list[dict[str, object]]: + """Return the ordered Noema review step list for sequencing assertions.""" + return _workflow()["jobs"]["noema-review"]["steps"] + + +def _step(name: str) -> dict[str, object]: + """Return one named Noema review step from the production workflow.""" + return next(step for step in _steps() if step.get("name") == name) + + +def _step_index(name: str) -> int: + """Return a named production step's position in the Noema review job.""" + return next(index for index, step in enumerate(_steps()) if step.get("name") == name) + + +def test_cross_repository_preflight_defers_private_lookup_until_scoped_credential(tmp_path: Path) -> None: + """The central repository token must not be used to query a private sibling.""" + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash is required for the workflow-shell regression") + + step = _step("Validate live Noema target before any setup") + script = str(step["run"]) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_gh = fake_bin / "gh" + fake_gh.write_text("#!/usr/bin/env bash\nexit 97\n", encoding="utf-8") + fake_gh.chmod(0o755) + output = tmp_path / "github-output" + output.write_text("", encoding="utf-8") + + env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "TARGET_REPOSITORY": "ContextualWisdomLab/private-sibling", + "PR_NUMBER": "42", + "EXPECTED_HEAD_SHA": "a" * 40, + } + result = subprocess.run([bash, "-c", script], env=env, text=True, capture_output=True, check=False) + + assert result.returncode == 0, result.stderr + assert "proceed=true" in output.read_text(encoding="utf-8") + assert "defer" in result.stdout.lower() + + +def test_post_credential_refresh_uses_selected_repository_scoped_token() -> None: + """Authoritative cross-repository state lookup uses the minted reviewer credential.""" + step = _step("Revalidate live Noema target before model setup") + token_expression = str(step.get("env", {}).get("GH_TOKEN", "")) + assert "NOEMA_REVIEW_TOKEN" in token_expression + assert "noema_github_app_token.outputs.token" in token_expression + assert "noema_oidc_token.outputs.token" in token_expression + assert "github.token" not in token_expression + + +def test_publication_revalidation_uses_fresh_repository_scoped_authority() -> None: + """Private sibling publication refreshes expiring authorities before its live check.""" + app_refresh_name = "Refresh repository-scoped Noema GitHub App token for publication" + oidc_refresh_name = "Refresh repository-scoped Noema OIDC app token for publication" + publish_check_name = "Revalidate live Noema target before publication" + publish_name = "Publish prepared Noema verdict on the exact live head" + + assert _step_index(app_refresh_name) < _step_index(publish_check_name) + assert _step_index(oidc_refresh_name) < _step_index(publish_check_name) + + app_refresh = _step(app_refresh_name) + oidc_refresh = _step(oidc_refresh_name) + live_publish = _step(publish_check_name) + publish = _step(publish_name) + + assert "steps.noema_prepare.outputs.prepared == 'true'" in str(app_refresh.get("if", "")) + assert "steps.noema_credential.outputs.source == 'github-app'" in str(app_refresh.get("if", "")) + assert "steps.noema_prepare.outputs.prepared == 'true'" in str(oidc_refresh.get("if", "")) + assert "steps.noema_credential.outputs.source == 'oidc'" in str(oidc_refresh.get("if", "")) + + live_token_expression = str(live_publish.get("env", {}).get("GH_TOKEN", "")) + assert "NOEMA_REVIEW_TOKEN" in live_token_expression + assert "noema_github_app_publication_token.outputs.token" in live_token_expression + assert "noema_oidc_publication_token.outputs.token" in live_token_expression + assert "github.token" not in live_token_expression + assert "noema_github_app_token.outputs.token" not in live_token_expression + assert "noema_oidc_token.outputs.token" not in live_token_expression + + publish_token_expression = str(publish.get("env", {}).get("GH_TOKEN", "")) + assert "noema_github_app_publication_token.outputs.token" in publish_token_expression + assert "noema_oidc_publication_token.outputs.token" in publish_token_expression + assert "steps.noema_oidc_token.outputs.token" not in publish_token_expression + assert "github.token" not in publish_token_expression diff --git a/tests/test_noema_reviewer_token_lifetime.py b/tests/test_noema_reviewer_token_lifetime.py index 8057a23435..dee1a647b5 100644 --- a/tests/test_noema_reviewer_token_lifetime.py +++ b/tests/test_noema_reviewer_token_lifetime.py @@ -37,25 +37,49 @@ def test_noema_remints_repository_scoped_app_token_after_model_before_publicatio assert "steps.noema_prepare.outputs.prepared == 'true'" in publish -def test_publication_step_uses_fresh_app_token_without_authority_fallback() -> None: - """Publication selects the refreshed App token and fails closed for unknown sources.""" +def test_publication_step_uses_fresh_app_and_oidc_tokens_without_authority_fallback() -> None: + """Publication selects freshly minted scoped authority and rejects central fallback.""" workflow = WORKFLOW.read_text(encoding="utf-8") - refresh = _step_block(workflow, "Refresh repository-scoped Noema GitHub App token for publication") + app_refresh = _step_block(workflow, "Refresh repository-scoped Noema GitHub App token for publication") + oidc_refresh = _step_block(workflow, "Refresh repository-scoped Noema OIDC app token for publication") + live_publish = _step_block(workflow, "Revalidate live Noema target before publication") publish = _step_block(workflow, "Publish prepared Noema verdict on the exact live head") - assert "owner: ContextualWisdomLab" in refresh - assert "repositories: ${{ steps.noema_credential.outputs.repository }}" in refresh - assert "permission-pull-requests: write" in refresh - assert "permission-contents: read" in refresh - assert "permission-actions: read" in refresh - assert "steps.noema_github_app_publication_token.outputs.token" in publish - assert "steps.noema_github_app_token.outputs.token" not in publish - assert "secrets.NOEMA_REVIEW_TOKEN" in publish - assert "steps.noema_oidc_token.outputs.token" in publish - assert "github.token" not in publish + assert "owner: ContextualWisdomLab" in app_refresh + assert "repositories: ${{ steps.noema_credential.outputs.repository }}" in app_refresh + assert "permission-pull-requests: write" in app_refresh + assert "permission-contents: read" in app_refresh + assert "permission-actions: read" in app_refresh + assert "steps.noema_prepare.outputs.prepared == 'true'" in oidc_refresh + assert "steps.noema_credential.outputs.source == 'oidc'" in oidc_refresh + assert "id: noema_oidc_publication_token" in oidc_refresh + assert "target_repository" in oidc_refresh + + for step in (live_publish, publish): + assert "steps.noema_github_app_publication_token.outputs.token" in step + assert "steps.noema_oidc_publication_token.outputs.token" in step + assert "steps.noema_github_app_token.outputs.token" not in step + assert "steps.noema_oidc_token.outputs.token" not in step + assert "secrets.NOEMA_REVIEW_TOKEN" in step + assert "github.token" not in step + + assert "refusing any GITHUB_TOKEN fallback" in live_publish assert "refusing any GITHUB_TOKEN or author fallback" in publish +def test_publication_authority_refresh_precedes_private_sibling_live_revalidation() -> None: + """Fresh scoped App/OIDC authority must exist before the private live-PR lookup.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + app_marker = " - name: Refresh repository-scoped Noema GitHub App token for publication\n" + oidc_marker = " - name: Refresh repository-scoped Noema OIDC app token for publication\n" + live_marker = " - name: Revalidate live Noema target before publication\n" + publish_marker = " - name: Publish prepared Noema verdict on the exact live head\n" + + assert workflow.index(app_marker) < workflow.index(live_marker) + assert workflow.index(oidc_marker) < workflow.index(live_marker) + assert workflow.index(live_marker) < workflow.index(publish_marker) + + def test_prepare_and_publish_are_the_only_model_verdict_execution_path() -> None: """The old single-process review path must not survive beside the handoff.""" workflow = WORKFLOW.read_text(encoding="utf-8") diff --git a/tests/test_opencode_workflow_shell_syntax.py b/tests/test_opencode_workflow_shell_syntax.py index b0a672b1a2..925ef00e76 100644 --- a/tests/test_opencode_workflow_shell_syntax.py +++ b/tests/test_opencode_workflow_shell_syntax.py @@ -301,3 +301,86 @@ def test_merge_scheduler_targeted_dispatch_validates_live_exact_pr(tmp_path): assert malformed_head.returncode == 1 assert "malformed live PR metadata" in malformed_head.stdout assert not output.exists() + + +def _run_noema_validate_head(tmp_path, *, live_state: str, live_head_sha: str, expected_head_sha: str): + """Execute noema-review.yml's live admission step.""" + bash = shutil.which("bash") + jq = shutil.which("jq") + assert bash is not None and jq is not None + + workflow_text = (REPO_ROOT / ".github/workflows/noema-review.yml").read_text( + encoding="utf-8" + ) + script = _extract_run_block(workflow_text, "Validate live Noema target before any setup") + + fake_bin = tmp_path / f"bin-{live_state}-{live_head_sha}-{expected_head_sha}" + fake_bin.mkdir() + fake_gh = fake_bin / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +test "$1" = api +printf '%s\\n' "$FAKE_PULL_JSON" +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + pull = {"state": live_state, "draft": False, "head": {"sha": live_head_sha}} + env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_PULL_JSON": json.dumps(pull), + "TARGET_REPOSITORY": "ContextualWisdomLab/newsdom-api", + "PR_NUMBER": "1", + "EXPECTED_HEAD_SHA": expected_head_sha, + "GITHUB_REPOSITORY": "ContextualWisdomLab/newsdom-api", + "GITHUB_OUTPUT": str(tmp_path / "noema-live-output"), + } + return subprocess.run( + [bash], input=script, text=True, capture_output=True, check=False, env=env + ) + + +def test_noema_validate_head_distinguishes_closed_from_stale(tmp_path): + """A closed PR on its current head is not the same failure as a stale head. + + Regression test: an async Noema review job that finishes after + the target PR was merged/closed through another path (e.g. the merge + scheduler) must skip cleanly, not report a spurious job failure. A PR + whose head actually moved past what this job was reviewing must still + fail loudly. + """ + if sys.platform == "win32": + return + if shutil.which("bash") is None or shutil.which("jq") is None: + return + + head = "a" * 40 + other_head = "b" * 40 + + open_current = _run_noema_validate_head( + tmp_path, live_state="open", live_head_sha=head, expected_head_sha=head + ) + assert open_current.returncode == 0 + assert "::error::" not in open_current.stdout + assert "::notice::" not in open_current.stdout + + closed_current = _run_noema_validate_head( + tmp_path, live_state="closed", live_head_sha=head, expected_head_sha=head + ) + assert closed_current.returncode == 0, closed_current.stderr + assert "::error::" not in closed_current.stdout + assert "skipping all setup" in closed_current.stdout + + genuinely_stale = _run_noema_validate_head( + tmp_path, live_state="open", live_head_sha=other_head, expected_head_sha=head + ) + assert genuinely_stale.returncode == 1 + assert "trigger is stale" in genuinely_stale.stdout + + closed_and_stale = _run_noema_validate_head( + tmp_path, live_state="closed", live_head_sha=other_head, expected_head_sha=head + ) + assert closed_and_stale.returncode == 1 + assert "trigger is stale" in closed_and_stale.stdout diff --git a/tests/test_strix_repository_dispatch_live_state.py b/tests/test_strix_repository_dispatch_live_state.py new file mode 100644 index 0000000000..794b4316d4 --- /dev/null +++ b/tests/test_strix_repository_dispatch_live_state.py @@ -0,0 +1,241 @@ +"""Executable regressions for live-authoritative Strix repository_dispatch admission.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "strix.yml" + + +def _workflow() -> dict[str, object]: + """Load the production Strix workflow for cross-job contract checks.""" + return yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + + +def _steps() -> list[dict[str, object]]: + """Return the ordered steps of the authoritative Strix scan job.""" + return _workflow()["jobs"]["strix"]["steps"] + + +def _step(name: str) -> dict[str, object]: + """Return a named step from the authoritative Strix scan job.""" + return next(step for step in _steps() if step.get("name") == name) + + +def _step_index(name: str) -> int: + """Return the position of a named step in the authoritative Strix scan job.""" + return next(index for index, step in enumerate(_steps()) if step.get("name") == name) + + +def _fake_gh_environment(tmp_path: Path, pull_request: dict[str, object] | None, *, gh_fails: bool) -> dict[str, str]: + """Build a deterministic fake-GitHub shell environment for workflow-step tests.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir(parents=True) + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "if [ \"${FAKE_GH_FAIL:-0}\" = 1 ]; then exit 1; fi\n" + "printf '%s\\n' \"${FAKE_PR_JSON:?}\"\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + output_path = tmp_path / "github-output" + output_path.write_text("", encoding="utf-8") + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "GITHUB_OUTPUT": str(output_path), + "RUNNER_TEMP": str(tmp_path), + "GITHUB_SERVER_URL": "https://github.com", + "FAKE_PR_JSON": json.dumps(pull_request or {}), + "FAKE_GH_FAIL": "1" if gh_fails else "0", + } + ) + return env + + +def _run_preflight(tmp_path: Path, pull_request: dict[str, object] | None, *, gh_fails: bool = False) -> tuple[int, str]: + """Execute the production dispatch admission shell through its live-state decision.""" + validation = _step("Validate repository dispatch against live pull request metadata") + script = str(validation["run"]) + # Exercise the exact validation program, but stop before the open/current + # case performs the subsequent trusted git materialization. + script = script.split('trusted_workspace="$RUNNER_TEMP/trusted-workspace"', 1)[0] + + env = _fake_gh_environment(tmp_path, pull_request, gh_fails=gh_fails) + env.update( + { + "REPOSITORY": "ContextualWisdomLab/example", + "PR_NUMBER": "42", + "SUPPLIED_BASE_REF": "main", + "SUPPLIED_BASE_SHA": "a" * 40, + "SUPPLIED_HEAD_SHA": "b" * 40, + } + ) + result = subprocess.run(["bash", "-c", script], env=env, text=True, capture_output=True) + return result.returncode, Path(env["GITHUB_OUTPUT"]).read_text(encoding="utf-8") + + +def _run_status_revalidation( + tmp_path: Path, + pull_request: dict[str, object] | None, + *, + gh_fails: bool = False, +) -> tuple[int, str]: + """Execute the late status-publication live-state gate against fake GitHub state.""" + validation = _step("Revalidate repository dispatch before status publication") + script = str(validation["run"]) + env = _fake_gh_environment(tmp_path, pull_request, gh_fails=gh_fails) + env.update( + { + "REPOSITORY": "ContextualWisdomLab/example", + "PR_NUMBER": "42", + "EXPECTED_HEAD_SHA": "b" * 40, + } + ) + result = subprocess.run(["bash", "-c", script], env=env, text=True, capture_output=True) + return result.returncode, Path(env["GITHUB_OUTPUT"]).read_text(encoding="utf-8") + + +def _live_pr(*, state: str = "open", draft: bool = False, head: str | None = None, base: str | None = None) -> dict[str, object]: + """Build canonical live pull-request JSON for exact-head admission tests.""" + return { + "state": state, + "draft": draft, + "base": { + "repo": {"full_name": "ContextualWisdomLab/example"}, + "ref": "main", + "sha": base or "a" * 40, + }, + "head": { + "repo": {"full_name": "ContextualWisdomLab/example"}, + "sha": head or "b" * 40, + }, + } + + +def test_repository_dispatch_revalidates_live_state_and_head_before_scan(tmp_path: Path) -> None: + """Initial dispatch admission distinguishes resolved/draft targets from stale heads.""" + validation = _step("Validate repository dispatch against live pull request metadata") + assert validation.get("id") == "dispatch_validation" + script = str(validation["run"]) + assert "live_draft=" in script + assert "should_scan=false" in script + assert "should_scan=true" in script + + rc, output = _run_preflight(tmp_path / "ready", _live_pr()) + assert rc == 0 + assert "should_scan=true" in output + + # A completed PR on the exact dispatched head is resolved work, not stale + # evidence. Its base may already have advanced after merge; no scan/status + # publication may occur after live closure. + rc, output = _run_preflight( + tmp_path / "closed", + _live_pr(state="closed", base="c" * 40), + ) + assert rc == 0 + assert "should_scan=false" in output + + # Symmetric stale-event ordering: a ready dispatch that starts after the PR + # became draft must use the live draft state and perform no admission work. + rc, output = _run_preflight(tmp_path / "draft", _live_pr(draft=True)) + assert rc == 0 + assert "should_scan=false" in output + + rc, _ = _run_preflight(tmp_path / "stale", _live_pr(head="d" * 40)) + assert rc != 0 + + rc, _ = _run_preflight(tmp_path / "lookup", None, gh_fails=True) + assert rc != 0 + + +def test_repository_dispatch_skip_signal_guards_every_downstream_admission_effect() -> None: + """Dispatch false skips work while native PR events remain admitted without that output.""" + # These steps are shared with pull_request_target, where dispatch_validation + # intentionally has no output. `!= false` therefore means: repository_dispatch + # must have explicitly survived live validation, while the native PR path is + # not accidentally disabled by an absent dispatch-only output. + guarded_names = { + "Resolve target repository visibility", + "Fetch pull request head for trusted scan", + "Self-test Strix required workflow contract", + "Gate Strix secrets", + } + for name in guarded_names: + condition = str(_step(name).get("if", "")) + assert "steps.dispatch_validation.outputs.should_scan != 'false'" in condition, name + + # Status publication intentionally uses a second, later live-state authority + # because a PR can close, become draft, or move heads while a long scan runs. + publish_condition = str(_step("Publish same-head manual Strix status").get("if", "")) + assert "steps.dispatch_publish_validation.outputs.publish_status == 'true'" in publish_condition + + +def test_repository_dispatch_revalidates_live_state_again_before_status_publication(tmp_path: Path) -> None: + """Hours-long scans cannot publish after the exact target closes or becomes draft.""" + refresh_name = "Refresh OpenCode app token for Strix status revalidation" + validation_name = "Revalidate repository dispatch before status publication" + assert _step_index(refresh_name) < _step_index(validation_name) + + refresh = _step(refresh_name) + validation = _step(validation_name) + assert refresh.get("id") == "status_target_app_token" + refresh_condition = str(refresh.get("if", "")) + # This refresh is repository_dispatch-only, so exact `true` is fail-closed: + # absent/malformed output is never permission to mint a target credential. + assert "steps.dispatch_validation.outputs.should_scan == 'true'" in refresh_condition + assert "github.event_name == 'repository_dispatch'" in refresh_condition + + assert validation.get("id") == "dispatch_publish_validation" + token_expression = str(validation.get("env", {}).get("GH_TOKEN", "")) + assert "steps.status_target_app_token.outputs.token" in token_expression + # Do not use the early target token for late publication revalidation. Match + # the complete step reference so the status_target_app_token name cannot + # create a false positive through substring overlap. + assert "steps.target_app_token.outputs.token" not in token_expression + + rc, output = _run_status_revalidation(tmp_path / "ready", _live_pr()) + assert rc == 0 + assert "publish_status=true" in output + + rc, output = _run_status_revalidation(tmp_path / "closed", _live_pr(state="closed")) + assert rc == 0 + assert "publish_status=false" in output + + rc, output = _run_status_revalidation(tmp_path / "draft", _live_pr(draft=True)) + assert rc == 0 + assert "publish_status=false" in output + + rc, _ = _run_status_revalidation(tmp_path / "stale", _live_pr(head="d" * 40)) + assert rc != 0 + + rc, _ = _run_status_revalidation(tmp_path / "lookup", None, gh_fails=True) + assert rc != 0 + + +def test_repository_dispatch_skip_signal_crosses_the_status_job_boundary() -> None: + """Both manual-status publishers require fresh late-bound live authority.""" + workflow = _workflow() + scan_job = workflow["jobs"]["strix"] + status_job = workflow["jobs"]["publish-manual-pr-evidence-status"] + + outputs = scan_job.get("outputs", {}) + assert outputs.get("should_scan") == "${{ steps.dispatch_validation.outputs.should_scan }}" + assert outputs.get("publish_status") == "${{ steps.dispatch_publish_validation.outputs.publish_status }}" + + in_job_condition = str(_step("Publish same-head manual Strix status").get("if", "")) + assert "steps.dispatch_publish_validation.outputs.publish_status == 'true'" in in_job_condition + + status_condition = str(status_job.get("if", "")) + assert "needs.strix.outputs.should_scan == 'true'" in status_condition + assert "needs.strix.outputs.publish_status == 'true'" in status_condition + assert "github.event_name == 'repository_dispatch'" in status_condition