docs(#856): architecture plan for controller resolution refactor #2374
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
| # Actions are pinned to commit SHAs (not version tags) to prevent supply chain attacks. | |
| # Version comments (e.g., "# v6") allow Dependabot to track and update to newer versions. | |
| name: Tests | |
| on: | |
| workflow_dispatch: | |
| pull_request: | |
| push: | |
| workflow_call: | |
| jobs: | |
| security: | |
| name: Security Scan | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| enable-cache: true | |
| - name: Install bandit | |
| run: uv tool install bandit[toml] | |
| - name: Run bandit security scan | |
| run: bandit -c pyproject.toml -r nac_test/ -ll -f json -o bandit-security-report.json | |
| - name: Upload security report | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| if: always() | |
| with: | |
| name: bandit-security-report | |
| path: bandit-security-report.json | |
| lint: | |
| name: Lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: write # Required for dependabot to push lock file updates | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| # For fork PRs, use merge commit; for same-repo PRs, use head ref | |
| ref: ${{ github.event.pull_request.head.sha || github.head_ref }} | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| enable-cache: true | |
| - name: Install mypy | |
| run: uv tool install mypy | |
| - name: Verify uv.lock is up to date | |
| if: github.actor != 'dependabot[bot]' | |
| run: | | |
| set -euo pipefail | |
| uv lock | |
| git diff --exit-code uv.lock | |
| - name: Update lock file | |
| # Only run for dependabot on same-repo PRs (not forks) | |
| if: github.actor == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository | |
| run: | | |
| set -euo pipefail | |
| echo "=== uv lock diagnostics ===" | |
| pwd | |
| ls -la | |
| test -f pyproject.toml && echo "pyproject.toml: present" || (echo "pyproject.toml: MISSING" >&2; exit 1) | |
| uv --version | |
| echo "actor=$GITHUB_ACTOR ref=$GITHUB_REF head_ref=${GITHUB_HEAD_REF:-<unset>}" | |
| uv lock | |
| if [[ -n $(git status --porcelain uv.lock) ]]; then | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add uv.lock | |
| git commit -m "chore: update uv.lock [dependabot skip]" | |
| git push origin HEAD:refs/heads/${{ github.head_ref }} | |
| fi | |
| - name: Check License Headers | |
| run: bash scripts/license-headers.sh | |
| - name: Pre-commit Checks | |
| uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 | |
| test-linux: | |
| name: Tests (Linux) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| strategy: | |
| matrix: | |
| python: | |
| - "3.10" | |
| - "3.11" | |
| - "3.12" | |
| - "3.13" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| enable-cache: true | |
| - name: Set up Python ${{ matrix.python }} | |
| run: uv python install ${{ matrix.python }} | |
| - name: Test | |
| run: | | |
| uv sync --frozen --extra dev | |
| # Run tests in parallel (--dist loadscope keeps test classes together, this is critical for e2e tests) | |
| uv run pytest -n auto --dist loadscope tests/ | |
| test-windows: | |
| name: Tests (Windows) | |
| runs-on: windows-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| strategy: | |
| matrix: | |
| python: | |
| - "3.10" | |
| - "3.11" | |
| - "3.12" | |
| - "3.13" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| enable-cache: true | |
| - name: Set up Python ${{ matrix.python }} | |
| run: uv python install ${{ matrix.python }} | |
| - name: Test | |
| run: | | |
| uv sync --frozen --extra dev | |
| # Explicit file list: Windows shell doesn't expand globs, and other integration | |
| # tests import pyats which isn't available on Windows | |
| uv run pytest -m windows -n auto --dist loadscope tests/e2e tests/integration/test_cli_basic.py tests/integration/test_cli_rendering.py tests/integration/test_cli_ordering.py tests/integration/test_cli_extra_args.py | |
| uv run nac-test --version | |
| test-uv-tool: | |
| name: Tests (uv tool install) | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| strategy: | |
| matrix: | |
| os: [ubuntu-latest, windows-latest] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| enable-cache: true | |
| - name: Set up Python 3.12 | |
| run: uv python install 3.12 | |
| - name: Install nac-test via uv tool install | |
| run: uv tool install --python 3.12 . | |
| - name: Smoke test uv tool install (Linux) | |
| # Smoke-tests the render→pabot→robot path in a clean uv tool env (no dev | |
| # venv on PATH), catching regressions like #718. PyATS is not covered here | |
| # as it requires a live API endpoint — see #722. | |
| if: runner.os == 'Linux' | |
| run: | | |
| TMPDIR=$(mktemp -d) | |
| nac-test -d tests/integration/fixtures/data -t tests/integration/fixtures/templates -o "$TMPDIR" | |
| - name: Smoke test uv tool install (Windows) | |
| if: runner.os == 'Windows' | |
| env: | |
| PYTHONUTF8: 1 # CI stdout is piped/captured → falls back to cp1252 (#723) | |
| run: | | |
| $TMPDIR = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } | |
| nac-test -d tests/integration/fixtures/data -t tests/integration/fixtures/templates -o "$TMPDIR" | |
| shell: pwsh | |
| notification: | |
| name: Notification | |
| if: always() && github.event_name != 'pull_request' && github.event_name != 'workflow_call' && github.actor != 'dependabot[bot]' | |
| needs: [security, lint, test-linux, test-windows, test-uv-tool] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: {} | |
| steps: | |
| - name: Check Job Success | |
| run: | | |
| if [ ${{ needs.security.result }} == 'success' ] && [ ${{ needs.lint.result }} == 'success' ] && [ ${{ needs.test-linux.result }} == 'success' ] && [ ${{ needs.test-windows.result }} == 'success' ] && [ ${{ needs.test-uv-tool.result }} == 'success' ]; then | |
| echo "All jobs succeeded" | |
| echo "JOB_SUCCESS=success" >> $GITHUB_ENV | |
| else | |
| echo "Not all jobs succeeded" | |
| echo "JOB_SUCCESS=fail" >> $GITHUB_ENV | |
| fi | |
| id: print_status | |
| - name: Sanitize notification variables | |
| env: | |
| # Pass through env to avoid shell injection from server-side expression expansion | |
| RAW_COMMIT_MSG: ${{ github.event.head_commit.message }} | |
| RAW_PR_TITLE: ${{ github.event.pull_request.title }} | |
| run: | | |
| # Truncate to stay within Webex message size limits (500 chars for commit | |
| # messages, 200 chars for PR titles are generous practical limits) | |
| COMMIT_MSG=$(echo "$RAW_COMMIT_MSG" | head -c 500) | |
| PR_TITLE=$(echo "$RAW_PR_TITLE" | head -c 200) | |
| { | |
| echo "SAFE_COMMIT_MSG<<EOF" | |
| echo "$COMMIT_MSG" | |
| echo "EOF" | |
| echo "SAFE_PR_TITLE<<EOF" | |
| echo "$PR_TITLE" | |
| echo "EOF" | |
| } >> "$GITHUB_ENV" | |
| # Using direct curl to Webex API instead of a GitHub Action because: | |
| # - qsnyder/action-wxt is unmaintained (last commit 2022, no releases) | |
| # - Direct API call has no third-party action dependency to maintain | |
| # - Gives full control over error handling and message format | |
| - name: Webex Notification | |
| if: always() | |
| env: | |
| # Pass sanitized env vars and static context values through env to avoid | |
| # server-side expression expansion inside the shell script | |
| JOB_SUCCESS: ${{ env.JOB_SUCCESS }} | |
| SAFE_COMMIT_MSG: ${{ env.SAFE_COMMIT_MSG }} | |
| SAFE_PR_TITLE: ${{ env.SAFE_PR_TITLE }} | |
| WEBEX_TOKEN: ${{ secrets.WEBEX_TOKEN }} | |
| WEBEX_ROOM_ID: ${{ secrets.WEBEX_ROOM_ID }} | |
| COMMIT_URL: ${{ github.event.head_commit.url }} | |
| AUTHOR: ${{ github.event.sender.login }} | |
| REF: ${{ github.ref }} | |
| HEAD_REF: ${{ github.head_ref }} | |
| EVENT: ${{ github.event_name }} | |
| REPO: ${{ github.repository }} | |
| RUN_NUM: ${{ github.run_number }} | |
| RUN_ID: ${{ github.run_id }} | |
| run: | | |
| # jq -n --arg handles all JSON escaping; avoids double-encoding from jq -Rs | |
| PAYLOAD=$(jq -n \ | |
| --arg room_id "$WEBEX_ROOM_ID" \ | |
| --arg status "$JOB_SUCCESS" \ | |
| --arg repo "$REPO" \ | |
| --arg run_num "$RUN_NUM" \ | |
| --arg run_url "https://github.com/$REPO/actions/runs/$RUN_ID" \ | |
| --arg commit_msg "$SAFE_COMMIT_MSG" \ | |
| --arg commit_url "$COMMIT_URL" \ | |
| --arg pr_title "$SAFE_PR_TITLE" \ | |
| --arg author "$AUTHOR" \ | |
| --arg ref "$REF" \ | |
| --arg head_ref "$HEAD_REF" \ | |
| --arg event "$EVENT" \ | |
| '{roomId: $room_id, markdown: ("[**[\($status)] \($repo) #\($run_num)**](\($run_url))\n* Commit: [\($commit_msg)](\($commit_url))\($pr_title)\n* Author: \($author)\n* Branch: \($ref) \($head_ref)\n* Event: \($event)")}') | |
| # --fail-with-body: exit non-zero on HTTP 4xx/5xx so the || warning fires | |
| curl --silent --fail-with-body --max-time 30 \ | |
| --request POST \ | |
| --url https://webexapis.com/v1/messages \ | |
| --header "Authorization: Bearer $WEBEX_TOKEN" \ | |
| --header "Content-Type: application/json" \ | |
| --data "$PAYLOAD" \ | |
| || echo "::warning::Webex notification failed" |