Skip to content

feat(skills): add /dso:prioritize-epics for backlog reprioritization #923

feat(skills): add /dso:prioritize-epics for backlog reprioritization

feat(skills): add /dso:prioritize-epics for backlog reprioritization #923

Workflow file for this run

# 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