Skip to content

Commit 20521e2

Browse files
authored
Merge pull request #165 from avifenesh/feature/spec-drift-sentinel-107
feat: add spec-drift sentinel workflow (#107)
2 parents f252a75 + 62cd65d commit 20521e2

4 files changed

Lines changed: 357 additions & 0 deletions

File tree

.github/spec-baselines.json

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"description": "Baseline content hashes for spec-drift sentinel. Updated automatically by workflow.",
4+
"last_updated": "2026-02-04T00:00:00Z",
5+
"sources": {
6+
"s-tier": {
7+
"agent-skills-spec": {
8+
"url": "https://agentskills.io/specification",
9+
"hash": ""
10+
},
11+
"mcp-spec": {
12+
"url": "https://modelcontextprotocol.io/specification/2025-11-25",
13+
"hash": ""
14+
},
15+
"claude-code-memory": {
16+
"url": "https://code.claude.com/docs/en/memory",
17+
"hash": ""
18+
},
19+
"claude-code-hooks": {
20+
"url": "https://code.claude.com/docs/en/hooks",
21+
"hash": ""
22+
},
23+
"claude-code-skills": {
24+
"url": "https://code.claude.com/docs/en/skills",
25+
"hash": ""
26+
},
27+
"claude-code-plugins": {
28+
"url": "https://code.claude.com/docs/en/plugins-reference",
29+
"hash": ""
30+
},
31+
"claude-code-subagents": {
32+
"url": "https://code.claude.com/docs/en/sub-agents",
33+
"hash": ""
34+
},
35+
"codex-cli-agents-md": {
36+
"url": "https://developers.openai.com/codex/guides/agents-md/",
37+
"hash": ""
38+
},
39+
"opencode-rules": {
40+
"url": "https://opencode.ai/docs/rules/",
41+
"hash": ""
42+
}
43+
},
44+
"a-tier": {
45+
"cursor-rules": {
46+
"url": "https://cursor.com/docs/context/rules",
47+
"hash": ""
48+
},
49+
"github-copilot": {
50+
"url": "https://docs.github.com/en/copilot/customizing-copilot",
51+
"hash": ""
52+
},
53+
"cline-rules": {
54+
"url": "https://docs.cline.bot/features/cline-rules/overview",
55+
"hash": ""
56+
}
57+
}
58+
}
59+
}

.github/workflows/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,4 @@ When a new version of an action is released:
7676
| changelog.yml | PR | Verify CHANGELOG.md is updated |
7777
| claude.yml | issue/PR comments | Claude Code assistant |
7878
| claude-code-review.yml | PR | Automated code review |
79+
| spec-drift.yml | schedule/manual | Monitor upstream specs for changes |

.github/workflows/spec-drift.yml

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# Spec Drift Sentinel
2+
# Monitors upstream specification sources for changes that may require rule updates.
3+
# See .github/spec-baselines.json for tracked sources.
4+
5+
name: Spec Drift Sentinel
6+
7+
on:
8+
schedule:
9+
# S-tier: Weekly on Sunday at midnight UTC
10+
- cron: '0 0 * * 0'
11+
# A-tier: Monthly on 1st at midnight UTC
12+
- cron: '0 0 1 * *'
13+
workflow_dispatch:
14+
inputs:
15+
update_baselines:
16+
description: 'Update baseline hashes (run after reviewing changes)'
17+
required: false
18+
type: boolean
19+
default: false
20+
tier:
21+
description: 'Which tier to check'
22+
required: false
23+
type: choice
24+
options:
25+
- all
26+
- s-tier
27+
- a-tier
28+
default: all
29+
30+
permissions:
31+
contents: read
32+
issues: write
33+
34+
jobs:
35+
check-drift:
36+
name: Check for spec drift
37+
runs-on: ubuntu-latest
38+
steps:
39+
- name: Checkout repository
40+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
41+
42+
- name: Check spec sources for drift
43+
id: drift-check
44+
env:
45+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+
UPDATE_BASELINES: ${{ inputs.update_baselines || 'false' }}
47+
TIER_FILTER: ${{ inputs.tier || 'all' }}
48+
SCHEDULE_CRON: ${{ github.event.schedule }}
49+
run: |
50+
set -euo pipefail
51+
52+
BASELINES_FILE=".github/spec-baselines.json"
53+
54+
# Validate JSON structure
55+
if ! jq -e '.sources["s-tier"] and .sources["a-tier"]' "$BASELINES_FILE" > /dev/null 2>&1; then
56+
echo "ERROR: Invalid spec-baselines.json structure"
57+
exit 1
58+
fi
59+
60+
DRIFT_DETECTED=false
61+
DRIFT_REPORT=""
62+
NEW_HASHES=""
63+
64+
# Determine which tiers to check based on trigger
65+
check_s_tier=false
66+
check_a_tier=false
67+
68+
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
69+
case "$TIER_FILTER" in
70+
s-tier) check_s_tier=true ;;
71+
a-tier) check_a_tier=true ;;
72+
all) check_s_tier=true; check_a_tier=true ;;
73+
esac
74+
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
75+
# Each cron schedule triggers a separate workflow run
76+
# SCHEDULE_CRON contains the specific cron that triggered THIS run
77+
# Weekly schedule (Sunday midnight UTC) = S-tier sources
78+
if [[ "$SCHEDULE_CRON" == "0 0 * * 0" ]]; then
79+
check_s_tier=true
80+
fi
81+
# Monthly schedule (1st midnight UTC) = A-tier sources
82+
if [[ "$SCHEDULE_CRON" == "0 0 1 * *" ]]; then
83+
check_a_tier=true
84+
fi
85+
else
86+
check_s_tier=true
87+
check_a_tier=true
88+
fi
89+
90+
echo "Checking S-tier: $check_s_tier"
91+
echo "Checking A-tier: $check_a_tier"
92+
93+
# Function to normalize content for consistent hashing
94+
normalize_content() {
95+
# Remove extra whitespace, normalize line endings
96+
tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//'
97+
}
98+
99+
# Function to compute hash of URL content
100+
compute_hash() {
101+
local url="$1"
102+
local content
103+
local hash
104+
105+
# Validate URL is https
106+
if [[ ! "$url" =~ ^https:// ]]; then
107+
echo "INVALID_URL"
108+
return
109+
fi
110+
111+
# Fetch with curl, follow redirects, timeout after 30s
112+
# Use separate variable for exit code to avoid partial content issues
113+
local exit_code
114+
content=$(curl -fsSL --max-time 30 "$url" 2>/dev/null) && exit_code=0 || exit_code=$?
115+
116+
if [[ $exit_code -ne 0 ]]; then
117+
echo "FETCH_FAILED"
118+
return
119+
fi
120+
121+
# Normalize and hash
122+
hash=$(echo "$content" | normalize_content | sha256sum | cut -d' ' -f1)
123+
echo "$hash"
124+
}
125+
126+
# Function to check sources in a tier
127+
check_tier() {
128+
local tier="$1"
129+
local sources
130+
sources=$(jq -r ".sources[\"$tier\"] | keys[]" "$BASELINES_FILE")
131+
132+
for source in $sources; do
133+
local url
134+
local baseline_hash
135+
136+
url=$(jq -r ".sources[\"$tier\"][\"$source\"].url" "$BASELINES_FILE")
137+
baseline_hash=$(jq -r ".sources[\"$tier\"][\"$source\"].hash" "$BASELINES_FILE")
138+
139+
echo "Checking: $source ($url)"
140+
141+
current_hash=$(compute_hash "$url")
142+
143+
if [[ "$current_hash" == "FETCH_FAILED" ]]; then
144+
echo " WARNING: Failed to fetch $source"
145+
DRIFT_REPORT="${DRIFT_REPORT}\n- **$source**: Failed to fetch (may be temporary)"
146+
continue
147+
fi
148+
149+
if [[ "$current_hash" == "INVALID_URL" ]]; then
150+
echo " ERROR: Invalid URL for $source (must be https://)"
151+
DRIFT_REPORT="${DRIFT_REPORT}\n- **$source**: Invalid URL (must be https://)"
152+
continue
153+
fi
154+
155+
# Store new hash for potential baseline update
156+
NEW_HASHES="${NEW_HASHES}${tier}|${source}|${current_hash}\n"
157+
158+
if [[ -z "$baseline_hash" ]]; then
159+
echo " INFO: No baseline hash set for $source (first run)"
160+
DRIFT_REPORT="${DRIFT_REPORT}\n- **$source**: No baseline (initial hash: ${current_hash:0:12}...)"
161+
DRIFT_DETECTED=true
162+
elif [[ "$current_hash" != "$baseline_hash" ]]; then
163+
echo " DRIFT DETECTED: $source"
164+
echo " Baseline: ${baseline_hash:0:12}..."
165+
echo " Current: ${current_hash:0:12}..."
166+
DRIFT_REPORT="${DRIFT_REPORT}\n- **$source** ($tier): Content changed"
167+
DRIFT_REPORT="${DRIFT_REPORT}\n - URL: $url"
168+
DRIFT_REPORT="${DRIFT_REPORT}\n - Previous hash: \`${baseline_hash:0:12}...\`"
169+
DRIFT_REPORT="${DRIFT_REPORT}\n - Current hash: \`${current_hash:0:12}...\`"
170+
DRIFT_DETECTED=true
171+
else
172+
echo " OK: No change"
173+
fi
174+
done
175+
}
176+
177+
# Check requested tiers
178+
if [[ "$check_s_tier" == "true" ]]; then
179+
echo ""
180+
echo "=== Checking S-tier sources ==="
181+
check_tier "s-tier"
182+
fi
183+
184+
if [[ "$check_a_tier" == "true" ]]; then
185+
echo ""
186+
echo "=== Checking A-tier sources ==="
187+
check_tier "a-tier"
188+
fi
189+
190+
# Output results
191+
echo ""
192+
echo "drift_detected=$DRIFT_DETECTED" >> "$GITHUB_OUTPUT"
193+
194+
# Use multiline output syntax for drift report
195+
{
196+
echo "drift_report<<EOF"
197+
echo -e "$DRIFT_REPORT"
198+
echo "EOF"
199+
} >> "$GITHUB_OUTPUT"
200+
201+
# Output new hashes if update requested
202+
if [[ "$UPDATE_BASELINES" == "true" ]]; then
203+
echo ""
204+
echo "=== New baseline hashes ==="
205+
echo -e "$NEW_HASHES"
206+
207+
# Write updated baselines file
208+
temp_file=$(mktemp)
209+
cp "$BASELINES_FILE" "$temp_file"
210+
211+
echo -e "$NEW_HASHES" | while IFS='|' read -r tier source hash; do
212+
if [[ -n "$tier" && -n "$source" && -n "$hash" ]]; then
213+
jq ".sources[\"$tier\"][\"$source\"].hash = \"$hash\"" "$temp_file" > "${temp_file}.new"
214+
mv "${temp_file}.new" "$temp_file"
215+
fi
216+
done
217+
218+
# Update timestamp
219+
jq ".last_updated = \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"" "$temp_file" > "${temp_file}.new"
220+
mv "${temp_file}.new" "$temp_file"
221+
222+
echo ""
223+
echo "Updated baselines file:"
224+
cat "$temp_file"
225+
fi
226+
227+
if [[ "$DRIFT_DETECTED" == "true" ]]; then
228+
echo ""
229+
echo "DRIFT DETECTED - See report above"
230+
else
231+
echo ""
232+
echo "No drift detected"
233+
fi
234+
235+
- name: Create or update drift issue
236+
if: steps.drift-check.outputs.drift_detected == 'true' && inputs.update_baselines != true
237+
env:
238+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
239+
DRIFT_REPORT: ${{ steps.drift-check.outputs.drift_report }}
240+
run: |
241+
set -euo pipefail
242+
243+
ISSUE_TITLE="Spec Drift Detected - Review Required"
244+
ISSUE_LABEL="spec-drift"
245+
246+
# Check if an open issue already exists
247+
existing_issue=$(gh issue list --state open --label "$ISSUE_LABEL" --json number --jq '.[0].number // empty')
248+
249+
# Use report directly (multiline output preserved)
250+
REPORT_BODY="$DRIFT_REPORT"
251+
252+
ISSUE_BODY="## Spec Drift Sentinel Alert
253+
254+
The following upstream specification sources have changed since our last baseline check.
255+
These changes may require updates to agnix validation rules.
256+
257+
### Changed Sources
258+
$REPORT_BODY
259+
260+
### Action Required
261+
262+
1. **Review each changed source** to understand what has changed
263+
2. **Check if rule updates are needed** in \`knowledge-base/rules.json\`
264+
3. **Update VALIDATION-RULES.md** if rules are modified
265+
4. **Update baselines** by running the workflow with \`update_baselines: true\`
266+
267+
### How to Update Baselines
268+
269+
Run the spec-drift workflow manually with:
270+
- \`update_baselines: true\`
271+
- \`tier: all\` (or specific tier)
272+
273+
Then copy the output JSON to \`.github/spec-baselines.json\`.
274+
275+
---
276+
*This issue was automatically created by the Spec Drift Sentinel workflow.*
277+
*Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}*"
278+
279+
if [[ -n "$existing_issue" ]]; then
280+
echo "Updating existing issue #$existing_issue"
281+
# Use stdin to avoid shell interpretation of special characters
282+
echo "$ISSUE_BODY" | gh issue comment "$existing_issue" --body-file -
283+
else
284+
echo "Creating new drift issue"
285+
# Ensure label exists
286+
gh label create "$ISSUE_LABEL" --description "Upstream spec changes detected" --color "FFA500" 2>/dev/null || true
287+
# Use stdin to avoid shell interpretation of special characters
288+
echo "$ISSUE_BODY" | gh issue create --title "$ISSUE_TITLE" --body-file - --label "$ISSUE_LABEL"
289+
fi

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- Spec Drift Sentinel workflow for automated upstream specification monitoring (#107)
12+
- Weekly checks for S-tier sources (Agent Skills, MCP, Claude Code, Codex CLI, OpenCode)
13+
- Monthly checks for A-tier sources (Cursor, GitHub Copilot, Cline)
14+
- SHA256 content hashing with whitespace normalization for drift detection
15+
- Baseline storage in `.github/spec-baselines.json`
16+
- Auto-creates GitHub issues when drift detected with actionable review steps
17+
- Manual workflow dispatch for on-demand checks and baseline updates
18+
- Security hardened: HTTPS-only URL validation, SHA-pinned actions, minimal permissions
1119
- Cross-layer contradiction detection with 3 new validation rules (XP-004 to XP-006)
1220
- XP-004: Conflicting build/test commands detection (npm vs pnpm vs yarn vs bun)
1321
- XP-005: Conflicting tool constraints detection (allow vs disallow across files)

0 commit comments

Comments
 (0)