Template Drift Scan #21
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
| # Weekly fleet-wide template-drift scan. Iterates over every repository that | |
| # consumes a netresearch/.github template, compares its .github/ tree to the | |
| # matching template, and opens/updates one issue per drifting repo. | |
| # | |
| # The fleet is discovered, not listed: a repository declares its membership by | |
| # carrying .github/template.yaml, and scripts/list-consumers.py asks the | |
| # organisation for those. The matrix used to be written out here and mirrored by | |
| # hand in scripts/sync-all-consumers.sh; both had drifted to six Go repos while | |
| # the fleet had grown to 57 across five templates, so this scan was watching a | |
| # ninth of what its own comment claimed. | |
| # | |
| # This is a monitoring workflow — informational only, it does not block | |
| # any PR. Per-repo enforcement is handled by check-template-drift.yml | |
| # which runs on PR events in each consuming repo. | |
| name: Template Drift Scan | |
| on: | |
| schedule: | |
| # Monday 06:00 UTC — surfaces drift at start of week for triage. | |
| - cron: "0 6 * * 1" | |
| workflow_dispatch: | |
| inputs: | |
| template: | |
| description: "Only scan consumers of this template (go-app, go-lib, php-module, skill, typo3-extension). Empty: the whole fleet." | |
| required: false | |
| type: string | |
| default: "" | |
| permissions: {} | |
| jobs: | |
| discover: | |
| name: Discover consumers | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| outputs: | |
| matrix: ${{ steps.list.outputs.matrix }} | |
| count: ${{ steps.list.outputs.count }} | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| - name: List consumers | |
| id: list | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TEMPLATE_FILTER: ${{ inputs.template }} | |
| run: | | |
| set -euo pipefail | |
| # list-consumers.py exits non-zero on an empty fleet, so a discovery | |
| # failure stops the run here rather than producing an empty matrix | |
| # that would report a clean scan of nothing. | |
| MATRIX=$(python3 scripts/list-consumers.py --json \ | |
| ${TEMPLATE_FILTER:+--template "$TEMPLATE_FILTER"}) | |
| COUNT=$(printf '%s' "$MATRIX" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["include"]))') | |
| echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" | |
| echo "count=$COUNT" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Scanning $COUNT consumer(s)." | |
| scan: | |
| name: Scan consumer | |
| needs: discover | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| issues: write | |
| strategy: | |
| fail-fast: false | |
| # The fleet is 57 repositories on a free-plan job quota. Starting | |
| # them all at once starves the pool — during one bulk campaign that | |
| # left merge-queue entries waiting for a runner until they timed | |
| # out. Six at a time keeps a weekly scan out of everyone's way. | |
| max-parallel: 6 | |
| matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout templates | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| path: templates-src | |
| - name: Checkout consumer repo | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| repository: ${{ matrix.repo }} | |
| persist-credentials: false | |
| path: consumer | |
| - name: Run drift check | |
| id: drift | |
| continue-on-error: true | |
| env: | |
| CONSUMER_DIR: ${{ github.workspace }}/consumer | |
| TEMPLATE_DIR: ${{ github.workspace }}/templates-src/templates/${{ matrix.template }} | |
| run: | | |
| set +e | |
| OUTPUT=$(bash "$GITHUB_WORKSPACE/templates-src/scripts/check-drift.sh" "$CONSUMER_DIR" "$TEMPLATE_DIR" 2>&1) | |
| STATUS=$? | |
| { | |
| echo "status=$STATUS" | |
| # Preserve multi-line output via GHA heredoc syntax. | |
| echo "output<<DRIFT_EOF" | |
| echo "$OUTPUT" | |
| echo "DRIFT_EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Open or update drift issue | |
| if: steps.drift.outputs.status != '0' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TARGET_REPO: ${{ matrix.repo }} | |
| TEMPLATE_NAME: ${{ matrix.template }} | |
| DRIFT_OUTPUT: ${{ steps.drift.outputs.output }} | |
| run: | | |
| set -euo pipefail | |
| TITLE="Template drift: ${TARGET_REPO#*/} vs ${TEMPLATE_NAME}" | |
| BODY=$(cat <<MARKDOWN | |
| ## Template drift detected | |
| Repo: \`$TARGET_REPO\` | |
| Template: \`$TEMPLATE_NAME\` (netresearch/.github) | |
| Scan date: $(date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| Auto-opened by \`.github/workflows/drift-scan.yml\`. Resolve by running | |
| \`scripts/sync-template.sh $TEMPLATE_NAME $TARGET_REPO\`, then opening | |
| the generated PR in the consumer repo. | |
| If the drift is intentional, add an entry to \`$TARGET_REPO\`'s | |
| \`.github/template.yaml\` \`intentional-drift:\` list and close this issue. | |
| ### Drift output | |
| \`\`\` | |
| $DRIFT_OUTPUT | |
| \`\`\` | |
| MARKDOWN | |
| ) | |
| # Find existing open drift issue for this repo; update it if present. | |
| EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --search "$TITLE in:title" --state open --json number --jq '.[0].number // empty') | |
| if [ -n "$EXISTING" ]; then | |
| gh issue edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY" | |
| echo "Updated issue #$EXISTING" | |
| else | |
| gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY" --label "template-drift" || \ | |
| gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY" | |
| fi | |
| - name: Close stale drift issue | |
| if: steps.drift.outputs.status == '0' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TARGET_REPO: ${{ matrix.repo }} | |
| TEMPLATE_NAME: ${{ matrix.template }} | |
| run: | | |
| set -euo pipefail | |
| TITLE="Template drift: ${TARGET_REPO#*/} vs ${TEMPLATE_NAME}" | |
| EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --search "$TITLE in:title" --state open --json number --jq '.[0].number // empty') | |
| if [ -n "$EXISTING" ]; then | |
| gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --comment "Drift resolved — closed automatically by drift-scan on $(date -u +"%Y-%m-%dT%H:%M:%SZ")." | |
| echo "Closed stale issue #$EXISTING" | |
| fi |