Drift Auto-Detection #5
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
| name: Drift Auto-Detection | |
| on: | |
| schedule: | |
| - cron: '0 9 * * 1' # Monday 9:00 UTC | |
| workflow_dispatch: | |
| inputs: | |
| coherence_root: | |
| description: 'Coherence root directory (default: coherence)' | |
| required: false | |
| default: 'coherence' | |
| type: string | |
| permissions: | |
| contents: read | |
| issues: write | |
| jobs: | |
| detect-drift: | |
| name: Detect Assumption Expiry Drift | |
| runs-on: ubuntu-latest | |
| env: | |
| COHERENCE_ROOT: ${{ inputs.coherence_root || 'coherence' }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Check for expired assumptions | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const { owner, repo } = context.repo; | |
| const root = process.env.COHERENCE_ROOT; | |
| const path = `${root}/intel/assumptions.yaml`; | |
| if (!fs.existsSync(path)) { | |
| core.info(`No assumptions.yaml found at ${path}. Nothing to check.`); | |
| return; | |
| } | |
| const content = fs.readFileSync(path, 'utf8'); | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); | |
| // Parse assumptions from YAML. | |
| // Handles: empty files, assumptions: [], indented lists, multi-line statements. | |
| if (!content.includes('- id:')) { | |
| core.info('No assumptions defined. Nothing to check.'); | |
| return; | |
| } | |
| const blocks = content.split(/^\s*- id:/m).slice(1); | |
| const assumptions = blocks.map(block => { | |
| const id = (block.match(/^\s*(.+)/m) || ['', ''])[1].trim(); | |
| const status = (block.match(/^\s*status:\s*(.+)/m) || ['', ''])[1].trim(); | |
| const expires = (block.match(/^\s*expires:\s*(.+)/m) || ['', ''])[1].trim(); | |
| const owner = (block.match(/^\s*owner:\s*"?(.+?)"?\s*$/m) || ['', ''])[1].trim(); | |
| // Handle multi-line statements (> or | YAML folding) | |
| let statement = ''; | |
| const stmtMatch = block.match(/^\s*statement:\s*(.+)/m); | |
| if (stmtMatch) { | |
| const val = stmtMatch[1].trim().replace(/^["']|["']$/g, ''); | |
| if (/^[>|][-+]?$/.test(val)) { | |
| const lines = block.split('\n'); | |
| const idx = lines.findIndex(l => /^\s*statement:/.test(l)); | |
| const keyIndent = (lines[idx].match(/^(\s*)/)||['',''])[1].length; | |
| const parts = []; | |
| for (let i = idx + 1; i < lines.length; i++) { | |
| const indent = (lines[i].match(/^(\s*)/)||['',''])[1].length; | |
| if (lines[i].trim() && indent <= keyIndent) break; | |
| if (lines[i].trim()) parts.push(lines[i].trim()); | |
| } | |
| statement = parts.join(' '); | |
| } else { | |
| statement = val; | |
| } | |
| } | |
| return { | |
| id: id.startsWith('ASM-') ? id : `ASM-${id}`, | |
| status, expires, statement, owner, | |
| }; | |
| }); | |
| const expired = assumptions.filter(a => { | |
| if (a.status !== 'active' || !a.expires) return false; | |
| return new Date(a.expires + 'T00:00:00') < today; | |
| }); | |
| if (expired.length === 0) { | |
| core.info('No expired assumptions. No drift issues needed.'); | |
| return; | |
| } | |
| // Check existing open issues to avoid duplicates | |
| const { data: existingIssues } = await github.rest.issues.listForRepo({ | |
| owner, repo, | |
| state: 'open', | |
| labels: 'drift,auto-detected', | |
| per_page: 100, | |
| }); | |
| const existingTitles = existingIssues.map(i => i.title); | |
| for (const asm of expired) { | |
| const title = `[DRIFT] Assumption expired: ${asm.id}`; | |
| if (existingTitles.includes(title)) { | |
| core.info(`Issue already exists for ${asm.id}, skipping.`); | |
| continue; | |
| } | |
| const todayStr = today.toISOString().split('T')[0]; | |
| const body = [ | |
| '## Severity', | |
| 'medium', | |
| '', | |
| '## What Drifted', | |
| `Assumption **${asm.id}** has expired.`, | |
| '', | |
| `> ${asm.statement}`, | |
| '', | |
| `Expiry date: \`${asm.expires}\``, | |
| '', | |
| '## Evidence', | |
| `Auto-detected by [drift-auto-detect](.github/workflows/drift-auto-detect.yml) workflow on ${todayStr}.`, | |
| "The assumption's `expires` field is past today's date while `status` is still `active`.", | |
| '', | |
| '## Affected', | |
| `- Assumption: ${asm.id}`, | |
| `- Owner: ${asm.owner}`, | |
| '', | |
| '## Action Required', | |
| '1. Re-validate the assumption — is it still true?', | |
| '2. If still true: update `expires` date in `assumptions.yaml`', | |
| '3. If no longer true: set `status: expired`, create a DRIFT file in `coherence/drift/`, and open a patch PR', | |
| '', | |
| '## Status', | |
| 'open', | |
| '', | |
| '---', | |
| '_Auto-detected by [CoherenceOps](https://github.com/8ryanWh1t3/CoherenceOps) drift auto-detection._', | |
| ].join('\n'); | |
| const { data: issue } = await github.rest.issues.create({ | |
| owner, repo, | |
| title, | |
| body, | |
| labels: ['drift', 'auto-detected'], | |
| }); | |
| core.info(`Created drift issue #${issue.number} for ${asm.id}`); | |
| } |