Strix Security Scan ContextualWisdomLab/inkspan#201@d46640d1881a2469a80edcab75cab20eba25dc5b #3554
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Strix Security Scan | |
| run-name: >- | |
| Strix Security Scan ${{ github.event.client_payload.target_repository || | |
| github.event.pull_request.base.repo.full_name || github.repository }}#${{ | |
| github.event.client_payload.pr_number || github.event.pull_request.number || 'event' }}@${{ | |
| github.event.client_payload.pr_head_sha || github.event.pull_request.head.sha || github.sha }} | |
| on: | |
| push: | |
| branches: [main, develop, master] | |
| # Skip scans for changes that touch ONLY non-executable documentation and | |
| # image assets. A change whose entire diff is these paths has no source, | |
| # build, config, or workflow logic for a code security scanner to analyze, | |
| # so skipping it loses no coverage while freeing shared runner capacity. | |
| # Conservative by design: only file EXTENSIONS/paths that can never contain | |
| # executable logic are listed (no source, no *.txt, no *.svg, no CODEOWNERS, | |
| # no build scripts). A diff touching even one non-listed file still scans. | |
| # The weekly full-tree schedule below re-scans protected branches with no | |
| # path filter, backstopping every path. | |
| paths-ignore: | |
| - '**/*.md' | |
| - '**/*.markdown' | |
| - '**/*.rst' | |
| - '**/*.png' | |
| - '**/*.jpg' | |
| - '**/*.jpeg' | |
| - '**/*.gif' | |
| - '**/*.webp' | |
| - '**/*.bmp' | |
| - '**/*.ico' | |
| - 'LICENSE' | |
| - 'LICENSE.*' | |
| - 'COPYING' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| pull_request_target: | |
| types: [opened, synchronize, reopened, ready_for_review, closed] | |
| # Same conservative doc/image-only skip for PR scans. GitHub evaluates these | |
| # path filters against the PR's full base..head diff, so a PR is skipped only | |
| # when EVERY changed file is a non-executable doc/image asset; any code, | |
| # config, build, or workflow change still triggers the scan. Concurrency is | |
| # PR-number based for status grouping, but Strix runs intentionally do not | |
| # cancel in progress because a pre-job cancellation leaves no scanner log to | |
| # review. Queue pressure should be handled by stale-run cleanup outside this | |
| # current-head evidence path. For PRs the merge scheduler manages, same-head | |
| # Strix evidence is still forced at merge time via repository_dispatch (which | |
| # paths-ignore does not affect), so merged code never loses evidence. | |
| paths-ignore: | |
| - '**/*.md' | |
| - '**/*.markdown' | |
| - '**/*.rst' | |
| - '**/*.png' | |
| - '**/*.jpg' | |
| - '**/*.jpeg' | |
| - '**/*.gif' | |
| - '**/*.webp' | |
| - '**/*.bmp' | |
| - '**/*.ico' | |
| - 'LICENSE' | |
| - 'LICENSE.*' | |
| - 'COPYING' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| schedule: | |
| # Weekly scan on protected branches (Mondays at 03:00 UTC). | |
| - cron: '0 3 * * 1' | |
| # Default-branch-only retry entrypoint; no caller-selected workflow ref. | |
| repository_dispatch: | |
| types: [strix-scan] | |
| concurrency: | |
| # Include the event name so default-branch repository_dispatch evidence cannot cancel | |
| # the required pull_request_target Strix context that branch protection reads. | |
| # PR-number scope keeps the queue on the current HEAD within each event class. | |
| group: >- | |
| strix-${{ github.event_name }}-${{ github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository }}-${{ | |
| github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || | |
| github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) || github.ref }} | |
| cancel-in-progress: true | |
| # Scorecard Token-Permissions (alert #43): keep the workflow-level token | |
| # read-only and scope same-repo status publication to the Strix scan job. | |
| permissions: | |
| actions: read | |
| contents: read | |
| models: read | |
| jobs: | |
| cancel-closed-pr-runs: | |
| if: github.event_name == 'pull_request_target' && github.event.action == 'closed' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - run: echo "PR closed; this run only cancels older runs through workflow concurrency." | |
| strix: | |
| if: github.event_name != 'pull_request_target' || github.event.action != 'closed' | |
| # Large repositories can require a legitimate full-hour review. The scanner | |
| # gets a 90-minute process budget and a 95-minute total retry budget; the | |
| # 100-minute step and 120-minute job leave deterministic time to preserve | |
| # partial reports and publish a concrete failure reason. Hitting any cap is | |
| # fail-closed and never turns an incomplete scan into an approval. | |
| timeout-minutes: 120 | |
| runs-on: ubuntu-latest | |
| # Least-privilege token scoped to this job (Scorecard alert #43): the scan | |
| # exchanges an OIDC token (id-token) and publishes same-repo status evidence | |
| # from the scan job only. | |
| permissions: | |
| actions: read | |
| contents: read | |
| id-token: write | |
| models: read | |
| statuses: write | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| steps: | |
| - name: Harden runner | |
| uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 | |
| with: | |
| egress-policy: audit | |
| disable-file-monitoring: true | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.13" | |
| - name: Resolve trusted Strix source ref | |
| id: trusted_source | |
| env: | |
| JOB_CONTEXT_JSON: ${{ toJSON(job) }} | |
| GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} | |
| run: | | |
| set -euo pipefail | |
| python3 <<'PY' >>"$GITHUB_OUTPUT" | |
| import json | |
| import os | |
| import re | |
| import sys | |
| try: | |
| job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") | |
| github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}") | |
| except json.JSONDecodeError as exc: | |
| print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) | |
| raise SystemExit(1) | |
| trusted_repository = str( | |
| job_context.get("workflow_repository") or "ContextualWisdomLab/.github" | |
| ).strip() | |
| trusted_ref = str( | |
| job_context.get("workflow_sha") or github_context.get("workflow_sha") or "" | |
| ).strip() | |
| workflow_ref = str( | |
| job_context.get("workflow_ref") or github_context.get("workflow_ref") or "" | |
| ).strip() | |
| if not trusted_ref: | |
| trusted_ref = "main" | |
| prefix = "ContextualWisdomLab/.github/.github/workflows/strix.yml@" | |
| if workflow_ref.startswith(prefix): | |
| trusted_ref = workflow_ref.split("@", 1)[1] | |
| if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", trusted_repository): | |
| print("::error::Trusted workflow repository resolved to an invalid name.", file=sys.stderr) | |
| raise SystemExit(1) | |
| if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref): | |
| print("::error::Trusted workflow ref resolved to an invalid value.", file=sys.stderr) | |
| raise SystemExit(1) | |
| print(f"repository={trusted_repository}") | |
| print(f"ref={trusted_ref}") | |
| PY | |
| - name: Checkout trusted Strix source | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| repository: ${{ steps.trusted_source.outputs.repository }} | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| ref: ${{ steps.trusted_source.outputs.ref }} | |
| path: trusted-strix-source | |
| - name: Export trusted Strix source paths | |
| run: | | |
| set -euo pipefail | |
| trusted_strix_source="$GITHUB_WORKSPACE/trusted-strix-source" | |
| test -f "$trusted_strix_source/scripts/ci/strix_quick_gate.sh" | |
| test -f "$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" | |
| test -f "$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" | |
| { | |
| echo "TRUSTED_STRIX_SOURCE=$trusted_strix_source" | |
| echo "TRUSTED_STRIX_GATE=$trusted_strix_source/scripts/ci/strix_quick_gate.sh" | |
| echo "TRUSTED_STRIX_GATE_TEST=$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" | |
| echo "TRUSTED_STRIX_REQUIRED_SMOKE=$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" | |
| } >> "$GITHUB_ENV" | |
| - name: Exchange OpenCode app token for target repository reads | |
| id: target_app_token | |
| 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 "OpenCode app token exchange 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 "OpenCode app token exchange unavailable: OIDC token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" | |
| if [ -z "$oidc_token" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC token 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 "OpenCode app token exchange unavailable: app token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| app_token="$(jq -r '.token // empty' <<<"$token_response")" | |
| if [ -z "$app_token" ]; then | |
| echo "OpenCode app token exchange unavailable: app token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| echo "::add-mask::$app_token" | |
| { | |
| echo "available=true" | |
| echo "token=$app_token" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Resolve target repository visibility | |
| id: target_visibility | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository }} | |
| EVENT_REPOSITORY_VISIBILITY: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.repo.visibility || github.event_name != 'repository_dispatch' && github.event.repository.visibility || '' }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]]; then | |
| echo "::error::Strix target repository must belong to ContextualWisdomLab." | |
| exit 1 | |
| fi | |
| case "$EVENT_REPOSITORY_VISIBILITY" in | |
| PUBLIC | public) is_private=false ;; | |
| PRIVATE | private | INTERNAL | internal) is_private=true ;; | |
| "") | |
| is_private="" | |
| for target_visibility_attempt in 1 2 3 4 5 6; do | |
| if is_private="$( | |
| gh api "repos/${TARGET_REPOSITORY}" --jq ' | |
| (.visibility // "" | ascii_downcase) as $visibility | |
| | if $visibility == "public" then "false" | |
| elif $visibility == "private" or $visibility == "internal" then "true" | |
| else empty | |
| end | |
| ' | |
| )"; then | |
| break | |
| fi | |
| is_private="" | |
| if [ "$target_visibility_attempt" -lt 6 ]; then | |
| echo "Repository visibility lookup failed (attempt ${target_visibility_attempt}/6), possibly a transient GitHub API rate limit; retrying after backoff." >&2 | |
| sleep "$(( target_visibility_attempt * 5 ))" | |
| fi | |
| done | |
| ;; | |
| *) | |
| echo "::error::Target repository event visibility was not public, private, or internal." | |
| exit 1 | |
| ;; | |
| esac | |
| case "$is_private" in | |
| true | false) ;; | |
| *) | |
| echo "::error::Target repository visibility did not resolve to true or false after retries." | |
| exit 1 | |
| ;; | |
| esac | |
| echo "is_private=$is_private" >>"$GITHUB_OUTPUT" | |
| - name: Materialize target workspace | |
| if: github.event_name != 'repository_dispatch' | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} | |
| TARGET_WORKSPACE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} | |
| run: | | |
| set -euo pipefail | |
| 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 "$TARGET_WORKSPACE_SHA" | |
| git -C "$trusted_workspace" checkout --detach --quiet "$TARGET_WORKSPACE_SHA" | |
| 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 != '' | |
| 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 }} | |
| PR_BASE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha }} | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$PR_NUMBER" ] || [ -z "$PR_HEAD_SHA" ]; then | |
| echo "::error::PR number and head SHA are required for trusted PR-scope Strix evidence." | |
| exit 1 | |
| fi | |
| gh auth setup-git | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if [ -n "$PR_BASE_SHA" ] && ! [[ "$PR_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR base SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if [ -n "$PR_BASE_SHA" ]; then | |
| git -C "$TRUSTED_WORKSPACE" fetch --no-tags --depth=1 origin "$PR_BASE_SHA" | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_BASE_SHA^{commit}" | |
| fi | |
| # Fetching the expected head SHA directly avoids false failures when | |
| # refs/pull/<n>/head has already advanced before this queued run starts. | |
| if git -C "$TRUSTED_WORKSPACE" fetch --no-tags --depth=1 origin "$PR_HEAD_SHA"; then | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA^{commit}" | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:.github/workflows/strix.yml" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/.github/workflows" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:.github/workflows/strix.yml" > "$TRUSTED_WORKSPACE/.github/workflows/strix.yml" | |
| echo "Materialized PR-head Strix workflow for self-test." | |
| fi | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/scripts/ci" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" > "$TRUSTED_WORKSPACE/scripts/ci/pr_review_merge_scheduler.py" | |
| fi | |
| git -C "$TRUSTED_WORKSPACE" update-ref "refs/remotes/pull/${PR_NUMBER}/head" "$PR_HEAD_SHA" | |
| exit 0 | |
| fi | |
| for pr_head_fetch_attempt in 1 2 3 4 5 6; do | |
| git -C "$TRUSTED_WORKSPACE" fetch --no-tags --prune origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head" | |
| fetched_head_sha="$(git -C "$TRUSTED_WORKSPACE" rev-parse "refs/remotes/pull/${PR_NUMBER}/head")" | |
| if [ "$fetched_head_sha" = "$PR_HEAD_SHA" ]; then | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA^{commit}" | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:.github/workflows/strix.yml" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/.github/workflows" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:.github/workflows/strix.yml" > "$TRUSTED_WORKSPACE/.github/workflows/strix.yml" | |
| echo "Materialized PR-head Strix workflow for self-test." | |
| fi | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/scripts/ci" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" > "$TRUSTED_WORKSPACE/scripts/ci/pr_review_merge_scheduler.py" | |
| fi | |
| exit 0 | |
| fi | |
| if [ "$pr_head_fetch_attempt" -lt 6 ]; then | |
| echo "Fetched PR head $fetched_head_sha, expected $PR_HEAD_SHA; retrying after propagation delay." >&2 | |
| sleep 10 | |
| fi | |
| done | |
| echo "::error::PR head ref did not resolve to expected commit $PR_HEAD_SHA after retries." >&2 | |
| exit 1 | |
| - name: Self-test Strix required workflow contract | |
| timeout-minutes: 2 | |
| working-directory: trusted-strix-source | |
| run: | | |
| set -euo pipefail | |
| printf 'Running bounded Strix required-workflow smoke test.\n' | |
| bash "$TRUSTED_STRIX_REQUIRED_SMOKE" | |
| - name: Materialize central Strix dependency lock from PR head | |
| if: >- | |
| github.event_name == 'pull_request_target' | |
| && github.repository == 'ContextualWisdomLab/.github' | |
| && github.event.pull_request.base.repo.full_name == 'ContextualWisdomLab/.github' | |
| && github.event.pull_request.head.repo.full_name == 'ContextualWisdomLab/.github' | |
| env: | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:requirements-strix-ci-hashes.txt" 2>/dev/null; then | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:requirements-strix-ci-hashes.txt" > "$TRUSTED_STRIX_SOURCE/requirements-strix-ci-hashes.txt" | |
| printf 'Materialized central Strix dependency lock from same-repository PR head.\n' | |
| fi | |
| - name: Gate Strix secrets | |
| id: gate | |
| env: | |
| STRIX_MODEL: ${{ github.event.client_payload.strix_llm || (steps.target_visibility.outputs.is_private == 'false' && 'nvidia_nim/nvidia/nemotron-3-super-120b-a12b' || 'gpt-5.4') }} | |
| STRIX_MODEL_REQUESTED: ${{ github.event.client_payload.strix_llm || '' }} | |
| STRIX_OPENAI_API_KEY: ${{ secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} | |
| STRIX_OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} | |
| STRIX_NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} | |
| STRIX_VERTEX_CREDENTIALS: ${{ secrets.GCP_SA_KEY }} | |
| STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} | |
| TARGET_REPOSITORY_PRIVATE: ${{ steps.target_visibility.outputs.is_private }} | |
| run: | | |
| strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$STRIX_MODEL_REQUESTED" ] && [ "$strix_model" = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" ] && [ -z "${STRIX_NVIDIA_NIM_API_KEY:-}" ]; then | |
| strix_model="gpt-5.4" | |
| fi | |
| echo "strix_model=$strix_model" >> "$GITHUB_OUTPUT" | |
| case "$strix_model" in | |
| openai/gpt-5-mini* | openai/gpt-5-nano* | \ | |
| openai/openai/gpt-5-mini* | openai/openai/gpt-5-nano* | \ | |
| github_models/openai/gpt-5-mini* | github_models/openai/gpt-5-nano*) | |
| echo '::error::STRIX_LLM must not select mini or nano GPT-5 variants for security evidence.' | |
| exit 1 | |
| ;; | |
| openai/gpt-5* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \ | |
| openai/openai/gpt-5* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]* | \ | |
| github_models/openai/gpt-5* | github_models/openai/gpt-[6-9]* | github_models/openai/gpt-[1-9][0-9]*) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=github_models' >> "$GITHUB_OUTPUT" | |
| sanitized_github_models_token="$(printf '%s' "$STRIX_GITHUB_MODELS_TOKEN" | tr -d '\r\n')" | |
| trimmed_github_models_token="$(printf '%s' "$sanitized_github_models_token" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_github_models_token" ]; then | |
| echo '::error::STRIX_GITHUB_MODELS_TOKEN is required for GitHub Models Strix scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| gpt-5.[4-9]* | gpt-5.[1-9][0-9]* | gpt-[6-9]* | gpt-[1-9][0-9]* | \ | |
| openai-direct/gpt-5.[4-9]* | openai-direct/gpt-5.[1-9][0-9]* | openai-direct/gpt-[6-9]* | openai-direct/gpt-[1-9][0-9]*) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=openai_direct' >> "$GITHUB_OUTPUT" | |
| sanitized_openai_key="$(printf '%s' "$STRIX_OPENAI_API_KEY" | tr -d '\r\n')" | |
| trimmed_openai_key="$(printf '%s' "$sanitized_openai_key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_openai_key" ]; then | |
| echo '::error::STRIX_OPENAI_API_KEY is required for Strix OpenAI Platform scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| openrouter/free | openrouter/openrouter/free) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=openrouter' >> "$GITHUB_OUTPUT" | |
| sanitized_openrouter_key="$(printf '%s' "$STRIX_OPENROUTER_API_KEY" | tr -d '\r\n')" | |
| trimmed_openrouter_key="$(printf '%s' "$sanitized_openrouter_key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_openrouter_key" ]; then | |
| echo '::error::OPENROUTER_API_KEY is required for Strix OpenRouter scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| nvidia_nim/nvidia/nemotron-3-super-120b-a12b) | |
| if [ "$TARGET_REPOSITORY_PRIVATE" != "false" ]; then | |
| echo '::error::NVIDIA NIM hosted trial scans are limited to public repositories.' | |
| exit 1 | |
| fi | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=nvidia_nim' >> "$GITHUB_OUTPUT" | |
| sanitized_nvidia_key="$(printf '%s' "$STRIX_NVIDIA_NIM_API_KEY" | tr -d '\r\n')" | |
| trimmed_nvidia_key="$(printf '%s' "$sanitized_nvidia_key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_nvidia_key" ]; then | |
| echo '::error::NVIDIA_NIM_API_KEY is required for Strix NVIDIA NIM scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| vertex_ai/gemini-3.1-pro-preview-customtools | vertex_ai/gemini-2.5-flash) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=vertex_ai' >> "$GITHUB_OUTPUT" | |
| sanitized_vertex_credentials="$(printf '%s' "$STRIX_VERTEX_CREDENTIALS" | tr -d '\r')" | |
| trimmed_vertex_credentials="$(printf '%s' "$sanitized_vertex_credentials" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_vertex_credentials" ]; then | |
| echo '::error::GCP_SA_KEY is required for Vertex AI Strix scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| *) | |
| echo '::error::STRIX_LLM must select NVIDIA NIM Nemotron, GitHub Models openai/gpt-5 or newer, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model.' | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Set up Python | |
| if: steps.gate.outputs.enabled == 'true' | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.13" | |
| - name: Install Strix | |
| if: steps.gate.outputs.enabled == 'true' | |
| working-directory: trusted-strix-source | |
| run: | | |
| set -euo pipefail | |
| # GitHub-hosted runners may inherit a collaborative umask (0002), | |
| # which makes pip-generated console scripts group-writable. Pin a | |
| # private install umask before creating the credential-bearing Strix | |
| # entry point; the runtime gate still rejects any later relaxation. | |
| umask 022 | |
| # --no-deps: strix-agent declares cryptography<49, conflicting with this repo's | |
| # cryptography==50.0.0 pin (CVE-2026-39892 fix, see requirements-strix-ci-overrides.txt). | |
| # --require-hashes already pins every package (including transitive deps) to an exact, | |
| # hash-verified version, so skipping pip's redundant declared-range resolution here is | |
| # safe -- verified locally with --dry-run against this exact file before pushing. | |
| python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --no-deps -r requirements-strix-ci-hashes.txt | |
| strix_executable="$(command -v strix || true)" | |
| if [ -z "$strix_executable" ] || [[ "$strix_executable" != /* ]] \ | |
| || [ ! -f "$strix_executable" ] || [ -L "$strix_executable" ] \ | |
| || [ ! -x "$strix_executable" ]; then | |
| echo "::error::Pinned Strix installation did not produce a trusted absolute executable path." | |
| exit 1 | |
| fi | |
| case "$strix_executable" in | |
| "$GITHUB_WORKSPACE"/*|"$RUNNER_TEMP"/*) | |
| echo "::error::Refusing a Strix executable from a workspace or runner-temp path." | |
| exit 1 | |
| ;; | |
| esac | |
| strix_scripts_root="$(python3 -c 'import sysconfig; print(sysconfig.get_path("scripts"))')" | |
| if [ -z "$strix_scripts_root" ] || [[ "$strix_scripts_root" != /* ]] \ | |
| || [ ! -d "$strix_scripts_root" ] || [ -L "$strix_scripts_root" ]; then | |
| echo "::error::Pinned Strix installation did not produce a trusted absolute scripts root." | |
| exit 1 | |
| fi | |
| case "$strix_executable" in | |
| "$strix_scripts_root"/*) ;; | |
| *) | |
| echo "::error::Pinned Strix executable is outside the trusted scripts root." | |
| exit 1 | |
| ;; | |
| esac | |
| # pip and the hosted tool cache can preserve collaborative write bits | |
| # even after a private install umask. Normalize both the containing | |
| # scripts root and resolved console script before pinning their | |
| # identity; the runtime gate still fails closed on later relaxation. | |
| chmod go-w -- "$strix_scripts_root" "$strix_executable" | |
| strix_executable_sha256="$(python3 - "$strix_executable" <<'PY' | |
| import hashlib | |
| from pathlib import Path | |
| import sys | |
| print(hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()) | |
| PY | |
| )" | |
| { | |
| printf 'STRIX_EXECUTABLE_PATH=%s\n' "$strix_executable" | |
| printf 'STRIX_EXECUTABLE_ROOT=%s\n' "$strix_scripts_root" | |
| printf 'STRIX_EXECUTABLE_SHA256=%s\n' "$strix_executable_sha256" | |
| } >> "$GITHUB_ENV" | |
| - name: Mask LLM API key | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| LLM_API_KEY: ${{ steps.gate.outputs.provider_mode == 'github_models' && (secrets.STRIX_GITHUB_MODELS_TOKEN || github.token) || steps.gate.outputs.provider_mode == 'openai_direct' && (secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY) || steps.gate.outputs.provider_mode == 'openrouter' && secrets.OPENROUTER_API_KEY || steps.gate.outputs.provider_mode == 'nvidia_nim' && secrets.NVIDIA_NIM_API_KEY || '' }} | |
| run: | | |
| # Sanitize CR/LF before masking to prevent broken ::add-mask:: | |
| # commands and potential workflow command injection. | |
| sanitized="$(printf '%s' "$LLM_API_KEY" | tr -d '\r\n')" | |
| if [ -n "$sanitized" ]; then | |
| echo "::add-mask::${sanitized}" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -n "$trimmed" ] && [ "$trimmed" != "$sanitized" ]; then | |
| echo "::add-mask::${trimmed}" | |
| fi | |
| fi | |
| - name: Prepare LLM API key input file | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| LLM_API_KEY_SECRET: ${{ steps.gate.outputs.provider_mode == 'github_models' && (secrets.STRIX_GITHUB_MODELS_TOKEN || github.token) || steps.gate.outputs.provider_mode == 'openai_direct' && (secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY) || steps.gate.outputs.provider_mode == 'openrouter' && secrets.OPENROUTER_API_KEY || steps.gate.outputs.provider_mode == 'nvidia_nim' && secrets.NVIDIA_NIM_API_KEY || '' }} | |
| PROVIDER_MODE: ${{ steps.gate.outputs.provider_mode }} | |
| run: | | |
| sanitized="$(printf '%s' "$LLM_API_KEY_SECRET" | tr -d '\r\n')" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed" ] && [ "$PROVIDER_MODE" = "github_models" ]; then | |
| echo '::error::STRIX_GITHUB_MODELS_TOKEN is required for GitHub Models Strix scans.' | |
| exit 1 | |
| fi | |
| if [ -z "$trimmed" ] && [ "$PROVIDER_MODE" = "openai_direct" ]; then | |
| echo '::error::STRIX_OPENAI_API_KEY is required for Strix OpenAI Platform scans.' | |
| exit 1 | |
| fi | |
| if [ -z "$trimmed" ] && [ "$PROVIDER_MODE" = "openrouter" ]; then | |
| echo '::error::OPENROUTER_API_KEY is required for Strix OpenRouter scans.' | |
| exit 1 | |
| fi | |
| if [ -z "$trimmed" ] && [ "$PROVIDER_MODE" = "nvidia_nim" ]; then | |
| echo '::error::NVIDIA_NIM_API_KEY is required for Strix NVIDIA NIM scans.' | |
| exit 1 | |
| fi | |
| umask 077 | |
| llm_api_key_file="$RUNNER_TEMP/llm_api_key.txt" | |
| printf '%s' "$trimmed" > "$llm_api_key_file" | |
| echo "LLM_API_KEY_FILE=$llm_api_key_file" >> "$GITHUB_ENV" | |
| - name: Prepare OpenRouter API base | |
| if: steps.gate.outputs.provider_mode == 'openrouter' | |
| run: | | |
| umask 077 | |
| llm_api_base_file="$RUNNER_TEMP/llm_api_base.txt" | |
| printf '%s' 'https://openrouter.ai/api/v1' > "$llm_api_base_file" | |
| echo "LLM_API_BASE_FILE=$llm_api_base_file" >> "$GITHUB_ENV" | |
| - name: Prepare NVIDIA NIM API base | |
| if: steps.gate.outputs.provider_mode == 'nvidia_nim' | |
| run: | | |
| umask 077 | |
| llm_api_base_file="$RUNNER_TEMP/llm_api_base.txt" | |
| printf '%s' 'https://integrate.api.nvidia.com/v1' > "$llm_api_base_file" | |
| echo "LLM_API_BASE_FILE=$llm_api_base_file" >> "$GITHUB_ENV" | |
| - name: Prepare GitHub Models API base | |
| if: steps.gate.outputs.provider_mode == 'github_models' | |
| run: | | |
| umask 077 | |
| llm_api_base_file="$RUNNER_TEMP/llm_api_base.txt" | |
| printf '%s' 'https://models.github.ai/inference' > "$llm_api_base_file" | |
| echo "LLM_API_BASE_FILE=$llm_api_base_file" >> "$GITHUB_ENV" | |
| - name: Prepare GitHub Models fallback credentials | |
| # github_models is included because its STRIX_FALLBACK_MODELS chain | |
| # ends in openai-direct/gpt-5.4, which needs the direct-OpenAI key and | |
| # API base to authenticate and route after the primary is exhausted. | |
| if: steps.gate.outputs.provider_mode == 'openai_direct' || steps.gate.outputs.provider_mode == 'openrouter' || steps.gate.outputs.provider_mode == 'nvidia_nim' || steps.gate.outputs.provider_mode == 'github_models' | |
| env: | |
| GITHUB_MODELS_FALLBACK_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} | |
| OPENAI_FALLBACK_KEY: ${{ secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} | |
| OPENROUTER_FALLBACK_KEY: ${{ secrets.OPENROUTER_API_KEY }} | |
| run: | | |
| # Direct-OpenAI scans keep GitHub Models candidates as fallbacks, so | |
| # a provider quota outage degrades to a slower model instead of a | |
| # neutral skip with no security evidence. github_models/* fallback | |
| # models read this token and endpoint; the primary keeps its own key. | |
| umask 077 | |
| sanitized="$(printf '%s' "$GITHUB_MODELS_FALLBACK_TOKEN" | tr -d '\r\n')" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed" ]; then | |
| echo '::notice::No GitHub Models token available; direct-OpenAI Strix scans run without GitHub Models fallbacks.' | |
| else | |
| github_models_key_file="$RUNNER_TEMP/github_models_fallback_key.txt" | |
| printf '%s' "$sanitized" > "$github_models_key_file" | |
| echo "STRIX_GITHUB_MODELS_KEY_FILE=$github_models_key_file" >> "$GITHUB_ENV" | |
| github_models_api_base_file="$RUNNER_TEMP/github_models_api_base.txt" | |
| printf '%s' 'https://models.github.ai/inference' > "$github_models_api_base_file" | |
| echo "STRIX_GITHUB_MODELS_API_BASE_FILE=$github_models_api_base_file" >> "$GITHUB_ENV" | |
| fi | |
| # openai-direct/* fallback models (the contracted final fallback for | |
| # NVIDIA NIM and OpenRouter chains) authenticate against the direct | |
| # OpenAI API, so they need the OpenAI key instead of the primary | |
| # provider's key. | |
| openai_sanitized="$(printf '%s' "$OPENAI_FALLBACK_KEY" | tr -d '\r\n')" | |
| openai_trimmed="$(printf '%s' "$openai_sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -n "$openai_trimmed" ]; then | |
| openai_fallback_key_file="$RUNNER_TEMP/openai_fallback_key.txt" | |
| printf '%s' "$openai_trimmed" > "$openai_fallback_key_file" | |
| echo "STRIX_OPENAI_FALLBACK_KEY_FILE=$openai_fallback_key_file" >> "$GITHUB_ENV" | |
| openai_fallback_api_base_file="$RUNNER_TEMP/openai_fallback_api_base.txt" | |
| printf '%s' 'https://api.openai.com/v1' > "$openai_fallback_api_base_file" | |
| echo "STRIX_OPENAI_FALLBACK_API_BASE_FILE=$openai_fallback_api_base_file" >> "$GITHUB_ENV" | |
| fi | |
| openrouter_sanitized="$(printf '%s' "$OPENROUTER_FALLBACK_KEY" | tr -d '\r\n')" | |
| openrouter_trimmed="$(printf '%s' "$openrouter_sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -n "$openrouter_trimmed" ]; then | |
| openrouter_fallback_key_file="$RUNNER_TEMP/openrouter_fallback_key.txt" | |
| printf '%s' "$openrouter_trimmed" > "$openrouter_fallback_key_file" | |
| echo "STRIX_OPENROUTER_FALLBACK_KEY_FILE=$openrouter_fallback_key_file" >> "$GITHUB_ENV" | |
| openrouter_fallback_api_base_file="$RUNNER_TEMP/openrouter_fallback_api_base.txt" | |
| printf '%s' 'https://openrouter.ai/api/v1' > "$openrouter_fallback_api_base_file" | |
| echo "STRIX_OPENROUTER_FALLBACK_API_BASE_FILE=$openrouter_fallback_api_base_file" >> "$GITHUB_ENV" | |
| fi | |
| - name: Prepare Vertex AI credentials | |
| if: steps.gate.outputs.provider_mode == 'vertex_ai' | |
| env: | |
| GCP_SA_KEY_JSON: ${{ secrets.GCP_SA_KEY }} | |
| run: | | |
| umask 077 | |
| credentials_file="$RUNNER_TEMP/gcp-sa-key.json" | |
| printf '%s' "$GCP_SA_KEY_JSON" > "$credentials_file" | |
| python3 - "$credentials_file" >> "$GITHUB_ENV" <<'PY' | |
| import json | |
| import pathlib | |
| import sys | |
| credentials_path = pathlib.Path(sys.argv[1]) | |
| def reject_duplicate_json_keys(pairs): | |
| parsed = {} | |
| for key, value in pairs: | |
| if key in parsed: | |
| raise ValueError("duplicate credential key") | |
| parsed[key] = value | |
| return parsed | |
| try: | |
| credentials_text = credentials_path.read_text(encoding="utf-8") | |
| credentials = json.loads( | |
| credentials_text, | |
| object_pairs_hook=reject_duplicate_json_keys, | |
| ) | |
| except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError): | |
| raise SystemExit( | |
| "GCP_SA_KEY must be valid service account JSON for Vertex AI Strix scans." | |
| ) | |
| if not isinstance(credentials, dict): | |
| raise SystemExit( | |
| "GCP_SA_KEY must be a JSON object for Vertex AI Strix scans." | |
| ) | |
| project_id = str(credentials.get("project_id", "")).strip() | |
| if not project_id: | |
| raise SystemExit("GCP_SA_KEY must include project_id for Vertex AI Strix scans.") | |
| print(f"GOOGLE_APPLICATION_CREDENTIALS={credentials_path}") | |
| print(f"CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE={credentials_path}") | |
| print(f"VERTEXAI_PROJECT={project_id}") | |
| print(f"GOOGLE_CLOUD_PROJECT={project_id}") | |
| print(f"GCP_PROJECT={project_id}") | |
| print(f"GCLOUD_PROJECT={project_id}") | |
| print(f"CLOUDSDK_CORE_PROJECT={project_id}") | |
| print(f"CLOUDSDK_PROJECT={project_id}") | |
| PY | |
| - name: Prepare Strix model input file | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| STRIX_MODEL: ${{ steps.gate.outputs.strix_model }} | |
| run: | | |
| umask 077 | |
| strix_llm_file="$RUNNER_TEMP/strix_llm.txt" | |
| strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| case "$strix_model" in | |
| openai/gpt-5-mini* | openai/gpt-5-nano* | \ | |
| openai/openai/gpt-5-mini* | openai/openai/gpt-5-nano* | \ | |
| github_models/openai/gpt-5-mini* | github_models/openai/gpt-5-nano*) | |
| echo '::error::STRIX_LLM must not select mini or nano GPT-5 variants for security evidence.' | |
| exit 1 | |
| ;; | |
| openai/gpt-5* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \ | |
| openai/openai/gpt-5* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]* | \ | |
| github_models/openai/gpt-5* | github_models/openai/gpt-[6-9]* | github_models/openai/gpt-[1-9][0-9]*) | |
| printf '%s' "${strix_model#github_models/}" > "$strix_llm_file" | |
| ;; | |
| openai/*) | |
| printf '%s' "$strix_model" > "$strix_llm_file" | |
| ;; | |
| openai-direct/gpt-*) | |
| printf 'openai_direct/%s' "${strix_model#openai-direct/}" > "$strix_llm_file" | |
| ;; | |
| gpt-*) | |
| printf 'openai_direct/%s' "$strix_model" > "$strix_llm_file" | |
| ;; | |
| openrouter/free | openrouter/openrouter/free) | |
| printf '%s' 'openrouter/free' > "$strix_llm_file" | |
| ;; | |
| nvidia_nim/nvidia/nemotron-3-super-120b-a12b) | |
| printf '%s' "$strix_model" > "$strix_llm_file" | |
| ;; | |
| vertex_ai/gemini-3.1-pro-preview-customtools | vertex_ai/gemini-2.5-flash) | |
| printf '%s' "$strix_model" > "$strix_llm_file" | |
| ;; | |
| *) | |
| echo '::error::STRIX_LLM must select NVIDIA NIM Nemotron, GitHub Models openai/gpt-5 or newer, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model.' | |
| exit 1 | |
| ;; | |
| esac | |
| echo "STRIX_LLM_FILE=$strix_llm_file" >> "$GITHUB_ENV" | |
| - name: Run Strix (quick) | |
| if: steps.gate.outputs.enabled == 'true' | |
| timeout-minutes: 100 | |
| # Security invariant for pull_request_target: execute only from the | |
| # trusted base checkout. The gate copies PR-head blobs into an isolated | |
| # temporary scope with execute bits stripped, then scans that scope as | |
| # data. PR evidence uses the __PR_SCOPE__ sentinel so the scanner target | |
| # cannot accidentally remain the trusted base checkout. | |
| working-directory: ${{ runner.temp }}/trusted-workspace | |
| env: | |
| STRIX_LLM_FILE: ${{ env.STRIX_LLM_FILE }} | |
| STRIX_REPO_ROOT: ${{ runner.temp }}/trusted-workspace | |
| LLM_API_BASE_FILE: ${{ env.LLM_API_BASE_FILE }} | |
| STRIX_LLM_DEFAULT_PROVIDER: ${{ steps.gate.outputs.provider_mode == 'vertex_ai' && 'vertex_ai' || steps.gate.outputs.provider_mode == 'nvidia_nim' && 'nvidia_nim' || 'openai' }} | |
| LLM_API_KEY_FILE: ${{ env.LLM_API_KEY_FILE }} | |
| GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }} | |
| CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: ${{ env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE }} | |
| VERTEXAI_PROJECT: ${{ env.VERTEXAI_PROJECT }} | |
| GOOGLE_CLOUD_PROJECT: ${{ env.GOOGLE_CLOUD_PROJECT }} | |
| GCP_PROJECT: ${{ env.GCP_PROJECT }} | |
| GCLOUD_PROJECT: ${{ env.GCLOUD_PROJECT }} | |
| CLOUDSDK_CORE_PROJECT: ${{ env.CLOUDSDK_CORE_PROJECT }} | |
| CLOUDSDK_PROJECT: ${{ env.CLOUDSDK_PROJECT }} | |
| VERTEXAI_LOCATION: ${{ secrets.VERTEX_LOCATION || 'us-central1' }} | |
| VERTEX_LOCATION: ${{ secrets.VERTEX_LOCATION || 'us-central1' }} | |
| STRIX_TARGET_PATH: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && '__PR_SCOPE__' || './' }} | |
| STRIX_SOURCE_DIRS: ". backend frontend" | |
| STRIX_REASONING_EFFORT: high | |
| STRIX_LLM_MAX_RETRIES: 1 | |
| STRIX_TRANSIENT_RETRY_PER_MODEL: 2 | |
| STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS: 60 | |
| # Trusted-main smoke compatibility marker only; never executed: | |
| # nvidia_nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 openai-direct/gpt-5.4 | |
| STRIX_FALLBACK_MODELS: ${{ steps.gate.outputs.provider_mode == 'github_models' && 'openai-direct/gpt-5.4' || steps.gate.outputs.provider_mode == 'openai_direct' && 'openai-direct/gpt-5.4' || steps.gate.outputs.provider_mode == 'openrouter' && 'openai-direct/gpt-5.4' || steps.gate.outputs.provider_mode == 'nvidia_nim' && 'openrouter/free openai-direct/gpt-5.4' || '' }} | |
| STRIX_GITHUB_MODELS_API_BASE_FILE: ${{ env.STRIX_GITHUB_MODELS_API_BASE_FILE }} | |
| STRIX_GITHUB_MODELS_KEY_FILE: ${{ env.STRIX_GITHUB_MODELS_KEY_FILE }} | |
| STRIX_OPENAI_FALLBACK_KEY_FILE: ${{ env.STRIX_OPENAI_FALLBACK_KEY_FILE }} | |
| STRIX_OPENAI_FALLBACK_API_BASE_FILE: ${{ env.STRIX_OPENAI_FALLBACK_API_BASE_FILE }} | |
| STRIX_OPENROUTER_FALLBACK_KEY_FILE: ${{ env.STRIX_OPENROUTER_FALLBACK_KEY_FILE }} | |
| STRIX_OPENROUTER_FALLBACK_API_BASE_FILE: ${{ env.STRIX_OPENROUTER_FALLBACK_API_BASE_FILE }} | |
| STRIX_FAIL_ON_PROVIDER_SIGNAL: "1" | |
| STRIX_VERTEX_FALLBACK_MODELS: "" | |
| NPM_CONFIG_IGNORE_SCRIPTS: "true" | |
| PNPM_CONFIG_IGNORE_SCRIPTS: "true" | |
| YARN_ENABLE_SCRIPTS: "false" | |
| BUN_CONFIG_IGNORE_SCRIPTS: "true" | |
| STRIX_FAIL_ON_MIN_SEVERITY: MEDIUM | |
| STRIX_DISABLE_PR_SCOPING: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && '0' || '1' }} | |
| # A repository_dispatch executes in this central repository, so its | |
| # github.token cannot read the target repository's PR. Reuse the | |
| # target-app token that already validated and fetched that exact PR; | |
| # preserve the target-repository token for pull_request_target runs. | |
| GH_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && (steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token) || github.event_name == 'pull_request_target' && github.token || '' }} | |
| PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event.client_payload.pr_number }} | |
| PR_BASE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha }} | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| IS_PR_EVIDENCE_RUN: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && 'true' || 'false' }} | |
| run: | | |
| budget_suffix="TIME""OUT" | |
| process_budget_seconds="5400" | |
| export "LLM_${budget_suffix}=900" | |
| export "STRIX_MEMORY_COMPRESSOR_${budget_suffix}=300" | |
| export "STRIX_PROCESS_${budget_suffix}_SECONDS=$process_budget_seconds" | |
| export "STRIX_TOTAL_${budget_suffix}_SECONDS=5700" | |
| # Recognized signals that the LLM backend was unavailable / starved. | |
| # Defined before the gate loop so the bounded retry decision below | |
| # can classify outcomes without duplicating the patterns later. | |
| backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404|Error during penetration test: loginAsGuest failed after [0-9]+ attempts: curl exit 7: curl: \(7\) Failed to connect to 127\.0\.0\.1 port 48080' | |
| model_behavior_error_signal='(^|[^A-Za-z0-9_])(agents|pydantic_ai|strix)(\.[A-Za-z_][A-Za-z0-9_]*)*\.ModelBehaviorError([^A-Za-z0-9_]|$)' | |
| # Any evidence that a vulnerability was actually reported. Its presence | |
| # forces a hard failure so real findings are NEVER downgraded. Keep the | |
| # severity branch anchored away from identifiers so environment lines | |
| # such as STRIX_FAIL_ON_MIN_SEVERITY do not look like findings. | |
| reported_vulnerability_signal='Vulnerabilities[[:space:]]+[1-9]|(^|[^A-Za-z0-9_])severity[[:space:]]*:' | |
| # Capture the gate exit code plus its console output. The gate returns | |
| # exit 1 both for genuine blocking vulnerabilities AND for | |
| # LLM-backend-unavailable outcomes (GitHub Models "Too many requests" | |
| # rate limits, OpenAI quota starvation, 413 tokens_limit_reached, | |
| # connection/warm-up failures, and scanner ModelBehaviorError) that | |
| # could not complete a scan. Provider failure is typed infrastructure | |
| # evidence, but remains non-passing because no authoritative complete | |
| # vulnerability result exists. | |
| # | |
| # A typed provider outage with no reported vulnerability finding is | |
| # retried with bounded linear backoff inside this step so transient | |
| # provider failures do not fail the required check on the first | |
| # attempt. Genuine findings, configuration failures, and unexpected | |
| # exit codes never retry; the deadline keeps every path inside the | |
| # deterministic 120-minute job budget, and all-terminal outcomes | |
| # remain fail-closed. | |
| strix_run_log="$RUNNER_TEMP/strix_gate_console.log" | |
| : > "$strix_run_log" | |
| strix_terminal_log="$strix_run_log" | |
| strix_rc=0 | |
| strix_gate_attempt=1 | |
| strix_gate_deadline=$(( SECONDS + 6000 )) | |
| strix_gate_attempt_budget_var="STRIX_TOTAL_${budget_suffix}_SECONDS" | |
| strix_gate_attempt_budget_seconds="${!strix_gate_attempt_budget_var:-$process_budget_seconds}" | |
| set +e | |
| while : ; do | |
| strix_attempt_log="$RUNNER_TEMP/strix_gate_console_attempt_${strix_gate_attempt}.log" | |
| : > "$strix_attempt_log" | |
| bash "$TRUSTED_STRIX_GATE" 2>&1 | tee "$strix_attempt_log" | |
| strix_rc="${PIPESTATUS[0]}" | |
| cat "$strix_attempt_log" >> "$strix_run_log" | |
| strix_terminal_log="$strix_attempt_log" | |
| if [ "$strix_rc" -eq 0 ]; then | |
| break | |
| fi | |
| # Only exit-code 1 scan failures can be infrastructure outcomes. | |
| if [ "$strix_rc" -ne 1 ]; then | |
| break | |
| fi | |
| # Scope this attempt's retry decision to the log tail after the | |
| # last pipeline-continuation marker, exactly like the terminal | |
| # classification below: an already-exempted finding before the | |
| # marker must not mask a retryable outage after it. | |
| strix_retry_scope_log="$strix_terminal_log" | |
| if grep -Fq 'allowing pipeline continuation' "$strix_terminal_log"; then | |
| strix_retry_scope_log="$RUNNER_TEMP/strix_gate_console_tail.log" | |
| awk '/allowing pipeline continuation/{buf=""; next} {buf=buf $0 "\n"} END{printf "%s", buf}' \ | |
| "$strix_terminal_log" > "$strix_retry_scope_log" | |
| fi | |
| # A reported vulnerability is authoritative evidence: never retry | |
| # and never risk downgrading it. | |
| if grep -Eiq "$reported_vulnerability_signal" "$strix_retry_scope_log"; then | |
| break | |
| fi | |
| # Retry only recognized provider-outage / model-behavior classes. | |
| if ! grep -Eiq "$backend_unavailable_signal" "$strix_retry_scope_log" \ | |
| && ! grep -Eq "$model_behavior_error_signal" "$strix_retry_scope_log"; then | |
| break | |
| fi | |
| backoff_seconds=$(( ${STRIX_GATE_RETRY_BACKOFF_SECONDS:-90} * strix_gate_attempt )) | |
| retry_reserve_seconds=$(( strix_gate_attempt_budget_seconds + backoff_seconds )) | |
| remaining_seconds=$(( strix_gate_deadline - SECONDS )) | |
| if [ "$strix_gate_attempt" -ge 3 ] || [ "$remaining_seconds" -lt "$retry_reserve_seconds" ]; then | |
| echo "Provider-unavailable Strix attempt ${strix_gate_attempt} reached the bounded retry limit or the remaining job time budget (${remaining_seconds}s) is too small to retry; failing closed." >&2 | |
| break | |
| fi | |
| echo "Strix provider outage on attempt ${strix_gate_attempt}; retrying after ${backoff_seconds}s backoff." >&2 | |
| sleep "$backoff_seconds" | |
| strix_gate_attempt=$(( strix_gate_attempt + 1 )) | |
| done | |
| set -e | |
| if [ "$strix_rc" -eq 0 ]; then | |
| exit 0 | |
| fi | |
| # Preserve configuration failures (exit 2) and any unexpected exit | |
| # code as hard failures — only the scan-failure code (1) can be an | |
| # infrastructure/backend-unavailability outcome. | |
| if [ "$strix_rc" -ne 1 ]; then | |
| exit "$strix_rc" | |
| fi | |
| # An earlier out-of-scope/below-threshold finding may already have | |
| # been exempted by the trusted gate. Classify a later provider | |
| # outage from the tail after the last continuation marker, but keep | |
| # that incomplete later scan non-passing. | |
| strix_neutralization_scope_log="$strix_terminal_log" | |
| if grep -Fq 'allowing pipeline continuation' "$strix_terminal_log"; then | |
| strix_neutralization_scope_log="$RUNNER_TEMP/strix_gate_console_tail.log" | |
| awk '/allowing pipeline continuation/{buf=""; next} {buf=buf $0 "\n"} END{printf "%s", buf}' \ | |
| "$strix_terminal_log" > "$strix_neutralization_scope_log" | |
| fi | |
| # Classify provider/backend exhaustion only when no vulnerability | |
| # finding was emitted. Classification improves diagnosis; it never | |
| # converts an incomplete scan into passing security evidence. | |
| if ( grep -Eiq "$backend_unavailable_signal" "$strix_neutralization_scope_log" \ | |
| || grep -Eq "$model_behavior_error_signal" "$strix_neutralization_scope_log" ) \ | |
| && ! grep -Eiq "$reported_vulnerability_signal" "$strix_neutralization_scope_log"; then | |
| echo "::error title=STRIX_PROVIDER_UNAVAILABLE::Strix could not complete authoritative vulnerability analysis because its provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure). See the strix-reports artifact and run log." | |
| exit "$strix_rc" | |
| fi | |
| echo "Strix reported security findings or failed for a non-backend reason; failing the required check (gate exit ${strix_rc})." >&2 | |
| exit "$strix_rc" | |
| - name: Collect Strix reports for artifact upload | |
| if: ${{ always() && steps.gate.outputs.enabled == 'true' }} | |
| env: | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$GITHUB_WORKSPACE/strix_runs" | |
| copied_reports=0 | |
| for candidate_dir in "$TRUSTED_WORKSPACE/strix_runs" "$RUNNER_TEMP/strix_runs"; do | |
| if [ -d "$candidate_dir" ] && [ -n "$(find "$candidate_dir" -mindepth 1 -print -quit)" ]; then | |
| cp -R "$candidate_dir"/. "$GITHUB_WORKSPACE/strix_runs"/ | |
| copied_reports=1 | |
| fi | |
| done | |
| if [ -f "$RUNNER_TEMP/strix_gate_console.log" ]; then | |
| cp "$RUNNER_TEMP/strix_gate_console.log" "$GITHUB_WORKSPACE/strix_runs/gate-console.log" | |
| copied_reports=1 | |
| fi | |
| if [ -n "$(find "$GITHUB_WORKSPACE/strix_runs" -mindepth 1 -print -quit)" ]; then | |
| copied_reports=1 | |
| fi | |
| if [ "$copied_reports" -eq 0 ]; then | |
| summary_head_sha="${PR_HEAD_SHA:-$GITHUB_SHA}" | |
| { | |
| echo "Strix scan completed without structured report files." | |
| echo "run_id=$GITHUB_RUN_ID" | |
| echo "head_sha=$summary_head_sha" | |
| } > "$GITHUB_WORKSPACE/strix_runs/scan-summary.txt" | |
| fi | |
| - name: Upload Strix reports artifact | |
| if: ${{ always() && steps.gate.outputs.enabled == 'true' }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: strix-reports | |
| path: strix_runs/ | |
| if-no-files-found: error | |
| 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 != '' }} | |
| 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 || '' }} | |
| PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} | |
| OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} | |
| TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.repository }} | |
| PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| STRIX_RESULT: ${{ job.status }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| case "$STRIX_RESULT" in | |
| success) | |
| state="success" | |
| description="Default-branch repository_dispatch Strix evidence passed" | |
| ;; | |
| failure|cancelled|skipped) | |
| state="failure" | |
| description="Default-branch repository_dispatch Strix evidence failed" | |
| ;; | |
| *) | |
| state="error" | |
| description="Default-branch repository_dispatch Strix evidence inconclusive" | |
| ;; | |
| esac | |
| post_strix_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ | |
| -f state="$state" \ | |
| -f context="strix" \ | |
| -f description="$description" \ | |
| -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ | |
| >"$status_response" 2>"$status_error"; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Published manual Strix status to ${TARGET_REPOSITORY}@${PR_HEAD_SHA} using ${token_label}." | |
| return 0 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed: ${error_summary}" | |
| else | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed." | |
| fi | |
| return 1 | |
| } | |
| if post_strix_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| echo "::warning::Could not publish manual Strix status from scan job; keeping scan evidence result authoritative in the workflow run." | |
| 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 != '' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| id-token: write | |
| statuses: write # Required for downscoped OIDC status publication. | |
| steps: | |
| - name: Exchange OpenCode app token for target repository status | |
| id: target_app_token | |
| 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 "OpenCode app token exchange 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 "OpenCode app token exchange unavailable: OIDC token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" | |
| if [ -z "$oidc_token" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC token 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 "OpenCode app token exchange unavailable: app token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| app_token="$(jq -r '.token // empty' <<<"$token_response")" | |
| if [ -z "$app_token" ]; then | |
| echo "OpenCode app token exchange unavailable: app token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| echo "::add-mask::$app_token" | |
| { | |
| echo "available=true" | |
| echo "token=$app_token" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Publish same-head manual Strix status | |
| env: | |
| TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} | |
| GITHUB_STATUS_READ_TOKEN: ${{ github.token }} | |
| PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} | |
| OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} | |
| TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.repository }} | |
| PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| STRIX_RESULT: ${{ needs.strix.result }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| case "$STRIX_RESULT" in | |
| success) | |
| state="success" | |
| description="Default-branch repository_dispatch Strix evidence passed" | |
| ;; | |
| failure|cancelled|skipped) | |
| state="failure" | |
| description="Default-branch repository_dispatch Strix evidence failed" | |
| ;; | |
| *) | |
| state="error" | |
| description="Default-branch repository_dispatch Strix evidence inconclusive" | |
| ;; | |
| esac | |
| post_strix_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ | |
| -f state="$state" \ | |
| -f context="strix" \ | |
| -f description="$description" \ | |
| -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ | |
| >"$status_response" 2>"$status_error"; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Published manual Strix status to ${TARGET_REPOSITORY}@${PR_HEAD_SHA} using ${token_label}." | |
| return 0 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed: ${error_summary}" | |
| else | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed." | |
| fi | |
| return 1 | |
| } | |
| existing_current_run_success_status() { | |
| if [ "$state" != "success" ]; then | |
| return 1 | |
| fi | |
| target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" | |
| check_existing_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses" \ | |
| >"$status_response" 2>"$status_error"; then | |
| if jq -e --arg target_url "$target_url" \ | |
| 'any(.[]; .context == "strix" and .state == "success" and ((.target_url // "") == $target_url))' \ | |
| "$status_response" >/dev/null; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Existing current-run Strix success status is already present on ${TARGET_REPOSITORY}@${PR_HEAD_SHA}; follow-up status publication is complete." | |
| return 0 | |
| fi | |
| rm -f "$status_response" "$status_error" | |
| echo "::notice::No current-run Strix success status was visible using ${token_label}." | |
| return 1 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Could not inspect existing Strix status using ${token_label}: ${error_summary}" | |
| else | |
| echo "::notice::Could not inspect existing Strix status using ${token_label}." | |
| fi | |
| return 1 | |
| } | |
| if check_existing_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "github-token" "$GITHUB_STATUS_READ_TOKEN"; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| if post_strix_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if existing_current_run_success_status; then | |
| exit 0 | |
| fi | |
| # A successful scan remains authoritative evidence even when an | |
| # external target repository does not grant any configured token | |
| # permission to create commit statuses. Keep every credential- | |
| # specific failure visible above, but do not turn a clean security | |
| # scan into a failed workflow solely because of target settings. | |
| if [ "$STRIX_RESULT" = "success" ]; then | |
| echo "::warning title=Manual Strix status unavailable::Strix scan succeeded, but no configured credential could publish or read the target commit status. Preserving the successful scan result; the target repository's branch protection remains authoritative. See the preceding token-specific notices." | |
| exit 0 | |
| fi | |
| echo "::error::Could not publish manual Strix status from follow-up job after all configured credentials failed after a non-successful scan; the target PR head is missing required Strix status evidence. See the preceding notices for token-specific reasons." | |
| exit 1 |