name: Heartbeat
on:
schedule:
# Run daily at 6 AM UTC
- cron: '0 6 * * *'
workflow_dispatch:
inputs:
task:
description: 'Task to run'
required: false
default: 'security-scan'
type: choice
options:
- security-scan
- branch-reap
- full-audit
- dashboard-refresh
- changelog-sync
jobs:
heartbeat:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout git-steer
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4
with:
node-version: '20'
- name: Install dependencies and build
run: npm ci && npm run build
- name: Generate token
id: app-token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
with:
app-id: ${{ secrets.GIT_STEER_APP_ID }}
private-key: ${{ secrets.GIT_STEER_PRIVATE_KEY }}
owner: ry-ops
- name: Get managed repos
id: repos
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
# Fetch managed repos from git-steer-state
REPOS=$(gh api repos/ry-ops/git-steer-state/contents/config/managed-repos.yaml \
--jq '.content' | base64 -d | grep -E '^\s+-\s+' | sed 's/.*- //' || echo "")
if [ -z "$REPOS" ]; then
# Fall back to installation repos
REPOS=$(gh api /installation/repositories --jq '.repositories[].full_name')
fi
echo "repos<<EOF" >> $GITHUB_OUTPUT
echo "$REPOS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Enforce Dependabot on managed repos
if: inputs.task == 'security-scan' || inputs.task == '' || inputs.task == 'full-audit'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
echo "## Enforcing Dependabot on managed repos"
while IFS= read -r repo; do
[ -z "$repo" ] && continue
# Enable vulnerability alerts
gh api "repos/$repo/vulnerability-alerts" \
-X PUT \
--silent 2>/dev/null && echo " $repo: vulnerability alerts enabled" \
|| echo " $repo: vulnerability alerts already enabled or not accessible"
# Enable automated security fixes
gh api "repos/$repo/automated-security-fixes" \
-X PUT \
--silent 2>/dev/null && echo " $repo: automated security fixes enabled" \
|| echo " $repo: automated security fixes already enabled or not accessible"
done <<< "${{ steps.repos.outputs.repos }}"
- name: Security scan
if: inputs.task == 'security-scan' || inputs.task == '' || inputs.task == 'full-audit'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
echo "## Security Scan Results"
echo ""
TOTAL_ALERTS=0
REPOS_WITH_ALERTS=0
while IFS= read -r repo; do
[ -z "$repo" ] && continue
ALERTS=$(gh api "repos/$repo/dependabot/alerts" --jq '[.[] | select(.state == "open")] | length' 2>/dev/null || echo "0")
if [ "$ALERTS" -gt 0 ]; then
echo "- **$repo**: $ALERTS open alerts"
TOTAL_ALERTS=$((TOTAL_ALERTS + ALERTS))
REPOS_WITH_ALERTS=$((REPOS_WITH_ALERTS + 1))
# Show critical/high
CRITICAL=$(gh api "repos/$repo/dependabot/alerts" \
--jq '[.[] | select(.state == "open" and .security_advisory.severity == "critical")] | length' 2>/dev/null || echo "0")
HIGH=$(gh api "repos/$repo/dependabot/alerts" \
--jq '[.[] | select(.state == "open" and .security_advisory.severity == "high")] | length' 2>/dev/null || echo "0")
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
echo " - Critical: $CRITICAL, High: $HIGH"
fi
fi
done <<< "${{ steps.repos.outputs.repos }}"
echo ""
echo "**Summary:** $TOTAL_ALERTS alerts across $REPOS_WITH_ALERTS repos"
# Store results for potential notification
echo "total_alerts=$TOTAL_ALERTS" >> $GITHUB_OUTPUT
echo "repos_with_alerts=$REPOS_WITH_ALERTS" >> $GITHUB_OUTPUT
- name: Auto-trigger security sweep for critical alerts
if: inputs.task == 'security-scan' || inputs.task == '' || inputs.task == 'full-audit'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
CRITICAL_REPOS=""
while IFS= read -r repo; do
[ -z "$repo" ] && continue
CRITICAL=$(gh api "repos/$repo/dependabot/alerts" \
--jq '[.[] | select(.state == "open" and .security_advisory.severity == "critical")] | length' 2>/dev/null || echo "0")
if [ "$CRITICAL" -gt 0 ]; then
OWNER=$(echo "$repo" | cut -d'/' -f1)
REPO_NAME=$(echo "$repo" | cut -d'/' -f2)
if [ -n "$CRITICAL_REPOS" ]; then
CRITICAL_REPOS="${CRITICAL_REPOS},"
fi
CRITICAL_REPOS="${CRITICAL_REPOS}{\"owner\":\"$OWNER\",\"repo\":\"$REPO_NAME\",\"issueNumber\":0}"
fi
done <<< "${{ steps.repos.outputs.repos }}"
if [ -n "$CRITICAL_REPOS" ]; then
echo "Critical alerts found, dispatching security sweep..."
gh workflow run security-sweep.yml \
-f "target_repos=[${CRITICAL_REPOS}]" \
-f severity=critical \
-f job_id="heartbeat-sweep-$(date +%Y%m%d)" \
-f dry_run=false
else
echo "No critical alerts found, skipping auto-sweep."
fi
- name: Follow up on security PRs
if: inputs.task == 'security-scan' || inputs.task == '' || inputs.task == 'full-audit'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STATE_OWNER: ry-ops
STATE_REPO: git-steer-state
run: node scripts/ci-pr-followup.mjs
- name: Compact CVE queue
if: inputs.task == 'security-scan' || inputs.task == '' || inputs.task == 'full-audit'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STATE_OWNER: ry-ops
STATE_REPO: git-steer-state
run: node scripts/ci-compact.mjs
- name: Regenerate dashboard
if: inputs.task == 'security-scan' || inputs.task == '' || inputs.task == 'full-audit' || inputs.task == 'dashboard-refresh'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STATE_OWNER: ry-ops
STATE_REPO: git-steer-state
run: node scripts/ci-dashboard.mjs
- name: Sync changelog to blog
if: inputs.task == 'security-scan' || inputs.task == '' || inputs.task == 'full-audit' || inputs.task == 'changelog-sync'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STATE_OWNER: ry-ops
STATE_REPO: git-steer-state
BLOG_OWNER: ry-ops
BLOG_REPO: blog
run: node scripts/ci-changelog.mjs
- name: Log heartbeat to state
if: always()
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
ENTRY="{\"timestamp\":\"$TIMESTAMP\",\"task\":\"${{ inputs.task || 'scheduled' }}\",\"status\":\"${{ job.status }}\"}"
echo "Heartbeat completed at $TIMESTAMP"