Repository navigation
Status Truth #29
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: Status Truth | |
| on: | |
| schedule: | |
| - cron: '0 21 * * 0' # Every Sunday at 21:00 UTC | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: 'Dry-run mode: count planned actions but open no issues' | |
| required: false | |
| default: 'true' | |
| type: choice | |
| options: | |
| - 'true' | |
| - 'false' | |
| open_prs: | |
| description: Execute correction PR actions (requires STATUS_TRUTH_PRS_ENABLED variable) | |
| required: false | |
| default: 'false' | |
| type: choice | |
| options: | |
| - 'false' | |
| - 'true' | |
| # Default permissions: read-only. The open job overrides to issues: write. | |
| permissions: | |
| contents: read | |
| # Serialize runs; never cancel an in-progress run so a detect-to-open sequence | |
| # is never interrupted halfway through proposal planning. | |
| concurrency: | |
| group: status-truth | |
| cancel-in-progress: false | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # Detect job: read-only scan of public status-truth claims. | |
| # Emits a structured report artifact and counts-only summary. | |
| # No write credentials; no issue mutations. | |
| # --------------------------------------------------------------------------- | |
| detect: | |
| name: Detect status-truth drift | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Detect job needs read access to contents, issues, and pull-requests for | |
| # API-backed claim resolution (PR/issue/release state lookups). | |
| permissions: | |
| contents: read | |
| issues: read | |
| pull-requests: read | |
| steps: | |
| - name: ⤵ Checkout Branch | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 1 | |
| - name: 📦 Setup | |
| uses: ./.github/actions/setup | |
| # Best-effort metadata overlay for parity with workflow consumers. The detect | |
| # step does not load private-token metadata; public writes are gated in `open`. | |
| - name: ⤵ Overlay metadata from data branch | |
| run: | | |
| set -euo pipefail | |
| if git ls-remote --exit-code origin data >/dev/null 2>&1; then | |
| git fetch --no-tags origin data | |
| git checkout origin/data -- metadata/ | |
| else | |
| echo "data branch not yet established; skipping metadata overlay." | |
| fi | |
| # Best-effort rollout snapshot for rollout-tracker-status resolution. | |
| # Raw snapshot output is streamed directly into jq and never echoed to logs. | |
| - name: 📸 Load rollout snapshot (best-effort) | |
| id: rollout-snapshot | |
| env: | |
| # Same credential class as gateway-rollout-tracker; scoped to this read-only snapshot step. | |
| GH_TOKEN: ${{ secrets.FRO_BOT_PAT }} | |
| ROLLOUT_TRACKER_SNAPSHOT_PATH: ${{ runner.temp }}/rollout-snapshot.json | |
| # Only .snapshot is consumed here; an empty body skips the ~1 MB tracker-comment fetch. | |
| ROLLOUT_TRACKER_COMMENT_BODY: '' | |
| run: | | |
| set -euo pipefail | |
| snapshot_path="$ROLLOUT_TRACKER_SNAPSHOT_PATH" | |
| tmp_snapshot="${snapshot_path}.tmp" | |
| snapshot_error="${RUNNER_TEMP}/rollout-snapshot-error.log" | |
| trap 'rm -f "$tmp_snapshot" "$snapshot_error"' EXIT | |
| if node scripts/rollout-tracker-snapshot.ts 2>"$snapshot_error" | jq -e '.snapshot' > "$tmp_snapshot"; then | |
| mv "$tmp_snapshot" "$snapshot_path" | |
| item_count=$(jq '.items | length' "$snapshot_path" 2>/dev/null || echo 0) | |
| echo "rollout-snapshot: loaded snapshot with ${item_count} item(s)" | |
| echo "snapshot_path=${snapshot_path}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "rollout-snapshot: snapshot command unavailable; rollout-tracker claims will be unresolved" | |
| fi | |
| - name: 🔍 Detect status-truth drift | |
| id: detect | |
| env: | |
| # Read-only GITHUB_TOKEN: detect step resolves current-repo PR/issue/release | |
| # state. Cross-repo references are classified as unavailable (unresolved). | |
| # Without a token, all API claims are unresolved (no fake clean report). | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| STATUS_TRUTH_REPORT_PATH: ${{ runner.temp }}/status-truth-report.json | |
| # Snapshot path from the pre-detect step (empty string if unavailable). | |
| # loadRolloutSnapshot in the script treats empty string as absent. | |
| STATUS_TRUTH_ROLLOUT_SNAPSHOT_PATH: ${{ steps.rollout-snapshot.outputs.snapshot_path || '' }} | |
| # Scheduled runs default to dry-run=true; only workflow_dispatch can set false. | |
| STATUS_TRUTH_DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }} | |
| run: node scripts/status-truth-detect.ts | |
| - name: 📤 Upload report artifact | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: status-truth-report | |
| # Artifact is workflow-internal (detect → open). It may contain public | |
| # finding fields needed by the open step (e.g. fingerprints, verdicts, | |
| # proposal-eligible flags). It must not contain private identity, source | |
| # snippets, API responses, credentials, or rendered proposal bodies. | |
| path: ${{ runner.temp }}/status-truth-report.json | |
| retention-days: 7 | |
| - name: 📋 Write detect summary | |
| if: always() | |
| env: | |
| STATUS_TRUTH_REPORT_PATH: ${{ runner.temp }}/status-truth-report.json | |
| run: | | |
| set -euo pipefail | |
| report="$STATUS_TRUTH_REPORT_PATH" | |
| { | |
| echo "## Status Truth Detect Summary" | |
| echo "" | |
| echo "| Field | Value |" | |
| echo "| --- | --- |" | |
| if [[ -s "$report" ]]; then | |
| echo "| Status | $(jq -r '.status // "unknown"' "$report") |" | |
| echo "| Scan complete | $(jq -r '.scan_complete // false' "$report") |" | |
| echo "| Total claims | $(jq '.counts.total // 0' "$report") |" | |
| echo "| Current | $(jq '.counts.current // 0' "$report") |" | |
| echo "| Drifted | $(jq '.counts.drifted // 0' "$report") |" | |
| echo "| Unresolved | $(jq '.counts.unresolved // 0' "$report") |" | |
| echo "| Unsafe | $(jq '.counts.unsafe // 0' "$report") |" | |
| echo "| Proposal eligible | $(jq '.counts.proposal_eligible // 0' "$report") |" | |
| else | |
| echo "| Status | (report unavailable) |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # --------------------------------------------------------------------------- | |
| # Open job: consume the report artifact, validate it, and perform proposal | |
| # issue actions. In dry-run mode (the default for scheduled runs) no issues | |
| # are mutated and no app write token is minted. | |
| # Separated from detect to enforce credential isolation. | |
| # --------------------------------------------------------------------------- | |
| open: | |
| name: Open status-truth proposals | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: detect | |
| if: always() | |
| # Read-only GITHUB_TOKEN permissions for the open job. | |
| # Live issue writes use the scoped app token minted in the "Mint write token" | |
| # step (workflow_dispatch + dry_run=false only). Scheduled dry-runs never | |
| # mint the app token and never mutate issues, so issues:write on the job | |
| # token is not needed and would grant unnecessary privilege. | |
| permissions: | |
| contents: read | |
| issues: read | |
| steps: | |
| - name: ⤵ Checkout Branch | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 1 | |
| - name: 📦 Setup | |
| uses: ./.github/actions/setup | |
| # Overlay metadata/ from the data branch so the privacy gate reads the | |
| # authoritative private-repo list from metadata/repos.yaml. The overlay is | |
| # best-effort; `loadPrivateTokensFromDisk()` is the fail-closed chokepoint. | |
| - name: ⤵ Overlay metadata from data branch | |
| run: | | |
| set -euo pipefail | |
| if git ls-remote --exit-code origin data >/dev/null 2>&1; then | |
| git fetch --no-tags origin data | |
| git checkout origin/data -- metadata/ | |
| else | |
| echo "data branch not yet established; skipping metadata overlay." | |
| fi | |
| - name: 📥 Download report artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: status-truth-report | |
| path: ${{ runner.temp }} | |
| # Mint a scoped app token only for live writes. Scheduled runs default to dry-run | |
| # and use the job token for read-only planning. | |
| - name: 🔑 Mint write token | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == 'false' }} | |
| id: app-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| app-id: ${{ secrets.APPLICATION_ID }} | |
| private-key: ${{ secrets.APPLICATION_PRIVATE_KEY }} | |
| # Scope to this repo only — smallest blast radius. | |
| repositories: ${{ github.event.repository.name }} | |
| # Phase 1 permissions: issue-write + read-only content access. | |
| permission-issues: write | |
| permission-contents: read | |
| - name: 📬 Open status-truth proposals | |
| id: open | |
| env: | |
| GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| STATUS_TRUTH_REPORT_PATH: ${{ runner.temp }}/status-truth-report.json | |
| STATUS_TRUTH_OPEN_RESULT_PATH: ${{ runner.temp }}/status-truth-open-result.json | |
| # Scheduled runs default to dry-run=true; only workflow_dispatch can set false. | |
| STATUS_TRUTH_DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }} | |
| run: node scripts/status-truth-proposals.ts | |
| - name: 📋 Write open summary | |
| if: always() | |
| env: | |
| STATUS_TRUTH_OPEN_RESULT_PATH: ${{ runner.temp }}/status-truth-open-result.json | |
| run: | | |
| set -euo pipefail | |
| result="$STATUS_TRUTH_OPEN_RESULT_PATH" | |
| { | |
| echo "## Status Truth Open Summary" | |
| echo "" | |
| echo "| Field | Value |" | |
| echo "| --- | --- |" | |
| if [[ -s "$result" ]]; then | |
| echo "| Dry run | $(jq -r 'if .dryRun then "true" else "false" end' "$result") |" | |
| echo "| Label gate failed | $(jq -r 'if .labelGateFailed then "true" else "false" end' "$result") |" | |
| echo "| Opened | $(jq '.counts.opened // 0' "$result") |" | |
| echo "| Updated | $(jq '.counts.updated // 0' "$result") |" | |
| echo "| Reopened | $(jq '.counts.reopened // 0' "$result") |" | |
| echo "| Closed | $(jq '.counts.closed // 0' "$result") |" | |
| echo "| Suppressed | $(jq '.counts.suppressed // 0' "$result") |" | |
| echo "| Failed | $(jq '.counts.failed // 0' "$result") |" | |
| echo "| Same-run deduplicated | $(jq '.plannedCounts.sameRunDeduplicated // 0' "$result") |" | |
| echo "| Version rejected | $(jq '.plannedCounts.versionRejected // 0' "$result") |" | |
| echo "| Blocked | $(jq '(.plannedCounts.blocked // 0)' "$result") |" | |
| echo "| Overflowed | $(jq '(.plannedCounts.overflowed // 0)' "$result") |" | |
| echo "| Needs outcome cooldown | $(jq '(.plannedCounts.needsOutcomeCooldown // 0)' "$result") |" | |
| # Outcome counts (read-model: current state of all proposal issues) | |
| if jq -e '.outcomeCounts' "$result" >/dev/null 2>&1; then | |
| echo "" | |
| echo "### Proposal outcome counts" | |
| echo "" | |
| echo "| Outcome | Count |" | |
| echo "| --- | --- |" | |
| echo "| Proposed pending | $(jq '.outcomeCounts.proposedPending // 0' "$result") |" | |
| echo "| Accepted | $(jq '.outcomeCounts.explicitAccepted // 0' "$result") |" | |
| echo "| Rejected | $(jq '.outcomeCounts.explicitRejected // 0' "$result") |" | |
| echo "| False positive | $(jq '.outcomeCounts.falsePositive // 0' "$result") |" | |
| echo "| Resolved positive | $(jq '.outcomeCounts.resolvedPositive // 0' "$result") |" | |
| echo "| Superseded | $(jq '.outcomeCounts.superseded // 0' "$result") |" | |
| echo "| Needs outcome | $(jq '.outcomeCounts.needsOutcome // 0' "$result") |" | |
| echo "| Conflicting labels | $(jq '.outcomeCounts.conflictingLabels // 0' "$result") |" | |
| echo "| Malformed outcome | $(jq '.outcomeCounts.malformedOutcome // 0' "$result") |" | |
| fi | |
| # Planned per-kind counts (from planner; counts-only, no paths or fingerprints) | |
| kind_count=$(jq '.plannedCountsByKind | length' "$result" 2>/dev/null || echo 0) | |
| if [[ "$kind_count" -gt 0 ]]; then | |
| echo "" | |
| echo "### Planned counts by claim kind" | |
| echo "" | |
| echo "| Kind | Opened | Updated | Reopened | Closed | Suppressed |" | |
| echo "| --- | --- | --- | --- | --- | --- |" | |
| jq -r ' | |
| .plannedCountsByKind | |
| | to_entries[] | |
| | "| \(.key) | \(.value.opened // 0) | \(.value.updated // 0) | \(.value.reopened // 0) | \(.value.closed // 0) | \(.value.suppressed // 0) |" | |
| ' "$result" | |
| fi | |
| # Proposal outcomes by claim kind (read-model; counts-only, no paths or fingerprints) | |
| outcome_kind_count=$(jq '.outcomeCountsByKind | length' "$result" 2>/dev/null || echo 0) | |
| if [[ "$outcome_kind_count" -gt 0 ]]; then | |
| echo "" | |
| echo "### Proposal outcomes by claim kind" | |
| echo "" | |
| echo "| Kind | Pending | Accepted | Rejected | False positive | Resolved | Superseded | Needs outcome | Conflicting | Malformed |" | |
| echo "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |" | |
| jq -r ' | |
| .outcomeCountsByKind | |
| | to_entries[] | |
| | "| \(.key) | \(.value.proposedPending // 0) | \(.value.explicitAccepted // 0) | \(.value.explicitRejected // 0) | \(.value.falsePositive // 0) | \(.value.resolvedPositive // 0) | \(.value.superseded // 0) | \(.value.needsOutcome // 0) | \(.value.conflictingLabels // 0) | \(.value.malformedOutcome // 0) |" | |
| ' "$result" | |
| fi | |
| else | |
| echo "| Status | (open result unavailable) |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # --------------------------------------------------------------------------- | |
| # PRs job: Bounded correction PR execution, gated by three independent keys. | |
| # | |
| # This job only produces real PR actions when ALL of the following hold: | |
| # 1. The repository variable STATUS_TRUTH_PRS_ENABLED is set to 'true'. | |
| # 2. At least one claim kind has been added to GRADUATED_CLAIM_KINDS in | |
| # scripts/status-truth-prs.ts via a reviewed repo change. | |
| # 3. A workflow_dispatch run explicitly sets the open_prs input to 'true'. | |
| # | |
| # Any single key alone produces zero PR actions. Scheduled runs are | |
| # structurally excluded (event-type check) and never open PRs. The script | |
| # re-checks all three keys at startup as defense in depth — the workflow | |
| # if: condition below is not the only enforcement point. | |
| # | |
| # Safety constraints (independently enforced in scripts/status-truth-prs.ts): | |
| # - Ungraduated or disarmed candidates downgrade to proposal-only (no PR actions). | |
| # - Forbidden paths (workflows, hooks, metadata, knowledge, persona, etc.) → proposal-only. | |
| # - Corrected content must re-resolve as current before any write; failure downgrades. | |
| # - At most one open PR per fingerprint; at most one new PR opened per run. | |
| # - Every write independently re-validates the single-file diff and the | |
| # bot-owned correction-branch naming pattern before it happens. | |
| # - Drift-cleared or terminal-labeled proposals close their own PR and delete | |
| # the correction branch; merged PRs are never touched. | |
| # - No merge, approve, automerge, force-push, or retarget actions are possible. | |
| # - Titles, branches, bodies, and comments carry only opaque fingerprint | |
| # digests and normalized fields, and pass the public-output gate. | |
| # - Workflow summaries and logs stay counts-only for PR actions. | |
| # --------------------------------------------------------------------------- | |
| prs: | |
| name: Execute status-truth correction PRs | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [detect, open] | |
| if: ${{ vars.STATUS_TRUTH_PRS_ENABLED == 'true' && github.event_name == 'workflow_dispatch' && github.event.inputs.open_prs == 'true' }} | |
| # Read-only job token; write authority is minted separately below, | |
| # scoped at mint time to this repo and this run only. | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: ⤵ Checkout Branch | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 1 | |
| - name: 📦 Setup | |
| uses: ./.github/actions/setup | |
| - name: 📥 Download report artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: status-truth-report | |
| path: ${{ runner.temp }} | |
| # Mint a scoped app token only inside this job, only on armed dispatches. | |
| # Adds contents:write and pull-requests:write for branch/commit/PR | |
| # mutations. issues:write is required because executeStatusTruthPrActions | |
| # closes stale/terminal correction PRs via issues.createComment (PR | |
| # comments are Issues API comments); pull-requests:write alone cannot | |
| # create the closure comment. | |
| - name: 🔑 Mint write token | |
| id: app-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| app-id: ${{ secrets.APPLICATION_ID }} | |
| private-key: ${{ secrets.APPLICATION_PRIVATE_KEY }} | |
| # Scope to this repo only — smallest blast radius. | |
| repositories: ${{ github.event.repository.name }} | |
| permission-contents: write | |
| permission-pull-requests: write | |
| permission-issues: write | |
| # Mint a separate, strictly read-only app token for fetch/list calls | |
| # (existing-PR discovery, terminal-fingerprint issue scan). Keeps the | |
| # write token's blast radius limited to the write calls that actually | |
| # need it — the fetch path never needs write scope. | |
| - name: 🔑 Mint fetch token (read-only) | |
| id: fetch-token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| app-id: ${{ secrets.APPLICATION_ID }} | |
| private-key: ${{ secrets.APPLICATION_PRIVATE_KEY }} | |
| # Scope to this repo only — smallest blast radius. | |
| repositories: ${{ github.event.repository.name }} | |
| permission-contents: read | |
| permission-pull-requests: read | |
| permission-issues: read | |
| - name: 🛠 Execute status-truth correction PRs | |
| id: prs | |
| env: | |
| GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} | |
| STATUS_TRUTH_FETCH_TOKEN: ${{ steps.fetch-token.outputs.token }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| STATUS_TRUTH_REPORT_PATH: ${{ runner.temp }}/status-truth-report.json | |
| STATUS_TRUTH_PRS_RESULT_PATH: ${{ runner.temp }}/status-truth-prs-result.json | |
| STATUS_TRUTH_DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }} | |
| STATUS_TRUTH_PRS_ENABLED: ${{ vars.STATUS_TRUTH_PRS_ENABLED }} | |
| STATUS_TRUTH_PRS_DISPATCH_INPUT: ${{ github.event.inputs.open_prs || 'false' }} | |
| run: node scripts/status-truth-prs.ts | |
| - name: 📋 Write PR execution summary | |
| if: always() | |
| env: | |
| STATUS_TRUTH_PRS_RESULT_PATH: ${{ runner.temp }}/status-truth-prs-result.json | |
| run: | | |
| set -euo pipefail | |
| result="$STATUS_TRUTH_PRS_RESULT_PATH" | |
| { | |
| echo "## Status Truth PR Execution Summary" | |
| echo "" | |
| echo "| Field | Value |" | |
| echo "| --- | --- |" | |
| if [[ -s "$result" ]]; then | |
| echo "| Armed | $(jq -r 'if .armed then "true" else "false" end' "$result") |" | |
| echo "| Dry run | $(jq -r 'if .dryRun then "true" else "false" end' "$result") |" | |
| echo "| Planned | $(jq '.plannedCounts.prActionsPlanned // 0' "$result") |" | |
| echo "| Downgraded to proposal-only | $(jq '.plannedCounts.downgradedToProposalOnly // 0' "$result") |" | |
| echo "| Path forbidden | $(jq '.plannedCounts.pathForbidden // 0' "$result") |" | |
| echo "| Privacy gate blocked | $(jq '.plannedCounts.privacyGateBlocked // 0' "$result") |" | |
| echo "| Overflow | $(jq '.plannedCounts.overflow // 0' "$result") |" | |
| echo "| Version rejected | $(jq '.plannedCounts.versionRejected // 0' "$result") |" | |
| echo "| Opened | $(jq '.executedCounts.opened // 0' "$result") |" | |
| echo "| Closed | $(jq '.executedCounts.closed // 0' "$result") |" | |
| echo "| Rediscovered | $(jq '.executedCounts.rediscovered // 0' "$result") |" | |
| echo "| Downgraded (execution) | $(jq '.executedCounts.downgraded // 0' "$result") |" | |
| echo "| Safety refused | $(jq '.executedCounts.safetyRefused // 0' "$result") |" | |
| echo "| Failed | $(jq '.executedCounts.failed // 0' "$result") |" | |
| echo "| Branch delete failed | $(jq '.executedCounts.branchDeleteFailed // 0' "$result") |" | |
| echo "| Would open (dry-run) | $(jq '.executedCounts.wouldOpen // 0' "$result") |" | |
| echo "| Would close (dry-run) | $(jq '.executedCounts.wouldClose // 0' "$result") |" | |
| echo "| Would rediscover (dry-run) | $(jq '.executedCounts.wouldRediscover // 0' "$result") |" | |
| echo "| Terminal fingerprints skipped | $(jq '.terminalFingerprintsSkipped // 0' "$result") |" | |
| echo "| Terminal fingerprint duplicates | $(jq '.terminalFingerprintDuplicates // 0' "$result") |" | |
| echo "| Error | $(jq -r '.error // "none"' "$result") |" | |
| else | |
| echo "| Status | (PR execution result unavailable) |" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" |