crm: extension-bag spike (7b mechanism) + full rung 7a + 7b #511
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: Spec sync | |
| # Enforces that a change to a header sub-domain (include/morph/<sub>/**) is | |
| # accompanied by a change to the matching design-spec folder (docs/spec/<sub>/**), | |
| # so the specs cannot silently drift behind the code they describe. | |
| # | |
| # The gate is skipped when the PR carries the label "no docs update" — a visible, | |
| # reviewable opt-out for changes that genuinely need no spec update. | |
| on: | |
| pull_request: | |
| branches: | |
| - master | |
| # `labeled`/`unlabeled` so adding or removing the escape-hatch label re-runs | |
| # the gate instead of leaving a stale result. | |
| types: [opened, synchronize, reopened, labeled, unlabeled] | |
| # Supersede a run a newer commit on the same ref has made obsolete -- see the | |
| # note in ci.yml. This one is pull_request-only, so it supersedes on a force-push | |
| # or a re-label rather than on a merge burst. | |
| concurrency: | |
| group: spec-sync-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| spec-sync: | |
| name: Header ↔ spec sync | |
| runs-on: ubuntu-24.04 | |
| if: ${{ !contains(github.event.pull_request.labels.*.name, 'no docs update') }} | |
| steps: | |
| - name: Checkout (full history) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Enforce header ↔ spec synchronization | |
| run: | | |
| set -euo pipefail | |
| # The base commit, without asking git to compute one. | |
| # | |
| # `pull_request` checks out `refs/pull/N/merge` -- a merge commit whose | |
| # first parent *is* the base branch commit the PR was merged against. | |
| # So HEAD^1 is the base by construction, needs no network, and cannot | |
| # go stale while the job runs. | |
| # | |
| # It replaces `git fetch --no-tags --depth=1 origin "$BASE_REF"` | |
| # followed by `git merge-base HEAD "origin/$BASE_REF"`, which failed | |
| # roughly one run in twenty for a reason unrelated to specs | |
| # (morph#186). Two things were wrong with it. The re-fetch was a | |
| # no-op: actions/checkout above already runs with `fetch-depth: 0`, so | |
| # `origin/$BASE_REF` is present, complete and current, and the fetch | |
| # updated no ref -- its only observable effect was writing | |
| # `.git/shallow`, grafting history at the fetched tip. And once the | |
| # repository was shallow, `git merge-base` could no longer see the | |
| # merge base whenever master had advanced between the PR event and | |
| # this job starting, so it exited 1 with no output at all and | |
| # `set -e` aborted the step before a single path was examined. | |
| # | |
| # The gate then reported "Header ↔ spec sync: failure" on pull | |
| # requests that touched no header whatsoever, which is worse than | |
| # useless -- it teaches reviewers to merge past it. Note it was never | |
| # re-runnable green either: `github.sha` stays pinned to the event | |
| # while `origin/$BASE_REF` only moves further ahead. | |
| if ! base="$(git rev-parse --verify HEAD^1 2>/dev/null)"; then | |
| echo "::error::Could not determine the base commit (HEAD has no first parent)." | |
| echo "This gate compares against HEAD^1, the base side of the pull_request merge ref." | |
| echo "If this workflow is ever run outside a pull_request event, that assumption does not hold." | |
| exit 1 | |
| fi | |
| echo "Comparing against base $base (HEAD^1, the base side of the merge ref)" | |
| changed="$(git diff --name-only "$base" HEAD)" | |
| echo "── Changed files ──" | |
| echo "$changed" | |
| echo "───────────────────" | |
| # Sub-domains that have a mirrored spec folder. include/morph/detail/** | |
| # and include/morph/qt/** have no specs and are intentionally exempt. | |
| subdomains="core journal offline session forms util" | |
| missing="" | |
| for sub in $subdomains; do | |
| if echo "$changed" | grep -qE "^include/morph/$sub/"; then | |
| if ! echo "$changed" | grep -qE "^docs/spec/$sub/"; then | |
| missing="$missing $sub" | |
| fi | |
| fi | |
| done | |
| if [ -n "$missing" ]; then | |
| echo "" | |
| echo "::error::Header changes without a matching spec update in:$missing" | |
| for sub in $missing; do | |
| echo " - include/morph/$sub/** changed but docs/spec/$sub/** did not." | |
| done | |
| echo "" | |
| echo "Update the design spec(s) for the affected sub-domain(s), or add the" | |
| echo "label 'no docs update' to this PR if no spec change is warranted." | |
| exit 1 | |
| fi | |
| echo "Spec sync OK: every touched header sub-domain has a matching spec change." |