From 77ff59f00fa8fcfde7984a8fd48cee6ca3fd7cf6 Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 11:14:00 -0700 Subject: [PATCH 1/8] ci: add an aggregation test job and action lint Signed-off-by: Brooke Storm --- .github/workflows/ci.yaml | 227 +++++++++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 066fe33aac..394980916b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,7 @@ on: workflow_dispatch: permissions: + actions: read contents: read pull-requests: read @@ -27,12 +28,43 @@ jobs: deps: ${{ steps.changes.outputs.deps }} e2e: ${{ steps.changes.outputs.e2e }} docs: ${{ steps.changes.outputs.docs }} - web: ${{ steps.changes.outputs.web }} + web-studio: ${{ steps.changes.outputs.web-studio }} tools: ${{ steps.changes.outputs.tools }} steps: - uses: actions/checkout@v6 - uses: ./.github/actions/changes id: changes + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Detect Path Changes + status: ${{ job.status }} + + actionlint: + name: Run actionlint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Install actionlint + shell: bash + run: | + mkdir -p "${RUNNER_TEMP}/actionlint" + curl -fsSL \ + -o "${RUNNER_TEMP}/actionlint.tar.gz" \ + "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" + tar -xzf "${RUNNER_TEMP}/actionlint.tar.gz" -C "${RUNNER_TEMP}/actionlint" + - name: Lint GitHub workflows and actions + shell: bash + run: | + "${RUNNER_TEMP}/actionlint/actionlint" + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Run actionlint + status: ${{ job.status }} lint: name: Lint all @@ -76,6 +108,12 @@ jobs: python-sdk-lint/ web/packages/sdk/generated/ diff.txt + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Lint all + status: ${{ job.status }} policy-wasm: name: Build OPA policy WASM @@ -89,6 +127,12 @@ jobs: name: policy-wasm retention-days: 1 path: services/core/auth/src/nmp/core/auth/assets/policy.wasm + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Build OPA policy WASM + status: ${{ job.status }} python-unit-test-tools: name: Python unit tests (tools) @@ -118,6 +162,12 @@ jobs: name: python-unit-test-tools-results retention-days: 30 path: report.xml + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Python unit tests (tools) + status: ${{ job.status }} python-unit-test: name: Python unit tests @@ -156,6 +206,12 @@ jobs: coverage.xml coverage.json coverage-html/ + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Python unit tests + status: ${{ job.status }} python-integration-test: name: Python integration tests @@ -203,6 +259,12 @@ jobs: report.xml coverage.xml coverage.json + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Python integration tests + status: ${{ job.status }} # Build wheels for all packages × python versions. Downstream jobs # (wheel-test, python-e2e-test) download these artifacts instead @@ -246,6 +308,12 @@ jobs: path: ${{ steps.build.outputs.wheel-path }} retention-days: 7 if-no-files-found: error + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: ${{ matrix.package }} wheel build (py${{ matrix.python-version }}) + status: ${{ job.status }} # Test each wheel: install from the artifact and run basic CLI / # import checks. @@ -284,7 +352,13 @@ jobs: _TYPER_FORCE_DISABLE_TERMINAL: "1" run: | set -euo pipefail - WHEEL="$(ls ${RUNNER_TEMP}/wheelcheck/*.whl)" + shopt -s nullglob + wheels=("${RUNNER_TEMP}"/wheelcheck/*.whl) + if [[ "${#wheels[@]}" -ne 1 ]]; then + echo "::error::Expected exactly one wheel, found ${#wheels[@]}" + exit 1 + fi + WHEEL="${wheels[0]}" uv tool install --force --python "${PYTHON_VERSION}" "${WHEEL}[services]" cd "${RUNNER_TEMP}/wheelcheck" unset PYTHONPATH VIRTUAL_ENV @@ -297,7 +371,13 @@ jobs: PYTHON_VERSION: ${{ matrix.python-version }} run: | set -euo pipefail - WHEEL="$(ls ${RUNNER_TEMP}/wheelcheck/*.whl)" + shopt -s nullglob + wheels=("${RUNNER_TEMP}"/wheelcheck/*.whl) + if [[ "${#wheels[@]}" -ne 1 ]]; then + echo "::error::Expected exactly one wheel, found ${#wheels[@]}" + exit 1 + fi + WHEEL="${wheels[0]}" cd "${RUNNER_TEMP}/wheelcheck" uv venv .venv --python "${PYTHON_VERSION}" uv pip install --python .venv/bin/python "${WHEEL}" @@ -312,6 +392,12 @@ jobs: import nemo_platform_plugin.commands print('nemo_platform_plugin', getattr(nemo_platform_plugin, '__version__', '')) " + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: ${{ matrix.package }} wheel build + test (py${{ matrix.python-version }}) + status: ${{ job.status }} # E2E tests: start services from the workspace venv and run the e2e # suite against the real process. @@ -357,6 +443,12 @@ jobs: path: | report.xml ${{ runner.temp }}/e2e-services-logs/ + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Python e2e tests + status: ${{ job.status }} # Required-check pin: branch protection should reference this aggregator # rather than the per-row matrix jobs, so the matrix can grow or shrink @@ -369,6 +461,8 @@ jobs: permissions: contents: read steps: + - name: Checkout code + uses: actions/checkout@v6 - name: Check matrix result shell: bash env: @@ -386,6 +480,12 @@ jobs: exit 1 ;; esac + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: Wheel build + test + status: ${{ job.status }} benchmark-guardrails: name: Guardrails plugin benchmark @@ -430,6 +530,12 @@ jobs: retention-days: 30 path: | nemo-platform/plugins/nemo-guardrails/benchmarks/artifacts/runs/ + - name: Record test status + if: always() + uses: ./nemo-platform/.github/actions/test-status + with: + name: Guardrails plugin benchmark + status: ${{ job.status }} coverage-comment: name: Post coverage comment @@ -497,10 +603,123 @@ jobs: - name: Run test run: | ./opa test services/core/auth/src/nmp/core/auth/app/policies services/core/auth/src/nmp/core/auth/app/policy_tests services/core/auth/src/nmp/core/auth/assets/static-authz.yaml -v + - name: Record test status + if: always() + uses: ./.github/actions/test-status + with: + name: OPA policy tests + status: ${{ job.status }} + + # Required-check pin: branch protection should require this final aggregate + # job. Jobs opt in by uploading a test-status artifact; skipped jobs do not + # contribute to the aggregate. + ci-status: + name: CI status + if: always() + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + actions: read + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Wait for contributing jobs + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Exclude this gate and jobs that intentionally depend on it. + excluded_jobs=( + "CI status" + "Notify CI consumer" + ) + + is_excluded() { + local job_name="$1" + local excluded_job + for excluded_job in "${excluded_jobs[@]}"; do + if [[ "${job_name}" == "${excluded_job}" ]]; then + return 0 + fi + done + return 1 + } + + while true; do + mapfile -t unfinished_jobs < <( + gh api --paginate \ + "/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs?per_page=100" \ + --jq '.jobs[] | select(.status != "completed") | .name' + ) + + waiting_jobs=() + for job_name in "${unfinished_jobs[@]}"; do + if ! is_excluded "${job_name}"; then + waiting_jobs+=("${job_name}") + fi + done + + if [[ "${#waiting_jobs[@]}" -eq 0 ]]; then + break + fi + + echo "Waiting for ${#waiting_jobs[@]} job(s): ${waiting_jobs[*]}" + sleep 30 + done + + - name: Download test statuses + uses: actions/download-artifact@v8 + with: + pattern: test-status-${{ github.run_attempt }}-* + path: test-status + merge-multiple: true + - name: Check test statuses + shell: bash + run: | + set -euo pipefail + + failed=0 + total=0 + + { + echo "## CI status" + echo + echo "| Job | Passed | Status |" + echo "| --- | --- | --- |" + } >> "${GITHUB_STEP_SUMMARY}" + + while IFS= read -r -d '' status_file; do + if [[ "$(jq -r '.run_attempt' "${status_file}")" != "${GITHUB_RUN_ATTEMPT}" ]]; then + continue + fi + + name="$(jq -r '.name' "${status_file}")" + passed="$(jq -r '.passed' "${status_file}")" + status="$(jq -r '.status' "${status_file}")" + total=$((total + 1)) + + printf '| %s | %s | %s |\n' \ + "${name}" "${passed}" "${status}" >> "${GITHUB_STEP_SUMMARY}" + + if [[ "${passed}" != "true" ]]; then + echo "::error title=CI status::${name} concluded ${status}" + failed=1 + fi + done < <(find test-status -type f -name '*.json' -print0 | sort -z) + + if [[ "${total}" -eq 0 ]]; then + echo "::error title=CI status::No test status artifacts were found" + exit 1 + fi + + exit "${failed}" notify-ci-consumer: name: Notify CI consumer - needs: [lint, python-unit-test, opa-policy-test, wheel-test-aggregate] + needs: [ci-status] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: From 94bd97372b603929e00ffc56ad150fcc61fc926c Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 11:14:25 -0700 Subject: [PATCH 2/8] ci: commit the composite action as well Signed-off-by: Brooke Storm --- .github/actions/test-status/action.yaml | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/actions/test-status/action.yaml diff --git a/.github/actions/test-status/action.yaml b/.github/actions/test-status/action.yaml new file mode 100644 index 0000000000..2afee4151f --- /dev/null +++ b/.github/actions/test-status/action.yaml @@ -0,0 +1,63 @@ +name: Test Status +description: Upload this job's terminal status for the CI status gate + +inputs: + name: + description: Human-readable check name to show in the CI status summary + required: true + status: + description: "Pass the job.status context from an if: always() step" + required: true + +runs: + using: composite + steps: + - name: Write status + id: write + shell: bash + env: + STATUS_NAME: ${{ inputs.name }} + STATUS: ${{ inputs.status }} + run: | + set -euo pipefail + + passed=false + if [[ "${STATUS}" == "success" ]]; then + passed=true + fi + + slug="$(printf '%s' "${STATUS_NAME}-${GITHUB_JOB}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9_.-' '-' \ + | sed -e 's/-\{1,\}/-/g' -e 's/^-//' -e 's/-$//')" + status_dir="${RUNNER_TEMP}/test-status-${slug}" + status_file="${status_dir}/${slug}.json" + artifact_name="test-status-${GITHUB_RUN_ATTEMPT}-${slug}" + + mkdir -p "${status_dir}" + jq -n \ + --arg name "${STATUS_NAME}" \ + --arg job_id "${GITHUB_JOB}" \ + --arg status "${STATUS}" \ + --arg run_attempt "${GITHUB_RUN_ATTEMPT}" \ + --argjson passed "${passed}" \ + '{ + name: $name, + job_id: $job_id, + status: $status, + run_attempt: $run_attempt, + passed: $passed + }' > "${status_file}" + + { + echo "artifact-name=${artifact_name}" + echo "status-file=${status_file}" + } >> "${GITHUB_OUTPUT}" + + - name: Upload status + uses: actions/upload-artifact@v6 + with: + name: ${{ steps.write.outputs.artifact-name }} + path: ${{ steps.write.outputs.status-file }} + retention-days: 1 + if-no-files-found: error From 7ca4e8c547269fee081b390839989ee7e0a3cb16 Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 11:18:52 -0700 Subject: [PATCH 3/8] ci: add actionlint config Signed-off-by: Brooke Storm --- .github/actionlint.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..7305595c47 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,9 @@ + +rules: + custom: + - name: "input-names-kebab-case" + description: "Ensure action input names use kebab-case" + # This regex looks for keys in the inputs section of an action metadata file + # It allows lowercase letters, numbers, and hyphens. + pattern: '^inputs\.[a-z0-9-]+$' + message: "input name must be kebab-case (lowercase and hyphens only)" From 10ed341dcfff6844bf4388775a4f9e1aa3e66ada Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 11:21:48 -0700 Subject: [PATCH 4/8] ci: add sha check Signed-off-by: Brooke Storm --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 394980916b..4e329711b6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -54,6 +54,8 @@ jobs: curl -fsSL \ -o "${RUNNER_TEMP}/actionlint.tar.gz" \ "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz" + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ${RUNNER_TEMP}/actionlint.tar.gz" \ + | sha256sum -c - tar -xzf "${RUNNER_TEMP}/actionlint.tar.gz" -C "${RUNNER_TEMP}/actionlint" - name: Lint GitHub workflows and actions shell: bash From 03739c69189b907dd9f292fa5bc856998eebf47d Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 12:17:42 -0700 Subject: [PATCH 5/8] ci: add start-finish markers Signed-off-by: Brooke Storm --- .github/actions/test-status/action.yaml | 44 +++++-- .github/workflows/ci.yaml | 146 +++++++++++++++++++++++- 2 files changed, 175 insertions(+), 15 deletions(-) diff --git a/.github/actions/test-status/action.yaml b/.github/actions/test-status/action.yaml index 2afee4151f..4b32a34d02 100644 --- a/.github/actions/test-status/action.yaml +++ b/.github/actions/test-status/action.yaml @@ -1,13 +1,17 @@ name: Test Status -description: Upload this job's terminal status for the CI status gate +description: Upload this job's start or finish marker for the CI status gate inputs: name: description: Human-readable check name to show in the CI status summary required: true + phase: + description: "Marker phase: start or finish" + required: true status: description: "Pass the job.status context from an if: always() step" - required: true + required: false + default: "" runs: using: composite @@ -17,33 +21,55 @@ runs: shell: bash env: STATUS_NAME: ${{ inputs.name }} + PHASE: ${{ inputs.phase }} STATUS: ${{ inputs.status }} run: | set -euo pipefail - passed=false - if [[ "${STATUS}" == "success" ]]; then - passed=true - fi + case "${PHASE}" in + start) + status="started" + passed=false + ;; + finish) + if [[ -z "${STATUS}" ]]; then + echo "::error::status is required when phase is finish" + exit 1 + fi + status="${STATUS}" + passed=false + if [[ "${status}" == "success" ]]; then + passed=true + fi + ;; + *) + echo "::error::phase must be start or finish" + exit 1 + ;; + esac slug="$(printf '%s' "${STATUS_NAME}-${GITHUB_JOB}" \ | tr '[:upper:]' '[:lower:]' \ | tr -c 'a-z0-9_.-' '-' \ | sed -e 's/-\{1,\}/-/g' -e 's/^-//' -e 's/-$//')" status_dir="${RUNNER_TEMP}/test-status-${slug}" - status_file="${status_dir}/${slug}.json" - artifact_name="test-status-${GITHUB_RUN_ATTEMPT}-${slug}" + status_file="${status_dir}/${slug}-${PHASE}.json" + artifact_name="test-status-${GITHUB_RUN_ATTEMPT}-${slug}-${PHASE}" mkdir -p "${status_dir}" jq -n \ --arg name "${STATUS_NAME}" \ --arg job_id "${GITHUB_JOB}" \ - --arg status "${STATUS}" \ + --arg key "${slug}" \ + --arg phase "${PHASE}" \ + --arg status "${status}" \ --arg run_attempt "${GITHUB_RUN_ATTEMPT}" \ --argjson passed "${passed}" \ '{ name: $name, job_id: $job_id, + key: $key, + phase: $phase, status: $status, run_attempt: $run_attempt, passed: $passed diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4e329711b6..df39cec6a4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,11 @@ jobs: tools: ${{ steps.changes.outputs.tools }} steps: - uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Detect Path Changes + phase: start - uses: ./.github/actions/changes id: changes - name: Record test status @@ -39,6 +44,7 @@ jobs: uses: ./.github/actions/test-status with: name: Detect Path Changes + phase: finish status: ${{ job.status }} actionlint: @@ -47,6 +53,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Run actionlint + phase: start - name: Install actionlint shell: bash run: | @@ -66,6 +77,7 @@ jobs: uses: ./.github/actions/test-status with: name: Run actionlint + phase: finish status: ${{ job.status }} lint: @@ -73,6 +85,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Lint all + phase: start - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -115,6 +132,7 @@ jobs: uses: ./.github/actions/test-status with: name: Lint all + phase: finish status: ${{ job.status }} policy-wasm: @@ -122,6 +140,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Build OPA policy WASM + phase: start - uses: ./.github/actions/build-policy-wasm - name: Upload policy WASM uses: actions/upload-artifact@v6 @@ -134,6 +157,7 @@ jobs: uses: ./.github/actions/test-status with: name: Build OPA policy WASM + phase: finish status: ${{ job.status }} python-unit-test-tools: @@ -150,6 +174,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Python unit tests (tools) + phase: start - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -169,6 +198,7 @@ jobs: uses: ./.github/actions/test-status with: name: Python unit tests (tools) + phase: finish status: ${{ job.status }} python-unit-test: @@ -180,6 +210,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Python unit tests + phase: start - name: Download policy WASM uses: actions/download-artifact@v8 with: @@ -213,6 +248,7 @@ jobs: uses: ./.github/actions/test-status with: name: Python unit tests + phase: finish status: ${{ job.status }} python-integration-test: @@ -222,6 +258,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Python integration tests + phase: start - name: Free disk space uses: ./.github/actions/free-disk-space - name: Download policy WASM @@ -266,6 +307,7 @@ jobs: uses: ./.github/actions/test-status with: name: Python integration tests + phase: finish status: ${{ job.status }} # Build wheels for all packages × python versions. Downstream jobs @@ -286,6 +328,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: ${{ matrix.package }} wheel build (py${{ matrix.python-version }}) + phase: start - name: Download policy WASM if: matrix.package == 'nemo-platform' uses: actions/download-artifact@v8 @@ -315,6 +362,7 @@ jobs: uses: ./.github/actions/test-status with: name: ${{ matrix.package }} wheel build (py${{ matrix.python-version }}) + phase: finish status: ${{ job.status }} # Test each wheel: install from the artifact and run basic CLI / @@ -334,6 +382,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: ${{ matrix.package }} wheel build + test (py${{ matrix.python-version }}) + phase: start - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -399,6 +452,7 @@ jobs: uses: ./.github/actions/test-status with: name: ${{ matrix.package }} wheel build + test (py${{ matrix.python-version }}) + phase: finish status: ${{ job.status }} # E2E tests: start services from the workspace venv and run the e2e @@ -411,6 +465,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Python e2e tests + phase: start - name: Download policy WASM uses: actions/download-artifact@v8 with: @@ -450,6 +509,7 @@ jobs: uses: ./.github/actions/test-status with: name: Python e2e tests + phase: finish status: ${{ job.status }} # Required-check pin: branch protection should reference this aggregator @@ -465,6 +525,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: Wheel build + test + phase: start - name: Check matrix result shell: bash env: @@ -487,6 +552,7 @@ jobs: uses: ./.github/actions/test-status with: name: Wheel build + test + phase: finish status: ${{ job.status }} benchmark-guardrails: @@ -499,6 +565,11 @@ jobs: uses: actions/checkout@v6 with: path: nemo-platform + - name: Record test start + uses: ./nemo-platform/.github/actions/test-status + with: + name: Guardrails plugin benchmark + phase: start - name: Checkout NeMo-Guardrails uses: actions/checkout@v6 with: @@ -537,6 +608,7 @@ jobs: uses: ./nemo-platform/.github/actions/test-status with: name: Guardrails plugin benchmark + phase: finish status: ${{ job.status }} coverage-comment: @@ -597,6 +669,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Record test start + uses: ./.github/actions/test-status + with: + name: OPA policy tests + phase: start - name: Install OPA 1.8.0 run: | curl -L -o opa https://github.com/open-policy-agent/opa/releases/download/v1.8.0/opa_linux_amd64_static @@ -610,6 +687,7 @@ jobs: uses: ./.github/actions/test-status with: name: OPA policy tests + phase: finish status: ${{ job.status }} # Required-check pin: branch protection should require this final aggregate @@ -685,12 +763,21 @@ jobs: failed=0 total=0 + keys_file="${RUNNER_TEMP}/test-status-keys.txt" + + declare -A names + declare -A started + declare -A finished + declare -A finish_passed + declare -A finish_status + + : > "${keys_file}" { echo "## CI status" echo - echo "| Job | Passed | Status |" - echo "| --- | --- | --- |" + echo "| Job | Started | Finished | Passed | Status |" + echo "| --- | --- | --- | --- | --- |" } >> "${GITHUB_STEP_SUMMARY}" while IFS= read -r -d '' status_file; do @@ -698,19 +785,66 @@ jobs: continue fi + key="$(jq -r '.key' "${status_file}")" name="$(jq -r '.name' "${status_file}")" + phase="$(jq -r '.phase' "${status_file}")" passed="$(jq -r '.passed' "${status_file}")" status="$(jq -r '.status' "${status_file}")" total=$((total + 1)) - printf '| %s | %s | %s |\n' \ - "${name}" "${passed}" "${status}" >> "${GITHUB_STEP_SUMMARY}" + names["${key}"]="${name}" + echo "${key}" >> "${keys_file}" + + case "${phase}" in + start) + started["${key}"]=true + ;; + finish) + finished["${key}"]=true + finish_passed["${key}"]="${passed}" + finish_status["${key}"]="${status}" + ;; + *) + echo "::error title=CI status::${name} has invalid phase ${phase}" + failed=1 + ;; + esac + done < <(find test-status -type f -name '*.json' -print0 | sort -z) + + while IFS= read -r key; do + [[ -n "${key}" ]] || continue - if [[ "${passed}" != "true" ]]; then + name="${names[$key]}" + has_start=false + has_finish=false + passed=false + status="missing finish" + + if [[ -n "${started[$key]+x}" ]]; then + has_start=true + fi + if [[ -n "${finished[$key]+x}" ]]; then + has_finish=true + status="${finish_status[$key]}" + fi + if [[ "${has_start}" == "true" && "${has_finish}" == "true" && "${finish_passed[$key]:-false}" == "true" ]]; then + passed=true + fi + + printf '| %s | %s | %s | %s | %s |\n' \ + "${name}" "${has_start}" "${has_finish}" "${passed}" "${status}" >> "${GITHUB_STEP_SUMMARY}" + + if [[ "${has_start}" != "true" ]]; then + echo "::error title=CI status::${name} has a finish marker without a start marker" + failed=1 + elif [[ "${has_finish}" != "true" ]]; then + echo "::error title=CI status::${name} started but did not upload a finish marker" + failed=1 + elif [[ "${passed}" != "true" ]]; then echo "::error title=CI status::${name} concluded ${status}" failed=1 fi - done < <(find test-status -type f -name '*.json' -print0 | sort -z) + done < <(sort -u "${keys_file}") if [[ "${total}" -eq 0 ]]; then echo "::error title=CI status::No test status artifacts were found" From a88f267e00f9631a5ac7f8e808aaf8f34e31ba56 Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 13:21:24 -0700 Subject: [PATCH 6/8] ci: simplify and extend the approach Signed-off-by: Brooke Storm --- .github/actions/test-status/action.yaml | 89 ----- .github/workflows/ci.yaml | 429 ++++-------------------- 2 files changed, 60 insertions(+), 458 deletions(-) delete mode 100644 .github/actions/test-status/action.yaml diff --git a/.github/actions/test-status/action.yaml b/.github/actions/test-status/action.yaml deleted file mode 100644 index 4b32a34d02..0000000000 --- a/.github/actions/test-status/action.yaml +++ /dev/null @@ -1,89 +0,0 @@ -name: Test Status -description: Upload this job's start or finish marker for the CI status gate - -inputs: - name: - description: Human-readable check name to show in the CI status summary - required: true - phase: - description: "Marker phase: start or finish" - required: true - status: - description: "Pass the job.status context from an if: always() step" - required: false - default: "" - -runs: - using: composite - steps: - - name: Write status - id: write - shell: bash - env: - STATUS_NAME: ${{ inputs.name }} - PHASE: ${{ inputs.phase }} - STATUS: ${{ inputs.status }} - run: | - set -euo pipefail - - case "${PHASE}" in - start) - status="started" - passed=false - ;; - finish) - if [[ -z "${STATUS}" ]]; then - echo "::error::status is required when phase is finish" - exit 1 - fi - status="${STATUS}" - passed=false - if [[ "${status}" == "success" ]]; then - passed=true - fi - ;; - *) - echo "::error::phase must be start or finish" - exit 1 - ;; - esac - - slug="$(printf '%s' "${STATUS_NAME}-${GITHUB_JOB}" \ - | tr '[:upper:]' '[:lower:]' \ - | tr -c 'a-z0-9_.-' '-' \ - | sed -e 's/-\{1,\}/-/g' -e 's/^-//' -e 's/-$//')" - status_dir="${RUNNER_TEMP}/test-status-${slug}" - status_file="${status_dir}/${slug}-${PHASE}.json" - artifact_name="test-status-${GITHUB_RUN_ATTEMPT}-${slug}-${PHASE}" - - mkdir -p "${status_dir}" - jq -n \ - --arg name "${STATUS_NAME}" \ - --arg job_id "${GITHUB_JOB}" \ - --arg key "${slug}" \ - --arg phase "${PHASE}" \ - --arg status "${status}" \ - --arg run_attempt "${GITHUB_RUN_ATTEMPT}" \ - --argjson passed "${passed}" \ - '{ - name: $name, - job_id: $job_id, - key: $key, - phase: $phase, - status: $status, - run_attempt: $run_attempt, - passed: $passed - }' > "${status_file}" - - { - echo "artifact-name=${artifact_name}" - echo "status-file=${status_file}" - } >> "${GITHUB_OUTPUT}" - - - name: Upload status - uses: actions/upload-artifact@v6 - with: - name: ${{ steps.write.outputs.artifact-name }} - path: ${{ steps.write.outputs.status-file }} - retention-days: 1 - if-no-files-found: error diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index df39cec6a4..e9b3434484 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,33 +31,16 @@ jobs: web-studio: ${{ steps.changes.outputs.web-studio }} tools: ${{ steps.changes.outputs.tools }} steps: - - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Detect Path Changes - phase: start + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/actions/changes id: changes - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Detect Path Changes - phase: finish - status: ${{ job.status }} actionlint: name: Run actionlint runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Run actionlint - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install actionlint shell: bash run: | @@ -72,30 +55,18 @@ jobs: shell: bash run: | "${RUNNER_TEMP}/actionlint/actionlint" - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Run actionlint - phase: finish - status: ${{ job.status }} lint: name: Lint all runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Lint all - phase: start + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: "3.11" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - name: Install pnpm via corepack @@ -113,7 +84,7 @@ jobs: CI_PROJECT_DIR: ${{ github.workspace }} - name: Upload lint artifacts if: failure() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: lint-artifacts retention-days: 7 @@ -127,38 +98,19 @@ jobs: python-sdk-lint/ web/packages/sdk/generated/ diff.txt - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Lint all - phase: finish - status: ${{ job.status }} policy-wasm: name: Build OPA policy WASM runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Build OPA policy WASM - phase: start + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/actions/build-policy-wasm - name: Upload policy WASM - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: policy-wasm retention-days: 1 path: services/core/auth/src/nmp/core/auth/assets/policy.wasm - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Build OPA policy WASM - phase: finish - status: ${{ job.status }} python-unit-test-tools: name: Python unit tests (tools) @@ -173,14 +125,9 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Python unit tests (tools) - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.11" enable-cache: true @@ -188,18 +135,11 @@ jobs: run: uv run pytest tools -v --junit-xml=report.xml - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-unit-test-tools-results retention-days: 30 path: report.xml - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Python unit tests (tools) - phase: finish - status: ${{ job.status }} python-unit-test: name: Python unit tests @@ -209,19 +149,14 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Python unit tests - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download policy WASM - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: policy-wasm path: services/core/auth/src/nmp/core/auth/assets - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.11" enable-cache: true @@ -234,7 +169,7 @@ jobs: _TYPER_FORCE_DISABLE_TERMINAL: "1" - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-unit-test-results retention-days: 30 @@ -243,13 +178,6 @@ jobs: coverage.xml coverage.json coverage-html/ - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Python unit tests - phase: finish - status: ${{ job.status }} python-integration-test: name: Python integration tests @@ -257,26 +185,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Python integration tests - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Free disk space uses: ./.github/actions/free-disk-space - name: Download policy WASM - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: policy-wasm path: services/core/auth/src/nmp/core/auth/assets - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.11" enable-cache: true - name: Log in to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ vars.AIRE_DOCKERHUB_USERNAME }} password: ${{ secrets.AIRE_DOCKERHUB_ACCESS_TOKEN }} @@ -294,7 +217,7 @@ jobs: _TYPER_FORCE_DISABLE_TERMINAL: "1" - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-integration-test-results retention-days: 30 @@ -302,13 +225,6 @@ jobs: report.xml coverage.xml coverage.json - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Python integration tests - phase: finish - status: ${{ job.status }} # Build wheels for all packages × python versions. Downstream jobs # (wheel-test, python-e2e-test) download these artifacts instead @@ -327,15 +243,10 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: ${{ matrix.package }} wheel build (py${{ matrix.python-version }}) - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download policy WASM if: matrix.package == 'nemo-platform' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: policy-wasm path: services/core/auth/src/nmp/core/auth/assets @@ -351,19 +262,12 @@ jobs: nightly-timestamp: "19700101000000" python-version: ${{ matrix.python-version }} - name: Upload wheel - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.package }}-wheel-py${{ matrix.python-version }} path: ${{ steps.build.outputs.wheel-path }} retention-days: 7 if-no-files-found: error - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: ${{ matrix.package }} wheel build (py${{ matrix.python-version }}) - phase: finish - status: ${{ job.status }} # Test each wheel: install from the artifact and run basic CLI / # import checks. @@ -381,19 +285,14 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: ${{ matrix.package }} wheel build + test (py${{ matrix.python-version }}) - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: ${{ matrix.python-version }} enable-cache: true - name: Download wheel - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ matrix.package }}-wheel-py${{ matrix.python-version }} path: ${{ runner.temp }}/wheelcheck @@ -447,13 +346,6 @@ jobs: import nemo_platform_plugin.commands print('nemo_platform_plugin', getattr(nemo_platform_plugin, '__version__', '')) " - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: ${{ matrix.package }} wheel build + test (py${{ matrix.python-version }}) - phase: finish - status: ${{ job.status }} # E2E tests: start services from the workspace venv and run the e2e # suite against the real process. @@ -464,19 +356,14 @@ jobs: timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Python e2e tests - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download policy WASM - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: policy-wasm path: services/core/auth/src/nmp/core/auth/assets - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.13" enable-cache: true @@ -497,63 +384,13 @@ jobs: echo "::endgroup::" - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-e2e-test-results retention-days: 30 path: | report.xml ${{ runner.temp }}/e2e-services-logs/ - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Python e2e tests - phase: finish - status: ${{ job.status }} - - # Required-check pin: branch protection should reference this aggregator - # rather than the per-row matrix jobs, so the matrix can grow or shrink - # without admin intervention. Skipped counts as pass. - wheel-test-aggregate: - name: Wheel build + test - needs: [wheel-test] - if: always() - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: Wheel build + test - phase: start - - name: Check matrix result - shell: bash - env: - MATRIX_RESULT: ${{ needs.wheel-test.result }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - case "${MATRIX_RESULT}" in - success|skipped) - echo "wheel-test matrix: ${MATRIX_RESULT}" - ;; - *) - echo "::error::wheel-test matrix concluded ${MATRIX_RESULT}" - echo "::error::see per-row results: ${RUN_URL}" - exit 1 - ;; - esac - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: Wheel build + test - phase: finish - status: ${{ job.status }} benchmark-guardrails: name: Guardrails plugin benchmark @@ -562,21 +399,16 @@ jobs: timeout-minutes: 30 steps: - name: Checkout nemo-platform - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: path: nemo-platform - - name: Record test start - uses: ./nemo-platform/.github/actions/test-status - with: - name: Guardrails plugin benchmark - phase: start - name: Checkout NeMo-Guardrails - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: NVIDIA/NeMo-Guardrails path: NeMo-Guardrails - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: # Point setup-uv at the nemo-platform checkout so it picks up the # repo's `required-version` constraint from pyproject.toml. Other @@ -597,19 +429,12 @@ jobs: _TYPER_FORCE_DISABLE_TERMINAL: "1" - name: Upload benchmark artifacts if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: benchmark-guardrails-results retention-days: 30 path: | nemo-platform/plugins/nemo-guardrails/benchmarks/artifacts/runs/ - - name: Record test status - if: always() - uses: ./nemo-platform/.github/actions/test-status - with: - name: Guardrails plugin benchmark - phase: finish - status: ${{ job.status }} coverage-comment: name: Post coverage comment @@ -622,13 +447,13 @@ jobs: steps: - name: Download unit test coverage continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-unit-test-results path: unit-coverage - name: Download integration test coverage continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-integration-test-results path: integration-coverage @@ -668,12 +493,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 - - name: Record test start - uses: ./.github/actions/test-status - with: - name: OPA policy tests - phase: start + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install OPA 1.8.0 run: | curl -L -o opa https://github.com/open-policy-agent/opa/releases/download/v1.8.0/opa_linux_amd64_static @@ -682,176 +502,47 @@ jobs: - name: Run test run: | ./opa test services/core/auth/src/nmp/core/auth/app/policies services/core/auth/src/nmp/core/auth/app/policy_tests services/core/auth/src/nmp/core/auth/assets/static-authz.yaml -v - - name: Record test status - if: always() - uses: ./.github/actions/test-status - with: - name: OPA policy tests - phase: finish - status: ${{ job.status }} - # Required-check pin: branch protection should require this final aggregate - # job. Jobs opt in by uploading a test-status artifact; skipped jobs do not - # contribute to the aggregate. + # Required-check pin: branch protection should require this final aggregate. + # Skipped jobs count as pass so conditional jobs can remain optional. ci-status: name: CI status + needs: + - changes + - actionlint + - lint + - policy-wasm + - python-unit-test-tools + - python-unit-test + - python-integration-test + - wheel-build + - wheel-test + - python-e2e-test + - benchmark-guardrails + - opa-policy-test if: always() runs-on: ubuntu-latest - timeout-minutes: 120 permissions: - actions: read contents: read steps: - - name: Checkout code - uses: actions/checkout@v6 - - name: Wait for contributing jobs + - name: Check all dependent jobs shell: bash env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - # Exclude this gate and jobs that intentionally depend on it. - excluded_jobs=( - "CI status" - "Notify CI consumer" - ) - - is_excluded() { - local job_name="$1" - local excluded_job - for excluded_job in "${excluded_jobs[@]}"; do - if [[ "${job_name}" == "${excluded_job}" ]]; then - return 0 - fi - done - return 1 - } - - while true; do - mapfile -t unfinished_jobs < <( - gh api --paginate \ - "/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs?per_page=100" \ - --jq '.jobs[] | select(.status != "completed") | .name' - ) - - waiting_jobs=() - for job_name in "${unfinished_jobs[@]}"; do - if ! is_excluded "${job_name}"; then - waiting_jobs+=("${job_name}") - fi - done - - if [[ "${#waiting_jobs[@]}" -eq 0 ]]; then - break - fi - - echo "Waiting for ${#waiting_jobs[@]} job(s): ${waiting_jobs[*]}" - sleep 30 - done - - - name: Download test statuses - uses: actions/download-artifact@v8 - with: - pattern: test-status-${{ github.run_attempt }}-* - path: test-status - merge-multiple: true - - name: Check test statuses - shell: bash + NEEDS_JSON: ${{ toJson(needs) }} run: | set -euo pipefail - failed=0 - total=0 - keys_file="${RUNNER_TEMP}/test-status-keys.txt" - - declare -A names - declare -A started - declare -A finished - declare -A finish_passed - declare -A finish_status - - : > "${keys_file}" - { echo "## CI status" echo - echo "| Job | Started | Finished | Passed | Status |" - echo "| --- | --- | --- | --- | --- |" + echo "| Job | Result |" + echo "| --- | --- |" + printf '%s\n' "${NEEDS_JSON}" \ + | jq -r 'to_entries[] | "| \(.key) | \(.value.result) |"' } >> "${GITHUB_STEP_SUMMARY}" - while IFS= read -r -d '' status_file; do - if [[ "$(jq -r '.run_attempt' "${status_file}")" != "${GITHUB_RUN_ATTEMPT}" ]]; then - continue - fi - - key="$(jq -r '.key' "${status_file}")" - name="$(jq -r '.name' "${status_file}")" - phase="$(jq -r '.phase' "${status_file}")" - passed="$(jq -r '.passed' "${status_file}")" - status="$(jq -r '.status' "${status_file}")" - total=$((total + 1)) - - names["${key}"]="${name}" - echo "${key}" >> "${keys_file}" - - case "${phase}" in - start) - started["${key}"]=true - ;; - finish) - finished["${key}"]=true - finish_passed["${key}"]="${passed}" - finish_status["${key}"]="${status}" - ;; - *) - echo "::error title=CI status::${name} has invalid phase ${phase}" - failed=1 - ;; - esac - done < <(find test-status -type f -name '*.json' -print0 | sort -z) - - while IFS= read -r key; do - [[ -n "${key}" ]] || continue - - name="${names[$key]}" - has_start=false - has_finish=false - passed=false - status="missing finish" - - if [[ -n "${started[$key]+x}" ]]; then - has_start=true - fi - if [[ -n "${finished[$key]+x}" ]]; then - has_finish=true - status="${finish_status[$key]}" - fi - if [[ "${has_start}" == "true" && "${has_finish}" == "true" && "${finish_passed[$key]:-false}" == "true" ]]; then - passed=true - fi - - printf '| %s | %s | %s | %s | %s |\n' \ - "${name}" "${has_start}" "${has_finish}" "${passed}" "${status}" >> "${GITHUB_STEP_SUMMARY}" - - if [[ "${has_start}" != "true" ]]; then - echo "::error title=CI status::${name} has a finish marker without a start marker" - failed=1 - elif [[ "${has_finish}" != "true" ]]; then - echo "::error title=CI status::${name} started but did not upload a finish marker" - failed=1 - elif [[ "${passed}" != "true" ]]; then - echo "::error title=CI status::${name} concluded ${status}" - failed=1 - fi - done < <(sort -u "${keys_file}") - - if [[ "${total}" -eq 0 ]]; then - echo "::error title=CI status::No test status artifacts were found" - exit 1 - fi - - exit "${failed}" + printf '%s\n' "${NEEDS_JSON}" \ + | jq -e 'to_entries | map(.value.result) | all(. as $result | ["success", "skipped"] | any($result == .))' notify-ci-consumer: name: Notify CI consumer From a2f5795e0d4a69b8518a3df384f4762e64c0b1d4 Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 14:31:10 -0700 Subject: [PATCH 7/8] ci: move require-nvskills job into PR workflow Signed-off-by: Brooke Storm --- .github/workflows/ci.yaml | 50 +++++++++++++++++++ .github/workflows/require-nvskills-ci.yml | 60 ----------------------- 2 files changed, 50 insertions(+), 60 deletions(-) delete mode 100644 .github/workflows/require-nvskills-ci.yml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e9b3434484..d3f7049806 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -503,6 +503,55 @@ jobs: run: | ./opa test services/core/auth/src/nmp/core/auth/app/policies services/core/auth/src/nmp/core/auth/app/policy_tests services/core/auth/src/nmp/core/auth/assets/static-authz.yaml -v + require-nvskills: + runs-on: ubuntu-latest + steps: + - name: Require trusted NVSkills signature for skills changes + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + SIGNATURE_ACTOR: ${{ vars.NVSKILLS_SIGNATURE_PUSH_ACTOR || 'svc-nvskills-signing' }} + SIGNATURE_TITLE: ${{ vars.NVSKILLS_SIGNATURE_COMMIT_TITLE || 'Attach NVSkills validation signatures' }} + ONBOARDING_DOC: https://nvidia.atlassian.net/wiki/spaces/GAIT/pages/3483240468/Github+First+-+Outbound+Repos+Onboarding+doc+-+NVCARPS#Review-Internal-Pipeline-Logs + with: + script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + + const touchedSkills = files.some((file) => file.filename.startsWith('skills/')); + if (!touchedSkills) { + core.info('No files under skills/ changed.'); + return; + } + + const commit = await github.rest.repos.getCommit({ + owner, + repo, + ref: pr.head.sha, + }); + + const actor = commit.data.author?.login || ''; + const title = commit.data.commit.message.split('\n')[0]; + + const okActor = actor === process.env.SIGNATURE_ACTOR; + const okTitle = title.startsWith(process.env.SIGNATURE_TITLE); + + if (!okActor || !okTitle) { + core.setFailed( + 'Files under skills/ changed in this PR, but HEAD is not the trusted NVSkills signature commit. ' + + 'Ask a maintainer/admin to comment /nvskills-ci. If new skills/ content was pushed after signing, rerun /nvskills-ci. ' + + `Expected HEAD GitHub author "${process.env.SIGNATURE_ACTOR}" and commit title prefix "${process.env.SIGNATURE_TITLE}". ` + + `See ${process.env.ONBOARDING_DOC}.` + ); + } + # Required-check pin: branch protection should require this final aggregate. # Skipped jobs count as pass so conditional jobs can remain optional. ci-status: @@ -515,6 +564,7 @@ jobs: - python-unit-test-tools - python-unit-test - python-integration-test + - require-nvskills - wheel-build - wheel-test - python-e2e-test diff --git a/.github/workflows/require-nvskills-ci.yml b/.github/workflows/require-nvskills-ci.yml deleted file mode 100644 index 3dd82788f2..0000000000 --- a/.github/workflows/require-nvskills-ci.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Require NVSkills CI for skill changes - -on: - pull_request: - branches: [main] - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: read - -jobs: - require-nvskills-ci: - runs-on: ubuntu-latest - steps: - - name: Require trusted NVSkills signature for skills changes - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - env: - SIGNATURE_ACTOR: ${{ vars.NVSKILLS_SIGNATURE_PUSH_ACTOR || 'svc-nvskills-signing' }} - SIGNATURE_TITLE: ${{ vars.NVSKILLS_SIGNATURE_COMMIT_TITLE || 'Attach NVSkills validation signatures' }} - ONBOARDING_DOC: https://nvidia.atlassian.net/wiki/spaces/GAIT/pages/3483240468/Github+First+-+Outbound+Repos+Onboarding+doc+-+NVCARPS#Review-Internal-Pipeline-Logs - with: - script: | - const pr = context.payload.pull_request; - const owner = context.repo.owner; - const repo = context.repo.repo; - - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, - repo, - pull_number: pr.number, - per_page: 100, - }); - - const touchedSkills = files.some((file) => file.filename.startsWith('skills/')); - if (!touchedSkills) { - core.info('No files under skills/ changed.'); - return; - } - - const commit = await github.rest.repos.getCommit({ - owner, - repo, - ref: pr.head.sha, - }); - - const actor = commit.data.author?.login || ''; - const title = commit.data.commit.message.split('\n')[0]; - - const okActor = actor === process.env.SIGNATURE_ACTOR; - const okTitle = title.startsWith(process.env.SIGNATURE_TITLE); - - if (!okActor || !okTitle) { - core.setFailed( - 'Files under skills/ changed in this PR, but HEAD is not the trusted NVSkills signature commit. ' + - 'Ask a maintainer/admin to comment /nvskills-ci. If new skills/ content was pushed after signing, rerun /nvskills-ci. ' + - `Expected HEAD GitHub author "${process.env.SIGNATURE_ACTOR}" and commit title prefix "${process.env.SIGNATURE_TITLE}". ` + - `See ${process.env.ONBOARDING_DOC}.` - ); - } From 5ef6046f278494c25d68e6c7c076da2520ba9ded Mon Sep 17 00:00:00 2001 From: Brooke Storm Date: Tue, 9 Jun 2026 14:40:10 -0700 Subject: [PATCH 8/8] ci: comment out the integration tests job in case people still want that to not block Signed-off-by: Brooke Storm --- .github/workflows/ci.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d3f7049806..804e924eab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -563,7 +563,8 @@ jobs: - policy-wasm - python-unit-test-tools - python-unit-test - - python-integration-test + # Enable if you want this required + # - python-integration-test - require-nvskills - wheel-build - wheel-test