feat(skills): add /dso:prioritize-epics for backlog reprioritization #922
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
| # Digital Service Orchestra (DSO) — Continuous Integration | |
| # Runs shellcheck, Python linting, and plugin tests. | |
| name: CI | |
| on: | |
| push: | |
| branches: | |
| - main | |
| - 'feature/**' | |
| - 'bugfix/**' | |
| - 'epic/**' | |
| - 'exp/**' | |
| pull_request: | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # ── Path-change detection ─────────────────────────────────── | |
| # Mirrors the local review-gate skip logic by invoking | |
| # plugins/dso/scripts/skip-review-check.sh — the SAME script used by | |
| # COMMIT-WORKFLOW.md Step 0.5 to decide whether a local commit can skip | |
| # review. This eliminates the divergence that allowed PR #66 to merge | |
| # silently: the prior `.md`-blanket exclude let CLAUDE.md and skill .md | |
| # changes skip tests entirely, even though those files affect agent | |
| # behavior and ARE classified as reviewable by the local gate. | |
| # | |
| # skip-review-check.sh classifies a file list (one file per line on stdin): | |
| # exit 0 → SKIP_REVIEW=true (all files allowlisted, e.g. tickets, images, | |
| # lockfiles); CI can skip downstream tests. | |
| # exit 1 → SKIP_REVIEW=false (at least one reviewable file: CLAUDE.md, | |
| # hooks/, skills/, docs/workflows/, or any | |
| # file not matched by review-gate-allowlist.conf); | |
| # CI MUST run downstream tests. | |
| # | |
| # DSO_FORCE_LOCAL_REVIEW=1 disables skip-review-check.sh's | |
| # enforcement.strategy=ci short-circuit (which is intended for the local | |
| # pipeline, NOT for CI itself). | |
| # | |
| # Bug f776-d7ef: PR #66 merged with green CI because the prior `.md`-only | |
| # filter classified its 8 .md changes as code_changed=false. Hook/Script/ | |
| # Python tests then "ran" in 4-5s (no-op) and reported SUCCESS — but the | |
| # underlying tests would have failed (and did, on the post-merge push event). | |
| changes: | |
| name: Detect Changed Paths | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 2 | |
| outputs: | |
| code_changed: ${{ steps.filter.outputs.code_changed }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Detect reviewable changes (mirrors local review-gate) | |
| id: filter | |
| run: | | |
| set -euo pipefail | |
| # Non-PR events (push, workflow_dispatch) always run the full pipeline. | |
| # Short-circuit before computing the diff to avoid sentinel-string fragility. | |
| if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then | |
| echo "Event ${GITHUB_EVENT_NAME}: forcing code_changed=true" | |
| echo "code_changed=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| BASE_REF="origin/${{ github.base_ref }}" | |
| # Verify BASE_REF is fetched before computing the diff. fetch-depth: 0 | |
| # on actions/checkout@v4 fetches all refs; this guard fails loud rather | |
| # than silent if the runner's checkout shape ever changes. | |
| if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then | |
| echo "ERROR: base ref ${BASE_REF} not found in local refs after checkout. Forcing code_changed=true to be safe." >&2 | |
| echo "code_changed=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| CHANGED=$(git diff --name-only "$BASE_REF...HEAD") | |
| echo "Changed files:" | |
| echo "$CHANGED" | |
| # Verify the local review-gate skip script is present. Fail-loud if | |
| # absent rather than silently falling back to the broken `.md`-only | |
| # heuristic (the very gap that landed PR #66 with broken tests). | |
| SKIP_SCRIPT="plugins/dso/scripts/skip-review-check.sh" | |
| if [[ ! -x "$SKIP_SCRIPT" ]]; then | |
| echo "ERROR: ${SKIP_SCRIPT} not found or not executable. Forcing code_changed=true to be safe." >&2 | |
| echo "code_changed=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Run the same classifier the local review gate uses. | |
| # exit 0 → all files allowlisted (tests can skip) | |
| # exit 1 → at least one reviewable file (tests MUST run) | |
| # DSO_FORCE_LOCAL_REVIEW=1 disables the enforcement.strategy=ci | |
| # short-circuit (we ARE the CI; that short-circuit would no-op us). | |
| set +e | |
| echo "$CHANGED" | DSO_FORCE_LOCAL_REVIEW=1 bash "$SKIP_SCRIPT" | |
| SKIP_RC=$? | |
| set -e | |
| if [[ "$SKIP_RC" -eq 0 ]]; then | |
| echo "skip-review-check: all changes allowlisted — code_changed=false" | |
| echo "code_changed=false" >> "$GITHUB_OUTPUT" | |
| elif [[ "$SKIP_RC" -eq 1 ]]; then | |
| echo "skip-review-check: reviewable changes detected — code_changed=true" | |
| echo "code_changed=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "ERROR: skip-review-check.sh exited ${SKIP_RC} (expected 0 or 1). Forcing code_changed=true to be safe." >&2 | |
| echo "code_changed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ── Fast gates ────────────────────────────────────────────── | |
| actionlint: | |
| name: Actionlint | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install actionlint | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: | | |
| bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) | |
| echo "$PWD" >> "$GITHUB_PATH" | |
| - name: Run actionlint | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: actionlint -color | |
| - name: Job timing report | |
| if: always() | |
| run: echo "actionlint completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| shellcheck: | |
| name: ShellCheck | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install shellcheck | |
| # Download from GitHub releases instead of apt to avoid azure.archive.ubuntu.com | |
| # DNS outages on Azure-hosted runners (saw 3 consecutive cancellations 2026-05-02). | |
| # github.com DNS is independent of the apt repo mirrors. | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: | | |
| set -euo pipefail | |
| SHELLCHECK_VERSION=v0.10.0 | |
| TARBALL="shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" | |
| for attempt in 1 2 3; do | |
| if curl -fsSL --retry 3 --retry-delay 5 \ | |
| "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/${TARBALL}" \ | |
| -o "${TARBALL}"; then | |
| break | |
| fi | |
| echo "Download attempt ${attempt} failed; retrying..." >&2 | |
| sleep 10 | |
| done | |
| tar -xJf "${TARBALL}" | |
| sudo mv "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/ | |
| rm -rf "shellcheck-${SHELLCHECK_VERSION}" "${TARBALL}" | |
| shellcheck --version | |
| - name: Run shellcheck | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: shellcheck --severity=warning plugins/dso/scripts/*.sh plugins/dso/hooks/*.sh plugins/dso/hooks/**/*.sh | |
| - name: Job timing report | |
| if: always() | |
| run: echo "shellcheck completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| lint-python: | |
| name: Lint Python (ruff) | |
| needs: [changes] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install ruff | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: pip install ruff | |
| - name: Ruff check | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: ruff check plugins/dso/scripts/*.py tests/**/*.py | |
| - name: Ruff format check | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: ruff format --check plugins/dso/scripts/*.py tests/**/*.py | |
| - name: Job timing report | |
| if: always() | |
| run: echo "lint-python completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| # ── Full test suite ───────────────────────────────────────── | |
| test-hooks: | |
| name: Hook Tests | |
| needs: [changes, shellcheck, lint-python] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| env: | |
| SUITE_TEST_INDEX: ${{ github.workspace }}/.test-index | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Run hook tests | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: bash tests/hooks/run-hook-tests.sh | |
| - name: Job timing report | |
| if: always() | |
| run: echo "test-hooks completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| test-scripts: | |
| name: Script Tests | |
| needs: [changes, shellcheck, lint-python] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| env: | |
| SUITE_TEST_INDEX: ${{ github.workspace }}/.test-index | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Python | |
| if: needs.changes.outputs.code_changed == 'true' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.13' | |
| - name: Install Python dependencies | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: pip install ruff pyyaml jsonschema | |
| - name: Install hyperfine | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: sudo apt-get update && sudo apt-get install -y hyperfine | |
| - name: Verify hyperfine installed | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: hyperfine --version | |
| - name: Run script tests | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: bash tests/scripts/run-script-tests.sh | |
| - name: Job timing report | |
| if: always() | |
| run: echo "test-scripts completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| validate-required-checks: | |
| name: Validate required-checks.txt | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Python dependencies | |
| run: pip install pyyaml | |
| - name: Validate check-context names | |
| run: bash plugins/dso/scripts/onboarding/validate-required-checks.sh | |
| # ── Mirror tracker defenses to PR ─────────────────────────── | |
| mirror-defenses-to-pr: | |
| name: Mirror Tracker Defenses to PR | |
| needs: [changes, test-hooks, test-scripts] | |
| if: github.event_name == 'pull_request' && github.base_ref == 'main' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| continue-on-error: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Mirror defenses to PR | |
| if: needs.changes.outputs.code_changed == 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUMBER="${GITHUB_REF#refs/pull/}" | |
| PR_NUMBER="${PR_NUMBER%/merge}" | |
| bash plugins/dso/scripts/mirror-defenses-to-pr.sh "$PR_NUMBER" || true | |
| # ── LLM code review ───────────────────────────────────────── | |
| llm-review: | |
| needs: [changes, test-hooks, test-scripts, mirror-defenses-to-pr] | |
| if: github.event_name == 'pull_request' && github.base_ref == 'main' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| # REVIEW-DEFENSE: id-token: write is intentionally absent. Adding it would trigger | |
| # the claude-code-action OIDC app-token exchange, which validates the workflow file | |
| # is identical to the main branch as a security check. Any PR that modifies ci.yml | |
| # (including this one) fails that check with 401 Unauthorized. The action authenticates | |
| # via ANTHROPIC_API_KEY (direct API key auth) and does not need OIDC. | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install litellm | |
| if: needs.changes.outputs.code_changed == 'true' | |
| run: pip install litellm==1.83.7 | |
| - name: Smoke-test review-complexity-classifier.sh | |
| if: needs.changes.outputs.code_changed == 'true' | |
| env: | |
| CLAUDE_PLUGIN_ROOT: "${{ github.workspace }}/plugins/dso" | |
| run: | | |
| set -euo pipefail | |
| FIXTURE="tests/fixtures/ci-review-corpus/fixture-diff.txt" | |
| if [[ -f "$FIXTURE" ]]; then | |
| DIFF_FILE="$FIXTURE" | |
| else | |
| echo 'No fixture diff found — generating minimal synthetic diff' | |
| DIFF_FILE=$(mktemp /tmp/smoke-test-diff.XXXXXX) | |
| printf '+foo\n-bar\n' > "$DIFF_FILE" | |
| fi | |
| OUTPUT=$(bash plugins/dso/scripts/review-complexity-classifier.sh < "$DIFF_FILE") | |
| echo "$OUTPUT" | python3 -c 'import sys,json; d=json.load(sys.stdin); assert d.get("selected_tier") in {"light","standard","deep"}, f"invalid: {d}"' | |
| echo "Smoke test passed: $OUTPUT" | |
| - name: Run LLM review | |
| if: needs.changes.outputs.code_changed == 'true' | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| CLAUDE_PLUGIN_ROOT: "${{ github.workspace }}/plugins/dso" | |
| shell: bash -eo pipefail {0} | |
| run: | | |
| PR_NUMBER="${GITHUB_REF#refs/pull/}" | |
| PR_NUMBER="${PR_NUMBER%/merge}" | |
| gh pr diff "$PR_NUMBER" | bash plugins/dso/scripts/ci-llm-review-runner.sh | |