diff --git a/.github/workflows/checkpoint-audit.yml b/.github/workflows/checkpoint-audit.yml new file mode 100644 index 0000000000..0aa1c21428 --- /dev/null +++ b/.github/workflows/checkpoint-audit.yml @@ -0,0 +1,99 @@ +name: Checkpoint Audit + +on: + schedule: + - cron: "0 7 * * *" # 07:00 UTC daily (GitHub does not honor sub-daily cron reliably) + workflow_dispatch: + inputs: + window: + description: 'git --since window to audit' + default: '24 hours ago' + +permissions: + contents: read + actions: read # required for actions/upload-artifact under a restricted token (matches e2e.yml) + +concurrency: + group: checkpoint-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + steps: + # The checkpoint remote (entireio/cli-checkpoints) is public, so the audit's + # read-only `git ls-remote` needs no credentials. If it is ever made private, + # set ENTIRE_CHECKPOINT_TOKEN on the audit step from a token with read access. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Fetch all branches + run: git fetch --all --prune + + # Capture the script's exit code (0 = all present, 1 = missing, 2 = + # remote/setup error) without failing the step, so the report, Slack, and + # gate steps below can distinguish "missing" from "could not run". + - name: Run checkpoint audit + id: audit + env: + AUDIT_WINDOW: ${{ github.event.inputs.window || '24 hours ago' }} + run: | + set +e + scripts/checkpoint-audit.sh + echo "code=$?" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Upload audit report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: checkpoint-audit-report + path: | + checkpoint-audit-report.md + checkpoint-audit-report.json + retention-days: 7 + if-no-files-found: ignore + + # Distinct wording per outcome: exit 1 asserts missing checkpoints (a report + # artifact exists); exit 2 is a remote/setup failure with no report, so the + # alert must not claim checkpoints are missing (avoids false positives). + - name: Compose Slack message + id: msg + if: ${{ always() && steps.audit.outputs.code != '0' }} + run: | + if [ "${{ steps.audit.outputs.code }}" = "1" ]; then + echo 'text=:red_circle: *Checkpoint audit: missing checkpoints*\n\nOne or more commits reference a checkpoint that is not on the checkpoint remote. See the report artifact for the full table.' >> "$GITHUB_OUTPUT" + else + echo 'text=:red_circle: *Checkpoint audit failed to run*\n\nThe audit could not complete (remote or setup error); no report was produced. See the run log.' >> "$GITHUB_OUTPUT" + fi + + - name: Notify Slack + if: ${{ always() && steps.audit.outputs.code != '0' }} + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + webhook: ${{ secrets.E2E_SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + { + "attachments": [ + { + "color": "#d50200", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "${{ steps.msg.outputs.text }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run details>" + } + } + ] + } + ] + } + + - name: Fail the job on audit error + if: ${{ steps.audit.outputs.code != '0' }} + run: | + echo "::error::checkpoint audit exited ${{ steps.audit.outputs.code }} (1 = missing checkpoints, 2 = remote/setup error)" + exit 1 diff --git a/.gitignore b/.gitignore index 0ec1a656d8..543e8b2142 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,7 @@ tmp/ entire-external-cp-test/ /skills/ /git-remote-entire + +# Runtime output from scripts/checkpoint-audit.sh (never commit) +checkpoint-audit-report.md +checkpoint-audit-report.json diff --git a/mise.toml b/mise.toml index a9dfb22166..34d9f02fb1 100644 --- a/mise.toml +++ b/mise.toml @@ -30,6 +30,10 @@ mise run test:e2e:canary description = "Run formatting, linting, and CI tests" depends = ["fmt", "lint", "test:ci"] +[tasks."checkpoint:audit"] +description = "Audit that recent commits' checkpoints exist on the checkpoint remote (set ENTIRE_CHECKPOINT_TOKEN)" +run = "scripts/checkpoint-audit.sh" + [tasks."build:windows"] description = "Cross-compile for Windows amd64" run = "CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o entire.exe ./cmd/entire/" diff --git a/scripts/checkpoint-audit.sh b/scripts/checkpoint-audit.sh new file mode 100755 index 0000000000..751c262934 --- /dev/null +++ b/scripts/checkpoint-audit.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Audit checkpoint availability on the checkpoint remote. +# +# Walks every commit created within a time window across all branches, extracts +# each `Entire-Checkpoint:` trailer, and checks whether the matching checkpoint +# ref exists on the checkpoint remote (github.com/). Any commit +# whose checkpoint ref is missing from the remote is reported with context and +# makes the script exit non-zero. +# +# This catches the failure mode where a commit reaches origin but its git-refs +# checkpoint ref (refs/entire/checkpoints//) was never pushed to the +# checkpoint remote — the checkpoint is then unrecoverable on any other machine +# (e.g. `entire trail resume` / `entire explain` fail with "checkpoint not found"). +# +# Trailer parsing and ref-set membership use git's own primitives rather than +# hand-rolled regex/shard math: `git show --format='%(trailers:...)'` parses +# trailers (handling squash-merge commits with multiple checkpoint trailers), and +# the checkpoint ID is simply the ref leaf, so membership is a plain string-set +# lookup that works for both legacy-hex and ULID IDs. +# +# Env (all optional except the token for a private remote): +# ENTIRE_CHECKPOINT_TOKEN GitHub token with read access to the checkpoint repo. +# Sent as an RFC 7617 basic auth header (matching the +# CLI), so it never appears in a remote URL. +# CHECKPOINT_REPO owner/repo of the checkpoint remote +# (default: entireio/cli-checkpoints). +# AUDIT_WINDOW git `--since` window (default: "24 hours ago"). +# AUDIT_REPORT_FILE markdown report sink (default: checkpoint-audit-report.md). +# AUDIT_JSON_FILE machine-readable report sink (default: checkpoint-audit-report.json). +# GITHUB_STEP_SUMMARY if set, the markdown report is appended to it. +# +# Exit codes: 0 = all present, 1 = one or more checkpoints missing, 2 = setup or +# remote error (a broken remote must never read as "0 missing"). + +CHECKPOINT_REPO="${CHECKPOINT_REPO:-entireio/cli-checkpoints}" +AUDIT_WINDOW="${AUDIT_WINDOW:-24 hours ago}" +AUDIT_REPORT_FILE="${AUDIT_REPORT_FILE:-checkpoint-audit-report.md}" +AUDIT_JSON_FILE="${AUDIT_JSON_FILE:-checkpoint-audit-report.json}" +TOKEN="${ENTIRE_CHECKPOINT_TOKEN:-}" + +CHECKPOINT_URL="https://github.com/${CHECKPOINT_REPO}.git" + +REMOTE_IDS_FILE=$(mktemp "${TMPDIR:-/tmp}/checkpoint-audit.XXXXXX") +ROWS_FILE=$(mktemp "${TMPDIR:-/tmp}/checkpoint-audit.XXXXXX") +trap 'rm -f "$REMOTE_IDS_FILE" "$ROWS_FILE"' EXIT + +# json_str emits a JSON string literal. Git commit subjects can legally contain +# tabs and other control characters, so escape backslash, double quote, and the +# C0 controls that would otherwise produce invalid JSON. +json_str() { + local s=${1//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\t'/\\t} + s=${s//$'\r'/\\r} + s=${s//$'\n'/\\n} + s=${s//$'\b'/\\b} + s=${s//$'\f'/\\f} + printf '"%s"' "$s" +} + +# 1. Enumerate the checkpoint refs present on the remote (names only, no object +# transfer). The ID is the ref leaf: refs/entire/checkpoints//. +echo "Enumerating checkpoint refs on ${CHECKPOINT_REPO} ..." >&2 +if [ -n "$TOKEN" ]; then + auth_b64=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n') + # Standard header name/scheme casing, matching the CLI (Authorization: Basic). + remote_refs=$(git -c "http.extraheader=Authorization: Basic ${auth_b64}" \ + ls-remote "$CHECKPOINT_URL" 'refs/entire/checkpoints/*') || { + echo "::error::failed to ls-remote ${CHECKPOINT_REPO} (check ENTIRE_CHECKPOINT_TOKEN and repo access)" >&2 + exit 2 + } +else + remote_refs=$(git ls-remote "$CHECKPOINT_URL" 'refs/entire/checkpoints/*') || { + echo "::error::failed to ls-remote ${CHECKPOINT_REPO} (no ENTIRE_CHECKPOINT_TOKEN set; is the repo private?)" >&2 + exit 2 + } +fi + +printf '%s\n' "$remote_refs" \ + | awk '$2 ~ /^refs\/entire\/checkpoints\// { id = $2; sub(/.*\//, "", id); print id }' \ + | sort -u > "$REMOTE_IDS_FILE" +remote_count=$(grep -c . "$REMOTE_IDS_FILE" || true) + +# 2. Candidate commits: every branch commit in the window (local heads + remotes), +# de-duplicated while preserving order. A git-log failure (e.g. run outside a +# git repo) is a setup error, not "0 missing" — surface it as exit 2 rather +# than letting git's raw 128 escape. +if ! commits=$(git log --branches --remotes --since="$AUDIT_WINDOW" --format='%H'); then + echo "::error::failed to enumerate commits (is this a git repository?)" >&2 + exit 2 +fi +commits=$(printf '%s\n' "$commits" | awk '!seen[$0]++') + +# 3. For each commit, diff its checkpoint trailers against the remote set. +commit_count=0 +cp_count=0 +missing_count=0 +while IFS= read -r sha; do + [ -z "$sha" ] && continue + commit_count=$((commit_count + 1)) + cps=$(git show -s --format='%(trailers:key=Entire-Checkpoint,valueonly=true)' "$sha") + while IFS= read -r cp; do + cp="${cp//[[:space:]]/}" + [ -z "$cp" ] && continue + cp_count=$((cp_count + 1)) + if grep -Fxq "$cp" "$REMOTE_IDS_FILE"; then + continue + fi + missing_count=$((missing_count + 1)) + meta=$(git show -s --format='%h%x1f%an%x1f%aI%x1f%s' "$sha") + short=${meta%%$'\x1f'*}; meta=${meta#*$'\x1f'} + author=${meta%%$'\x1f'*}; meta=${meta#*$'\x1f'} + cdate=${meta%%$'\x1f'*}; subject=${meta#*$'\x1f'} + # `|| true`: grep exits 1 when every branch is filtered out (or there are + # none), which would otherwise abort the whole run under `set -e`. Join with + # a single-char delimiter then expand to ", " — `paste -sd', '` treats the + # delimiter as a circular char list and would alternate "," and " ". + branches=$(git branch -a --contains "$sha" --format='%(refname:short)' 2>/dev/null \ + | sed -e 's#^remotes/##' -e 's#^origin/##' \ + | grep -v '^entire/' \ + | awk 'NF && !s[$0]++' \ + | paste -sd',' - \ + | sed 's/,/, /g' || true) + printf '%s\x1e%s\x1e%s\x1e%s\x1e%s\x1e%s\n' \ + "$cp" "$short" "$author" "${branches:-?}" "$cdate" "$subject" >> "$ROWS_FILE" + done <> "$GITHUB_STEP_SUMMARY" +fi + +# 5. Machine-readable report for the artifact. +{ + echo "[" + first=1 + while IFS=$'\x1e' read -r cp short author branches cdate subject; do + if [ "$first" -eq 1 ]; then first=0; else echo ","; fi + printf ' {"checkpoint":%s,"commit":%s,"author":%s,"branches":%s,"date":%s,"subject":%s}' \ + "$(json_str "$cp")" "$(json_str "$short")" "$(json_str "$author")" \ + "$(json_str "$branches")" "$(json_str "$cdate")" "$(json_str "$subject")" + done < "$ROWS_FILE" + echo + echo "]" +} > "$AUDIT_JSON_FILE" + +if [ "$missing_count" -gt 0 ]; then + echo "::error::${missing_count} checkpoint(s) missing from ${CHECKPOINT_REPO}" >&2 + exit 1 +fi +echo "All ${cp_count} checkpoint(s) present on ${CHECKPOINT_REPO}." >&2 +exit 0