# agent-context drift detection # # Weekly + on-demand check: does this repo's installed agent-pipeline # artifacts match the latest upstream pipeline release? # # - Reads .agent-context-manifest.yml (committed at repo root) # - Clones the pipeline repo at its latest tag # - Compares each tracked artifact's installed_hash to the pipeline source hash # - Compares manifest pipeline_version to pipeline version.txt # - Opens (or updates) an issue titled "agent-context: N files behind v" # if drift is detected # # No auto-fix. The fix workflow is: a human runs `sync-agent-context` in # Cursor and reviews per-file diffs. name: agent-context-drift on: schedule: # Mondays at 13:00 UTC. Adjust to taste. - cron: "0 13 * * 1" workflow_dispatch: permissions: contents: read issues: write jobs: drift: runs-on: ubuntu-latest steps: - name: Checkout consumer repo uses: actions/checkout@v4 - name: Read manifest id: manifest run: | if [ ! -f .agent-context-manifest.yml ]; then echo "::warning::No .agent-context-manifest.yml โ€” agent-pipeline not installed or pre-v0.3.0. Skipping drift check." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi INSTALLED=$(grep -E '^pipeline_version:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/') SOURCE=$(grep -E '^pipeline_source:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/') echo "installed_version=$INSTALLED" >> "$GITHUB_OUTPUT" echo "pipeline_source=$SOURCE" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT" - name: Clone pipeline at latest tag if: steps.manifest.outputs.skip != 'true' id: pipeline run: | PIPELINE_URL="${{ steps.manifest.outputs.pipeline_source }}" # Convert HTTPS URL โ†’ clone target. Already in HTTPS form. mkdir -p /tmp/pipeline git clone --depth 50 "$PIPELINE_URL" /tmp/pipeline cd /tmp/pipeline LATEST_TAG=$(git tag --sort=-v:refname | head -1) if [ -z "$LATEST_TAG" ]; then echo "::warning::Pipeline repo has no tags. Comparing against main." LATEST_TAG="main" fi git checkout "$LATEST_TAG" PIPELINE_VER=$(cat version.txt | tr -d '[:space:]') echo "tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" echo "version=$PIPELINE_VER" >> "$GITHUB_OUTPUT" - name: Compute drift if: steps.manifest.outputs.skip != 'true' id: drift run: | INSTALLED="${{ steps.manifest.outputs.installed_version }}" UPSTREAM="${{ steps.pipeline.outputs.version }}" BEHIND=0 CUSTOMIZED=0 CONFLICT=0 # Walk manifest artifacts. For each, compare installed_hash to local # current hash, and pipeline source hash to installed_hash. # YAML parsing in bash is intentionally minimal โ€” relies on the # bootstrap skill emitting a predictable shape. python3 - <<'PY' >> drift-report.md import hashlib, sys, yaml, os def sha(path): if not os.path.exists(path): return None h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return "sha256:" + h.hexdigest() with open(".agent-context-manifest.yml") as f: m = yaml.safe_load(f) counts = {"behind": [], "customized": [], "conflict": [], "deleted": []} for art in m.get("artifacts", []): local_hash = sha(art["path"]) pipe_hash = sha(os.path.join("/tmp/pipeline", art["source"])) if local_hash is None: counts["deleted"].append(art["path"]) continue local_matches = local_hash == art["installed_hash"] pipe_changed = pipe_hash is not None and pipe_hash != art["installed_hash"] if local_matches and pipe_changed: counts["behind"].append(art["path"]) elif not local_matches and pipe_changed: counts["conflict"].append(art["path"]) elif not local_matches: counts["customized"].append(art["path"]) print("# agent-context drift report") print(f"\nInstalled: `{m.get('pipeline_version')}` ยท Upstream: `${{ steps.pipeline.outputs.version }}` (`${{ steps.pipeline.outputs.tag }}`)") for kind in ("behind", "conflict", "customized", "deleted"): files = counts[kind] if files: print(f"\n## {kind} ({len(files)})") for f in files: print(f"- `{f}`") PY BEHIND=$(grep -c '^## behind' drift-report.md || echo 0) CONFLICT=$(grep -c '^## conflict' drift-report.md || echo 0) NEED_ISSUE="false" if [ "$INSTALLED" != "$UPSTREAM" ] || [ "$BEHIND" -gt 0 ] || [ "$CONFLICT" -gt 0 ]; then NEED_ISSUE="true" fi echo "need_issue=$NEED_ISSUE" >> "$GITHUB_OUTPUT" echo "upstream_version=$UPSTREAM" >> "$GITHUB_OUTPUT" - name: Open / update drift issue if: steps.manifest.outputs.skip != 'true' && steps.drift.outputs.need_issue == 'true' uses: actions/github-script@v7 with: script: | const fs = require('fs'); const body = fs.readFileSync('drift-report.md', 'utf8') + '\n\n---\n\n_To resolve: open this repo in Cursor and ask **"Sync agent context for this repo"**. The sync skill walks the diff per file._'; const title = `agent-context: behind ${{ steps.drift.outputs.upstream_version }}`; const existing = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', labels: 'agent-context-drift', }); const found = existing.data.find(i => i.title.startsWith('agent-context: behind')); if (found) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: found.number, title, body, }); } else { await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title, body, labels: ['agent-context-drift'], }); }