diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index e67868a..02efe52 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -12,11 +12,10 @@ workflows: '.github/workflows/governance.yml': - 'metadatastician/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' '.github/workflows/hypatia-scan.yml': - - 'actions/checkout@v7.0.1' - - 'actions/github-script@v9.0.0' - - 'actions/upload-artifact@v7.0.2' - - 'erlef/setup-beam@v1.24.1' - - 'github/codeql-action@v4.39.1' + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/mirror.yml': @@ -57,11 +56,6 @@ dependencies: commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 - 'actions/github-script@v9.0.0': - ref: 'v9.0.0' - commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3' - owner_id: 44036562 - repo_id: 205262760 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': ref: 'v7.0.0' commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' @@ -72,11 +66,6 @@ dependencies: commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' owner_id: 44036562 repo_id: 192625955 - 'actions/upload-artifact@v7.0.2': - ref: 'v7.0.2' - commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' - owner_id: 44036562 - repo_id: 192625955 'actions/upload-pages-artifact@v5.0.0': ref: 'v5.0.0' commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' @@ -104,11 +93,6 @@ dependencies: commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' owner_id: 47606891 repo_id: 331103973 - 'erlef/setup-beam@v1.24.1': - ref: 'v1.24.1' - commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' - owner_id: 47606891 - repo_id: 331103973 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': ref: 'v4.38.0' commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 46ceab3..a3ff99d 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,420 +1,120 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -# This workflow is managed by gh actions-lock. -# This workflow is managed by gh actions-lock. -# Hypatia Neurosymbolic CI/CD Security Scan +# +# Self-contained Hypatia neurosymbolic security scan + SARIF upload (`category: +# hypatia`) satisfying the `Hypatia` code-scanning tool requirement in the +# `Base` branch ruleset on `main`, as well as push, weekly schedule, and manual +# dispatch runs. name: Hypatia Security Scan on: - push: - branches: [ main, master, develop ] pull_request: - branches: [ main, master ] + push: + branches: [main, master, develop] schedule: - - cron: '0 0 * * 0' # Weekly on Sunday + - cron: '0 0 * * 0' workflow_dispatch: -# Estate guardrail: cancel superseded runs so re-pushes don't pile up -# queued runs across the estate. Safe here because this workflow only -# performs read-only checks/lint/test/scan with no publish or mutation. + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read - # security-events: write serves two purposes (write implies read): - # 1. read — lets the built-in GITHUB_TOKEN query this repo's own - # Dependabot alerts via the Hypatia DependabotAlerts rule - # (DA001-DA004). Without read, `scan_from_path` gets HTTP 403 - # and the rule silently returns no findings. - # See 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md. - # 2. write — lets the "Upload SARIF to code scanning" step publish - # Hypatia findings to the Security → Code scanning page so they - # are triaged/deduplicated like CodeQL alerts instead of living - # only in a build artifact nobody is required to look at. - # See hyperpolymath/burble#35 (SARIF integration). - # This is a single-job workflow, so job-level scoping would not - # narrow the grant further; it stays workflow-level and documented. - security-events: write - # pull-requests: write lets the advisory "Comment on PR with findings" - # step post its summary. Without it the built-in GITHUB_TOKEN gets - # "Resource not accessible by integration" and (absent continue-on-error) - # hard-fails the scan — exactly what the gate-decoupling design forbids. - pull-requests: write jobs: - scan: + hypatia: name: Hypatia Neurosymbolic Analysis - runs-on: ubuntu-latest - # Bound the job: clone + Elixir build + scan is minutes, not hours. - # Caps a hung clone/build below the 6-hour default. + runs-on: ubuntu-24.04 timeout-minutes: 20 - + permissions: + contents: read + security-events: write + env: + HYPATIA_SHA: 'd73f76b5cb7a35e1aa1024c916aee7cb4f9b141e' steps: - name: Checkout repository - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # Full history for better pattern analysis + fetch-depth: 0 + persist-credentials: false - - name: Setup Elixir for Hypatia scanner - uses: erlef/setup-beam@v1.24.1 + - name: Setup Elixir 1.19.4 / OTP 28.3 for Hypatia scanner + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: - elixir-version: '1.18' - otp-version: '27' + elixir-version: '1.19.4' + otp-version: '28.3' - - name: Clone Hypatia - run: | - if [ ! -d "$HOME/hypatia" ]; then - git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" - fi + - name: Cache pinned Hypatia build + id: cache-hypatia + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/hypatia + ~/.hex + ~/.mix + key: hypatia-sarif-${{ runner.os }}-otp28-elx1194-d73f76b5cb7a35e1aa1024c916aee7cb4f9b141e - - name: Build Hypatia scanner (if needed) + - name: Build pinned Hypatia scanner run: | - cd "$HOME/hypatia" - if [ ! -f hypatia ]; then - echo "Building hypatia scanner..." - mix deps.get - mix escript.build + set -euo pipefail + if [ ! -x "$HOME/hypatia/hypatia" ] || [ ! -x "$HOME/hypatia/hypatia-cli.sh" ]; then + rm -rf "$HOME/hypatia" + git init "$HOME/hypatia" + git -C "$HOME/hypatia" remote add origin https://github.com/hyperpolymath/hypatia.git + git -C "$HOME/hypatia" fetch --depth=1 origin "$HYPATIA_SHA" + git -C "$HOME/hypatia" checkout --detach FETCH_HEAD + ( + cd "$HOME/hypatia" + mix local.hex --force + mix local.rebar --force + mix deps.get + MIX_NO_PUBSUB=1 mix escript.build + ) + chmod +x "$HOME/hypatia/hypatia" "$HOME/hypatia/hypatia-cli.sh" fi - - name: Run Hypatia scan + - name: Run Hypatia JSON and SARIF analysis id: scan - env: - # Pass the built-in Actions token through to Hypatia so the - # DependabotAlerts rule can query this repo's own alerts. - # For cross-repo scanning (fleet-coordinator scan-supervised), - # a PAT with `security_events` scope is required instead. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo "Scanning repository: ${{ github.repository }}" - - # Run scanner (exits non-zero when findings exist — suppress to continue) - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true - - # Count findings - FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT - - # Extract severity counts - CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json) - HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) - MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json) - - echo "critical=$CRITICAL" >> $GITHUB_OUTPUT - echo "high=$HIGH" >> $GITHUB_OUTPUT - echo "medium=$MEDIUM" >> $GITHUB_OUTPUT - - echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY - echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY - echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY - echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY - echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY - - - name: Upload findings artifact - uses: actions/upload-artifact@v7.0.2 - with: - name: hypatia-findings - path: hypatia-findings.json - retention-days: 90 - - - name: Convert Hypatia findings to SARIF - # Always runs (no findings_count guard): an EMPTY SARIF run is - # valid and intentional — uploading it clears stale Hypatia - # alerts from the code-scanning page when a repo goes clean. - # The converter is dependency-free Node (Node ships on - # ubuntu-latest; no npm install — estate npm ban respected) and - # is hardened against the heterogeneous Hypatia JSON schema: - # most findings are {rule_module,severity,type,file,reason, - # action}; only some carry an integer `line`; `file` may be - # empty or absolute. See lib/hypatia/cli.ex (collect_findings). - run: | - cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS' - const fs = require('fs'); - const path = require('path'); - const crypto = require('crypto'); - - const ws = process.env.GITHUB_WORKSPACE || process.cwd(); - - let findings = []; - try { - const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - if (Array.isArray(parsed)) findings = parsed; - } catch (_) { - // Scanner unavailable / empty / malformed -> empty SARIF. - // Intentionally clears stale alerts rather than erroring. - findings = []; - } - - // Mirrors Hypatia's own "github" annotation mapping - // (lib/hypatia/cli.ex output/2): critical|high -> error, - // medium -> warning, everything else -> note. - const levelFor = (sev) => { - switch (String(sev || '').toLowerCase()) { - case 'critical': - case 'high': return 'error'; - case 'medium': return 'warning'; - default: return 'note'; - } - }; - - // SARIF artifactLocation.uri must be a repo-relative POSIX - // path. Hypatia may emit absolute paths (scanned under - // $GITHUB_WORKSPACE) or "" / "." for repo-level findings. - const relUri = (file) => { - if (!file) return '.'; - let f = String(file); - if (path.isAbsolute(f)) { - const rel = path.relative(ws, f); - f = (rel && !rel.startsWith('..')) ? rel : path.basename(f); - } - f = f.replace(/\\/g, '/').replace(/^\.\//, ''); - return f || '.'; - }; - - const rules = new Map(); - const results = findings.map((f) => { - const mod = String(f.rule_module || 'hypatia'); - const type = String(f.type || 'finding'); - const ruleId = `hypatia/${mod}/${type}`; - const level = levelFor(f.severity); - if (!rules.has(ruleId)) { - rules.set(ruleId, { - id: ruleId, - name: `${mod}.${type}`, - shortDescription: { text: `Hypatia ${mod}: ${type}` }, - defaultConfiguration: { level } - }); - } - const uri = relUri(f.file); - const msg = String(f.reason || f.type || 'Hypatia finding'); - const startLine = - Number.isInteger(f.line) && f.line > 0 ? f.line : 1; - // Stable cross-run fingerprint for dedupe (no line, so a - // moved finding in the same file/rule stays one alert). - const fp = crypto - .createHash('sha256') - .update([ruleId, uri, type, msg].join('|')) - .digest('hex'); - return { - ruleId, - level, - message: { text: msg }, - locations: [ - { - physicalLocation: { - artifactLocation: { uri }, - region: { startLine } - } - } - ], - partialFingerprints: { 'hypatiaFindingHash/v1': fp } - }; - }); - - const sarif = { - $schema: 'https://json.schemastore.org/sarif-2.1.0.json', - version: '2.1.0', - runs: [ - { - tool: { - driver: { - name: 'Hypatia', - informationUri: 'https://github.com/hyperpolymath/hypatia', - rules: Array.from(rules.values()) - } - }, - results - } - ] - }; - - fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2)); - console.log(`hypatia.sarif written: ${results.length} result(s).`); - CJS - node "$RUNNER_TEMP/hypatia-sarif.cjs" - - - name: Upload SARIF to GitHub code scanning - # Fork PRs get a read-only GITHUB_TOKEN, so security-events:write - # is unavailable and upload-sarif cannot publish — skip there - # rather than hard-fail (the push/schedule run on the default - # branch is the authoritative upload). Same-repo PRs and pushes - # do upload. This step is deliberately NOT continue-on-error: - # if the security-surface integration breaks we want a loud red, - # not a silently-ungated scanner (the exact failure mode #35 - # exists to end). The empty-SARIF "clear stale alerts" path is - # handled in the converter above and does not error here. - if: >- - always() && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.fork != true) - uses: github/codeql-action/upload-sarif@v4.39.1 + set -euo pipefail + HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json + jq -e 'type == "array"' hypatia-findings.json >/dev/null + + HYPATIA_FORMAT=sarif "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia.sarif + jq -e '.runs | type == "array" and length > 0' hypatia.sarif >/dev/null + + CRITICAL="$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json)" + HIGH="$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json)" + TOTAL="$(jq 'length' hypatia-findings.json)" + { + echo "critical=$CRITICAL" + echo "high=$HIGH" + echo "total=$TOTAL" + } >> "$GITHUB_OUTPUT" + + - name: Upload Hypatia SARIF to GitHub Code Scanning + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: hypatia.sarif - # Distinct category so Hypatia results coexist with CodeQL's - # (codeql.yml) instead of overwriting them on the same surface. category: hypatia - - name: Submit findings to gitbot-fleet (Phase 2) - if: steps.scan.outputs.findings_count > 0 - # Phase 2 is the collaborative LEARNING side-channel ("bots share - # findings via gitbot-fleet"), not the security gate. The gate is - # the baseline-aware "Check for critical or high-severity issues" - # step below. A fleet-side regression (e.g. the submit script being - # moved/removed) must NEVER hard-fail every consuming repo's scan. - # Same reasoning as the "Comment on PR with findings" step. - # See hyperpolymath/hypatia#213 (gate decoupling) and the exit-127 - # estate-wide breakage when gitbot-fleet/scripts/submit-finding.sh - # no longer existed on the default branch. - continue-on-error: true + - name: Enforce zero critical Hypatia findings env: - # All GitHub context values surface as env vars so the run - # block never interpolates `${{ … }}` inline (closes the - # workflow_audit/unsafe_curl_payload + actions_expression_injection - # findings). - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_SHA: ${{ github.sha }} - FINDINGS_COUNT: ${{ steps.scan.outputs.findings_count }} + CRITICAL_COUNT: ${{ steps.scan.outputs.critical }} + HIGH_COUNT: ${{ steps.scan.outputs.high }} + TOTAL_COUNT: ${{ steps.scan.outputs.total }} run: | - echo "📤 Submitting $FINDINGS_COUNT findings to gitbot-fleet..." - - # Clone gitbot-fleet to temp directory. A clone failure (network, - # repo gone) is non-fatal: learning submission is best-effort. - FLEET_DIR="/tmp/gitbot-fleet-$$" - if ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"; then - echo "::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal)." - exit 0 - fi - - # The submission script's location in gitbot-fleet has drifted - # before (it was absent from the default branch, which exit-127'd - # every consuming repo's scan). Probe known locations rather than - # hard-coding one path, and skip gracefully if none is present. - SUBMIT_SCRIPT="" - for cand in \ - "$FLEET_DIR/scripts/submit-finding.sh" \ - "$FLEET_DIR/scripts/submit_finding.sh" \ - "$FLEET_DIR/bin/submit-finding.sh" \ - "$FLEET_DIR/submit-finding.sh"; do - if [ -f "$cand" ]; then - SUBMIT_SCRIPT="$cand" - break - fi - done - - if [ -z "$SUBMIT_SCRIPT" ]; then - echo "::warning::gitbot-fleet submit-finding script not found at any known path — skipping Phase 2 learning submission (non-fatal). Findings are still uploaded as an artifact and gated below." - rm -rf "$FLEET_DIR" - exit 0 + set -euo pipefail + { + echo "## Hypatia Neurosymbolic Scan" + echo "" + echo "- Critical: ${CRITICAL_COUNT}" + echo "- High: ${HIGH_COUNT}" + echo "- Total findings: ${TOTAL_COUNT}" + } >> "$GITHUB_STEP_SUMMARY" + if [ "$CRITICAL_COUNT" != "0" ]; then + echo "::error::Hypatia reported ${CRITICAL_COUNT} critical finding(s)" + exit 1 fi - - # Run submission script. Pass the findings path as ABSOLUTE — - # the script cd's into its own working dir before reading the - # file, so a relative path would resolve to the wrong place. - # A submission-script failure is logged but non-fatal. - if bash "$SUBMIT_SCRIPT" "$GITHUB_WORKSPACE/hypatia-findings.json"; then - echo "✅ Finding submission complete" - else - echo "::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal)." - fi - - # Cleanup - rm -rf "$FLEET_DIR" - - - name: Check for critical issues - if: steps.scan.outputs.critical > 0 - # GATING POLICY (explicit, by design — not an oversight): - # Hypatia is ADVISORY here. Critical findings are surfaced - # (step annotation + SARIF alert on the code-scanning page + - # PR comment) but do NOT fail this check. Enforcement is - # delegated to the code-scanning surface: tighten by adding a - # branch-protection "required" status on the `hypatia` SARIF - # category, not by reintroducing an `exit 1` here. This keeps - # the gate decision in one auditable place (hypatia#213 gate - # decoupling) and lets a repo opt into fail-on-critical without - # editing this canonical workflow. To change the policy, change - # branch protection — deliberately no commented-out `exit 1`. - run: | - echo "::warning::Hypatia found critical security issue(s) — advisory." - echo "See the Security → Code scanning page (category: hypatia)" - echo "and the hypatia-findings.json artifact for details." - - - name: Generate scan report - run: | - cat << EOF > hypatia-report.md - # Hypatia Security Scan Report - - **Repository:** ${{ github.repository }} - **Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") - **Commit:** ${{ github.sha }} - - ## Summary - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.scan.outputs.critical }} | - | High | ${{ steps.scan.outputs.high }} | - | Medium | ${{ steps.scan.outputs.medium }} | - | **Total**| ${{ steps.scan.outputs.findings_count }} | - - ## Next Steps - - 1. Triage findings on the **Security → Code scanning** page - (SARIF category \`hypatia\`) — dismiss/track them there like - CodeQL alerts. - 2. The full finding set is also attached as the - \`hypatia-findings.json\` build artifact for offline review. - 3. Findings are **advisory** today (surfaced, not gated); the - gating policy is documented in the workflow's "Check for - critical issues" step. - - ## Learning - - These findings feed Hypatia's learning engine to improve future rules. - - --- - *Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence* - EOF - - cat hypatia-report.md >> $GITHUB_STEP_SUMMARY - - - name: Comment on PR with findings - if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0 - # Advisory only — posting findings as a PR comment must never gate - # the scan (hypatia#213 gate decoupling). Belt-and-braces alongside - # the pull-requests: write permission above: a token/API hiccup or - # a fork PR (read-only token) skips the comment, not the check. - continue-on-error: true - uses: actions/github-script@v9.0.0 - with: - script: | - const fs = require('fs'); - const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - - const critical = findings.filter(f => f.severity === 'critical').length; - const high = findings.filter(f => f.severity === 'high').length; - - let comment = `## 🔍 Hypatia Security Scan\n\n`; - comment += `**Findings:** ${findings.length} issues detected\n\n`; - comment += `| Severity | Count |\n|----------|-------|\n`; - comment += `| 🔴 Critical | ${critical} |\n`; - comment += `| 🟠 High | ${high} |\n`; - comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`; - - if (critical > 0) { - comment += `⚠️ **Action Required:** Critical security issues found!\n\n`; - } - - comment += `
View findings\n\n`; - comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`; - comment += `
\n\n`; - comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`; - - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); diff --git a/.github/workflows/label-triage.yml b/.github/workflows/label-triage.yml index 814a192..3b8aa96 100644 --- a/.github/workflows/label-triage.yml +++ b/.github/workflows/label-triage.yml @@ -46,11 +46,12 @@ permissions: jobs: triage: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 # Hypatia workflow_audit/missing_timeout_minutes (alert #68) steps: - name: Classify and label env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} NUM: ${{ github.event.issue.number || inputs.issue }} run: | set -uo pipefail diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 83ab941..c496ffd 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -32,11 +32,12 @@ permissions: jobs: sync: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 # Hypatia workflow_audit/missing_timeout_minutes (alert #69) steps: - name: Apply canonical labels env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} # ⚠ LOAD-BEARING. This workflow deliberately does not check the repo # out (no `uses:`, so no actions.lock entry can drift), which means # `gh label create` / `gh label edit` have no git remote to infer a