diff --git a/.github/actions/setup-python-uv/action.yml b/.github/actions/setup-python-uv/action.yml index a5c3b6ccb5..ace93bbb1f 100644 --- a/.github/actions/setup-python-uv/action.yml +++ b/.github/actions/setup-python-uv/action.yml @@ -41,16 +41,21 @@ inputs: sync-attempts: description: 'Maximum uv sync attempts before failing' required: false - default: '3' + default: '5' sync-retry-delay-seconds: description: 'Base delay in seconds between uv sync retries' required: false default: '10' + github-token: + description: >- + Token used to authenticate uv git+https dependency fetches against github.com. Defaults to the workflow GITHUB_TOKEN, which authenticates same-org public-repo fetches and lifts them above the anonymous rate limit. Override with `secrets.CROSS_REPO_PAT` for private cross-repo deps. + required: false + default: ${{ github.token }} runs: using: 'composite' steps: - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ inputs.python-version }} - name: Install uv @@ -75,9 +80,27 @@ runs: INSTALL_ARGS: ${{ inputs.install-args }} UV_SYNC_ATTEMPTS: ${{ inputs.sync-attempts }} UV_SYNC_RETRY_DELAY_SECONDS: ${{ inputs.sync-retry-delay-seconds }} + GIT_FETCH_TOKEN: ${{ inputs.github-token }} run: | export UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-600}" + export UV_CONCURRENT_DOWNLOADS="${UV_CONCURRENT_DOWNLOADS:-1}" + export UV_CONCURRENT_BUILDS="${UV_CONCURRENT_BUILDS:-1}" git config --global http.version HTTP/1.1 + # Authenticate uv's internal git+https fetches. Anonymous github.com + # requests are rate-limited (60/hr); many parallel --no-cache syncs from + # one runner egress IP trip "Empty reply from server". Authenticated + # requests get 5000/hr. uv shells out to `git fetch`, which inherits the + # GIT_CONFIG_* env vars below — a process-scoped insteadOf rewrite that + # never writes the token to a persistent gitconfig on the self-hosted + # runner. (OMN-12432) + if [ -n "${GIT_FETCH_TOKEN}" ]; then + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="url.https://x-access-token:${GIT_FETCH_TOKEN}@github.com/.insteadOf" + export GIT_CONFIG_VALUE_0="https://github.com/" + echo "git github.com fetches: authenticated (x-access-token)" + else + echo "::warning::github-token not provided; uv git fetches stay anonymous and may be rate-limited" + fi read -r -a install_args <<< "$INSTALL_ARGS" sync_attempts="${UV_SYNC_ATTEMPTS}" retry_delay_seconds="${UV_SYNC_RETRY_DELAY_SECONDS}" diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..af144c2d56 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +name: omnibase-infra-codeql +paths: + - src + - scripts + - tests +paths-ignore: + - .github/** diff --git a/.github/workflows/check-handshake.yml b/.github/workflows/check-handshake.yml index 8477accc38..c277d78a3a 100644 --- a/.github/workflows/check-handshake.yml +++ b/.github/workflows/check-handshake.yml @@ -19,7 +19,7 @@ jobs: && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') || fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') }} - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7b4cf585d..e8a0172655 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') || fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') }} - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout code @@ -314,7 +314,7 @@ jobs: && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') || fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') }} - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Checkout code @@ -461,7 +461,7 @@ jobs: && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') || fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') }} - timeout-minutes: 25 + timeout-minutes: 45 steps: - name: Checkout omnibase_infra @@ -502,6 +502,25 @@ jobs: lock-file-path: 'omnibase_infra/**/uv.lock' working-directory: omnibase_infra + - name: Preinstall CPU-only torch for sibling deps + working-directory: omnibase_infra + run: | + set -eu + export UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-600}" + attempt=1 + max_attempts=5 + until uv pip install "torch>=2.6.0,<3.0.0" --index-url https://download.pytorch.org/whl/cpu; do + status=$? + if [ "${attempt}" -ge "${max_attempts}" ]; then + echo "::error::uv pip install schema-handshake torch CPU wheel failed after ${attempt} attempt(s)" + exit "${status}" + fi + delay=$((attempt * 10)) + echo "::warning::uv pip install schema-handshake torch CPU wheel attempt ${attempt}/${max_attempts} failed with exit ${status}; retrying in ${delay}s" + attempt=$((attempt + 1)) + sleep "${delay}" + done + - name: Install sibling repos as editable (boundary pair deps) working-directory: omnibase_infra # Use --overrides to install siblings with their full transitive deps while @@ -512,10 +531,24 @@ jobs: # Prior approach (--no-deps) skipped transitive deps entirely, causing # ModuleNotFoundError for packages like adaptive_classifier at collection time. run: | + set -eu INFRA_VERSION=$(python -c "import tomllib; d=tomllib.load(open('pyproject.toml','rb')); print(d['project']['version'])") echo "omnibase-infra==${INFRA_VERSION}" > /tmp/sibling-overrides.txt echo "omnibase-compat @ file://$(pwd)/../omnibase_compat" >> /tmp/sibling-overrides.txt - uv pip install --overrides /tmp/sibling-overrides.txt -e ../omniintelligence + export UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-600}" + attempt=1 + max_attempts=5 + until uv pip install --overrides /tmp/sibling-overrides.txt -e ../omniintelligence; do + status=$? + if [ "${attempt}" -ge "${max_attempts}" ]; then + echo "::error::uv pip install schema-handshake sibling deps failed after ${attempt} attempt(s)" + exit "${status}" + fi + delay=$((attempt * 10)) + echo "::warning::uv pip install schema-handshake sibling deps attempt ${attempt}/${max_attempts} failed with exit ${status}; retrying in ${delay}s" + attempt=$((attempt + 1)) + sleep "${delay}" + done - name: Run schema handshake validation (changed-only) working-directory: omnibase_infra @@ -1216,7 +1249,7 @@ jobs: && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') || fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') }} - timeout-minutes: 25 + timeout-minutes: 45 steps: - name: Checkout omnibase_infra @@ -1265,7 +1298,18 @@ jobs: INFRA_VERSION=$(python -c "import tomllib; d=tomllib.load(open('pyproject.toml','rb')); print(d['project']['version'])") echo "omnibase-infra==${INFRA_VERSION}" > /tmp/sibling-overrides.txt echo "omnibase-compat @ file://$(pwd)/../omnibase_compat" >> /tmp/sibling-overrides.txt - uv pip install --overrides /tmp/sibling-overrides.txt -e ../omniintelligence + attempt=1 + max_attempts=5 + until uv pip install --overrides /tmp/sibling-overrides.txt -e ../omniintelligence; do + status=$? + if [ "${attempt}" -ge "${max_attempts}" ]; then + echo "::error::uv pip install sibling deps failed after ${attempt} attempt(s)" + exit "${status}" + fi + echo "::warning::uv pip install sibling deps attempt ${attempt}/${max_attempts} failed with exit ${status}; retrying in 10s" + attempt=$((attempt + 1)) + sleep 10 + done - name: Run Kafka boundary compatibility tests working-directory: omnibase_infra diff --git a/.github/workflows/omni-standards-compliance.yml b/.github/workflows/omni-standards-compliance.yml index d6622da618..6f728322db 100644 --- a/.github/workflows/omni-standards-compliance.yml +++ b/.github/workflows/omni-standards-compliance.yml @@ -31,19 +31,15 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 + - name: Setup Python and uv + uses: ./.github/actions/setup-python-uv with: python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: ${{ env.UV_VERSION }} - enable-cache: false - - - name: Install dependencies - run: uv sync --no-cache --all-extras + uv-version: ${{ env.UV_VERSION }} + cache-enabled: "false" + cache-key-prefix: uv-type-safety + install-args: --all-extras + github-token: ${{ secrets.CROSS_REPO_PAT || github.token }} - name: Run MyPy type checking run: | @@ -67,19 +63,15 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 + - name: Setup Python and uv + uses: ./.github/actions/setup-python-uv with: python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: ${{ env.UV_VERSION }} - enable-cache: false - - - name: Install dependencies - run: uv sync --no-cache --all-extras + uv-version: ${{ env.UV_VERSION }} + cache-enabled: "false" + cache-key-prefix: uv-type-union + install-args: --all-extras + github-token: ${{ secrets.CROSS_REPO_PAT || github.token }} - name: Check for Optional/Union usage (PEP 604) run: uv run ruff check src/ --select UP007,UP006 @@ -108,7 +100,17 @@ jobs: version: ${{ env.UV_VERSION }} - name: Install onex_change_control (pinned) + env: + GIT_FETCH_TOKEN: ${{ secrets.CROSS_REPO_PAT || github.token }} run: | + # Authenticate the git+https fetch so it isn't anonymous-rate-limited + # on the self-hosted runner ("Empty reply from server"). Process-scoped + # insteadOf rewrite; never writes the token to disk. (OMN-12432) + if [ -n "${GIT_FETCH_TOKEN}" ]; then + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="url.https://x-access-token:${GIT_FETCH_TOKEN}@github.com/.insteadOf" + export GIT_CONFIG_VALUE_0="https://github.com/" + fi uv venv .venv --python "${pythonLocation}/bin/python" . .venv/bin/activate uv pip install "onex_change_control @ git+https://github.com/OmniNode-ai/onex_change_control.git@08a5194c6e587e8b2256c1f1edde236ad34cd397" diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 658762fb56..0f1fe64d43 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT # # CodeQL security and quality analysis for omnibase_infra. -# Calls the reusable workflow in onex_change_control. +# Kept repo-local so CodeQL can use the repo config that excludes CI metadata. # OMN-5425 name: Security Scan @@ -19,11 +19,34 @@ on: jobs: codeql: name: CodeQL - uses: OmniNode-ai/onex_change_control/.github/workflows/codeql-reusable.yml@main - with: - language: python - query_suite: security-and-quality + runs-on: >- + ${{ + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository) + && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') + || fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') + }} permissions: actions: read contents: read security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: python + queries: security-and-quality + config-file: ./.github/codeql/codeql-config.yml + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:python + upload: never + wait-for-processing: false diff --git a/tests/ci/test_ci_workflow_resilience.py b/tests/ci/test_ci_workflow_resilience.py index 66ea08f504..28d5369193 100644 --- a/tests/ci/test_ci_workflow_resilience.py +++ b/tests/ci/test_ci_workflow_resilience.py @@ -16,6 +16,12 @@ CI_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci.yml" DOCKER_BUILD_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "docker-build.yml" ENV_PARITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "env-parity.yml" +OMNI_STANDARDS_WORKFLOW = ( + REPO_ROOT / ".github" / "workflows" / "omni-standards-compliance.yml" +) +SECURITY_SCAN_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security-scan.yml" +CHECK_HANDSHAKE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "check-handshake.yml" +CODEQL_CONFIG = REPO_ROOT / ".github" / "codeql" / "codeql-config.yml" SETUP_PYTHON_UV_ACTION = ( REPO_ROOT / ".github" / "actions" / "setup-python-uv" / "action.yml" ) @@ -221,10 +227,77 @@ def test_cross_repo_ci_jobs_use_retrying_uv_install() -> None: assert not any(step.get("run") == "uv sync --no-cache" for step in steps) +def test_heavy_cross_repo_boundary_installs_retry_and_have_timeout_budget() -> None: + ci_workflow = _load_yaml(CI_WORKFLOW) + for job_name in ("schema-handshake", "kafka-boundary-compat"): + job = ci_workflow["jobs"][job_name] + assert job["timeout-minutes"] >= 45 + + install_step = next( + step + for step in job["steps"] + if str(step.get("name", "")).startswith("Install sibling repos as editable") + ) + run_script = install_step["run"] + assert "max_attempts=5" in run_script + assert ( + "until uv pip install --overrides /tmp/sibling-overrides.txt" in run_script + ) + assert "sibling deps attempt" in run_script + assert "sibling deps failed after" in run_script + + +def test_schema_handshake_uses_cpu_torch_for_sibling_install() -> None: + ci_workflow = _load_yaml(CI_WORKFLOW) + job = ci_workflow["jobs"]["schema-handshake"] + steps = job["steps"] + + assert job["timeout-minutes"] >= 45 + + torch_step = next( + step + for step in steps + if step.get("name") == "Preinstall CPU-only torch for sibling deps" + ) + torch_script = torch_step["run"] + assert "https://download.pytorch.org/whl/cpu" in torch_script + assert "schema-handshake torch CPU wheel attempt" in torch_script + assert "schema-handshake torch CPU wheel failed after" in torch_script + + +def test_topic_enum_drift_has_install_retry_budget() -> None: + """OMN-12432: topic enum drift must survive one uv git fetch retry.""" + ci_workflow = _load_yaml(CI_WORKFLOW) + job = ci_workflow["jobs"]["topic-enum-drift"] + + assert job["timeout-minutes"] >= 15 + setup_step = next( + step + for step in job["steps"] + if step.get("uses") == "./.github/actions/setup-python-uv" + ) + assert setup_step["with"]["cache-enabled"] == "false" + assert setup_step["with"].get("skip-install") != "true" + + +def test_onex_validators_have_retry_timeout_budget() -> None: + ci_workflow = _load_yaml(CI_WORKFLOW) + job = ci_workflow["jobs"]["onex-validation"] + + assert job["timeout-minutes"] >= 20 + + +def test_architecture_handshake_has_checkout_retry_timeout_budget() -> None: + workflow = _load_yaml(CHECK_HANDSHAKE_WORKFLOW) + job = workflow["jobs"]["check-handshake"] + + assert job["timeout-minutes"] >= 10 + + def test_setup_python_uv_retries_uv_sync_and_logs_transport_settings() -> None: action = _load_yaml(SETUP_PYTHON_UV_ACTION) - assert action["inputs"]["sync-attempts"]["default"] == "3" + assert action["inputs"]["sync-attempts"]["default"] == "5" assert action["inputs"]["sync-retry-delay-seconds"]["default"] == "10" install_step = next( @@ -232,6 +305,11 @@ def test_setup_python_uv_retries_uv_sync_and_logs_transport_settings() -> None: for step in action["runs"]["steps"] if step.get("name") == "Install dependencies" ) + setup_step = next( + step for step in action["runs"]["steps"] if step.get("name") == "Set up Python" + ) + assert setup_step["uses"] == "actions/setup-python@v6" + assert install_step["env"]["UV_SYNC_ATTEMPTS"] == "${{ inputs.sync-attempts }}" assert ( install_step["env"]["UV_SYNC_RETRY_DELAY_SECONDS"] @@ -240,6 +318,10 @@ def test_setup_python_uv_retries_uv_sync_and_logs_transport_settings() -> None: run_script = install_step["run"] assert 'export UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-600}"' in run_script + assert ( + 'export UV_CONCURRENT_DOWNLOADS="${UV_CONCURRENT_DOWNLOADS:-1}"' in run_script + ) + assert 'export UV_CONCURRENT_BUILDS="${UV_CONCURRENT_BUILDS:-1}"' in run_script assert "git config --global http.version HTTP/1.1" in run_script assert "sync_cmd=(uv sync)" in run_script assert "sync_cmd+=(--no-cache)" in run_script @@ -254,3 +336,107 @@ def test_setup_python_uv_retries_uv_sync_and_logs_transport_settings() -> None: 'echo "UV_CONCURRENT_DOWNLOADS=${UV_CONCURRENT_DOWNLOADS:-}"' in run_script ) + + +def test_setup_python_uv_authenticates_git_fetches() -> None: + """OMN-12432: uv's git+https dependency fetches must be authenticated. + + Anonymous github.com fetches from the self-hosted runners hit the 60/hr + anonymous rate limit and fail with "Empty reply from server" when many + parallel --no-cache uv syncs run from one egress IP. The action configures + a process-scoped insteadOf rewrite (via GIT_CONFIG_* env vars, never a + persisted gitconfig) using a github token so uv's internal `git fetch` + authenticates and gets the 5000/hr limit. + """ + action = _load_yaml(SETUP_PYTHON_UV_ACTION) + + token_input = action["inputs"]["github-token"] + assert token_input["default"] == "${{ github.token }}" + + install_step = next( + step + for step in action["runs"]["steps"] + if step.get("name") == "Install dependencies" + ) + assert install_step["env"]["GIT_FETCH_TOKEN"] == "${{ inputs.github-token }}" + + run_script = install_step["run"] + assert 'if [ -n "${GIT_FETCH_TOKEN}" ]; then' in run_script + assert "export GIT_CONFIG_COUNT=1" in run_script + assert ( + 'export GIT_CONFIG_KEY_0="url.https://x-access-token:${GIT_FETCH_TOKEN}@github.com/.insteadOf"' + in run_script + ) + assert 'export GIT_CONFIG_VALUE_0="https://github.com/"' in run_script + # Token must never be written to a persistent global gitconfig on the runner. + assert "git config --global url." not in run_script + + +def test_omni_standards_uv_jobs_use_authenticated_composite_action() -> None: + """OMN-12432: the uv-sync jobs that block #1781/#1782 must authenticate. + + type-safety and type-union-check previously inlined an unauthenticated + `uv sync --no-cache --all-extras`. They now route through setup-python-uv + with an explicit token so the git fetches are authenticated and retried. + """ + workflow = _load_yaml(OMNI_STANDARDS_WORKFLOW) + + for job_name in ("type-safety", "type-union-check"): + steps = workflow["jobs"][job_name]["steps"] + setup_step = next( + step + for step in steps + if step.get("uses") == "./.github/actions/setup-python-uv" + ) + assert setup_step["with"]["install-args"] == "--all-extras" + assert setup_step["with"]["cache-enabled"] == "false" + assert ( + setup_step["with"]["github-token"] + == "${{ secrets.CROSS_REPO_PAT || github.token }}" + ) + # No raw unauthenticated uv sync left behind. + assert not any( + step.get("run") == "uv sync --no-cache --all-extras" for step in steps + ) + + # The pinned onex_change_control git+https install must also authenticate. + occ_steps = workflow["jobs"]["handler-contract-compliance"]["steps"] + install_step = next( + step + for step in occ_steps + if step.get("name") == "Install onex_change_control (pinned)" + ) + assert ( + install_step["env"]["GIT_FETCH_TOKEN"] + == "${{ secrets.CROSS_REPO_PAT || github.token }}" + ) + assert "export GIT_CONFIG_COUNT=1" in install_step["run"] + + +def test_codeql_uses_repo_config_that_ignores_github_metadata() -> None: + """OMN-12432: CodeQL must not upload malformed .github directory results.""" + workflow = _load_yaml(SECURITY_SCAN_WORKFLOW) + config = _load_yaml(CODEQL_CONFIG) + + init_step = next( + step + for step in workflow["jobs"]["codeql"]["steps"] + if step.get("name") == "Initialize CodeQL" + ) + assert init_step["uses"] == "github/codeql-action/init@v4" + assert init_step["with"]["languages"] == "python" + assert init_step["with"]["queries"] == "security-and-quality" + assert init_step["with"]["config-file"] == "./.github/codeql/codeql-config.yml" + + analyze_step = next( + step + for step in workflow["jobs"]["codeql"]["steps"] + if step.get("name") == "Perform CodeQL Analysis" + ) + assert analyze_step["uses"] == "github/codeql-action/analyze@v4" + assert analyze_step["with"]["category"] == "/language:python" + assert analyze_step["with"]["upload"] == "never" + assert analyze_step["with"]["wait-for-processing"] is False + + assert config["paths"] == ["src", "scripts", "tests"] + assert ".github/**" in config["paths-ignore"]