diff --git a/.github/workflows/dev-enforcement.yml b/.github/workflows/dev-enforcement.yml index 4f0ae6ceaf..fa23e6ebfa 100644 --- a/.github/workflows/dev-enforcement.yml +++ b/.github/workflows/dev-enforcement.yml @@ -1,62 +1,117 @@ name: Dev Workflow Enforcement + +# This gate had never run a single job. Every layer was broken independently, +# and each failed in a way that looked like the layer below it. +# +# 1. The YAML did not parse. In the pr-status job `steps:` was indented inside +# `if:`, and further down `run:` inside `if:`. GitHub compiled zero jobs and +# titled the run by its file path rather than by `name:` — which is what a +# red X on ".github/workflows/dev-enforcement.yml" means. +# 2. No job had an actions/checkout step, so `git log -1` ran outside a +# repository and .trinity/dev_session.json was never on disk. +# 3. `$COMMIT` was never assigned, so the issue-ID test read an empty string and +# could only ever print "Missing issue ID" — including for a correct commit. +# 4. The commit-format regex was `^\[a-f]+\(...`, which requires a literal `[` +# at the start of the subject. No conventional commit begins with one, and +# its result was piped to `head -1` and discarded regardless. +# 5. session-check gated CI on .trinity/dev_session.json being TESTED or +# COMMITTED. That is local working state; CI cannot observe a dev session, +# and on main it asserts whatever state happened to be committed. +# +# REPORTING, NOT BLOCKING — and that is a deliberate difference from the same +# gate in trinity-fpga, which does block on pull requests. +# +# Measured before choosing, on the last 60 non-merge commits of each main: +# +# trinity-fpga 3 / 60 ( 5%) would fail +# trinity 25 / 60 (42%) would fail +# +# This repository's practice is largely prose subjects, many in Russian +# ("Локализовать ветвь дерева технологий (#785)"), while CLAUDE.md documents +# `(): `. That gap is a policy question and not mine +# to settle by switching a gate to blocking: a check that fails 42% of the work +# is a check people route around. So it says what it sees, with counts, and the +# decision to tighten it stays with a reader who can also change the rule. + on: pull_request: types: [opened, synchronize] push: branches: [main] +permissions: + contents: read + pull-requests: read + jobs: - ci-check: + commit-format: name: CI Validation runs-on: ubuntu-latest steps: - - name: Validate Commit Format + # Full history, unconditionally. There was no checkout at all before, + # which is why `git log` failed. + # + # The first version wrote + # fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + # which evaluates to 1 on a pull request, not 0: `true && 0` is `0`, `0` + # is falsy, so `|| 1` wins. The clone came out shallow and the job died + # with `fatal: Invalid revision range ..` — a range error that + # reads as a bad SHA rather than as a missing object. The ternary idiom is + # only safe when the middle operand is truthy. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Commit subject format + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} run: | - git log -1 --format=%s | grep -E '^\[a-f]+\((feat|fix|refactor|docs|chore|test|ci)\([^)]+\):' | head -1 - if [[ $(echo "$COMMIT" | grep -oE '(ISSUE-|#)[0-9]+') ]]; then - echo "✅ Valid issue ID format" + set -u + if [ -n "${BASE:-}" ] && [ -n "${HEAD:-}" ]; then + RANGE="$BASE..$HEAD" else - echo "❌ Missing issue ID (format: feat(scope): description (#123) or fix(scope): description (ISSUE-123))" - exit 1 + RANGE="-1" fi - session-check: - name: Verify Dev Session State - runs-on: ubuntu-latest - steps: - - name: Check session state before commit - run: | - if [ ! -f .trinity/dev_session.json ]; then - echo "❌ No dev session found" - exit 1 + # Merge subjects are generated by GitHub, not by an author, so + # holding them to the format would fail every merge. + SUBJECTS=$(git log --no-merges --format=%s $RANGE) + if [ -z "$SUBJECTS" ]; then + echo "no non-merge commits to check" + exit 0 fi - STATE=$(jq -r '.state' .trinity/dev_session.json 2>/dev/null || echo "IDLE") - ISSUE=$(jq -r '.issue_number' .trinity/dev_session.json 2>/dev/null || echo "0") + PATTERN='^(feat|fix|refactor|docs|chore|test|ci|perf|build|style)(\([a-z0-9._/-]+\))?!?: .+' + ok=0; bad=0 + while IFS= read -r s; do + if echo "$s" | grep -qE "$PATTERN"; then + ok=$((ok + 1)) + else + echo " off-format: $s" + bad=$((bad + 1)) + fi + done <<< "$SUBJECTS" - if [ "$STATE" != "TESTED" ] && [ "$STATE" != "COMMITTED" ]; then - echo "❌ Invalid state: $STATE (must be TESTED or COMMITTED)" - echo " Run: tri dev test to pass tests" - exit 1 + echo "$ok in CLAUDE.md format, $bad not" + if [ "$bad" -gt 0 ]; then + echo "::warning::$bad of $((ok + bad)) subject(s) are not (): " fi - if [ "$ISSUE" = "0" ]; then - echo "❌ No active issue" - exit 1 + - name: Issue reference + if: github.event_name == 'pull_request' + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + BODY: ${{ github.event.pull_request.body }} + TITLE: ${{ github.event.pull_request.title }} + run: | + set -u + BLOB="$(git log --no-merges --format='%s %b' "$BASE..$HEAD") + $TITLE + $BODY" + if echo "$BLOB" | grep -qoE '(ISSUE-|#)[0-9]+'; then + echo "issue reference found: $(echo "$BLOB" | grep -oE '(ISSUE-|#)[0-9]+' | sort -u | tr '\n' ' ')" + else + echo "::warning::no issue reference (#123 or ISSUE-123) in the commits, title or body" fi - - echo "✅ Session state valid: $STATE for issue #$ISSUE" - - pr-status: - name: Update PR Status - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - name: Add in-progress label - run: | - gh pr edit "$PR_NUMBER" --add-label "status:in-progress" - - name: Update to done when merged - if: github.event.action == 'closed' && github.event.pull_request.merged == true - run: | - gh pr edit "$PR_NUMBER" --remove-label "status:in-progress" - gh pr edit "$PR_NUMBER" --add-label "status:completed"