Skip to content

Run only the CI suites a pull request or push can affect #563

Open

Description

@HMarzban

Part of #554, Set C: change-aware gating, except the extension filter. #562 owns the filter. Work on main. Rulings: #554 rulings 1, 5, 6, 7, 8, 9, 10, 15, 16.

Summary

  • Today. Every pull request and every push without (build): runs Type Check, Webapp Unit Tests and Build Verification, whatever it changed. Every pull request also runs Backend Validation.
  • After. 🔎 Detect Changes also emits webapp, admin, backend and docs_only. A new script, .github/scripts/detect-change-scopes.sh, computes them from the changed files.
    • Type Check, Backend Validation, Webapp Unit Tests and Build Verification gate on them.
    • A docs-only change also skips the Ext suites. Lint and security always run.
  • Every scope is on for a (build): push, including (build): back no-deploy. The same holds for workflow_dispatch, the weekly cron and a missing diff base. Those keep today's gate set.
  • If a skip is not provable, every scope turns on. That covers a shared file, an unmatched path, an empty list and more than 500 files. It also covers .github/, except Markdown files and issue templates.

Gain. Docs-only push run 38364279436 (docs(contributing)) changed AGENTS.md, CONTRIBUTING.md, README.md and apps/webapp/README.md. It took 217 s.

  • Build Verification alone took 128 s (job 115186073670, +89 s to +217 s). Lint ended at +85 s (job 115185626119).
  • After this issue, that push ends when lint ends, at about 85–95 s. Single-area pull requests skip the suites of the other areas.

No gain:

New cost on deploys. Type Check and Backend Validation gain a needs: changes edge.

  • In deploy run 39398067267, 🔎 Detect Changes ended at +19 s. Type Check started at +12 s, and Backend E2E ended at +138 s.
  • So both jobs start about 10 s later on every (build): deploy. On a deploy with no Ext suite, Backend E2E is the longest gate, so M2g can grow by about 10 s.
  • After Build the deploy images while the quality gates run #565, the image path is the critical path, so the cost to M1 falls to about 0 s.

Depends on / Blocks

Current behaviour

Links are pinned to d0a53756f8676556358b6adc6c2397d2ed7c64c8. All paths are in .github/workflows/prod.docs.plus.yml unless named.

  1. Triage emits triggered. It is true only for a (build): subject (prod.docs.plus.yml:97-105). Any other subject emits triggered=false (parse-build-trigger.sh:30-34). On pull_request, head_commit is empty, so triggered is false (:111).
  2. 🔎 Detect Changes emits only the extension list (:124-176).
    • Outputs: changed_extensions only (:140-141).
    • The fallback expression is at :157: FORCE_ALL: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event.before == '0000000000000000000000000000000000000000' }}.
    • The header comment says "lint/typecheck/security stay always-on because they are fast and repo-global" (:120-122). That sentence becomes false.
  3. Type Check runs on every non-cron event (:189-197): needs: [triage], if: github.event_name != 'schedule' && needs.triage.outputs.skip_app_ci != 'true'.
  4. Backend Validation runs on every pull request (:260-270): if: github.event_name == 'pull_request' || needs.triage.outputs.has_back == 'true' || needs.triage.outputs.app_deploy == 'true'.
    • A push without (build): never runs it.
    • run_e2e is github.event_name != 'pull_request' (:270), so any non-PR run of it also runs Backend E2E (backend-ci.yml:44-46).
  5. The Ext suites follow the extension filter only (:272-281). A change to extensions/<ext>/docs/*.md starts that suite today, about 194 s for mermaid.
  6. Webapp Unit Tests run on every pull request and every push without (build): (:308-318). The last clause is (needs.triage.outputs.has_front == 'true' || needs.triage.outputs.triggered != 'true' || github.event_name == 'pull_request').
  7. Build Verification runs whenever its gates pass (:351-376). It accepts a skipped Backend Validation, Ext suite or Webapp Unit Tests result (:374-376). It demands success from lint, typecheck and security (:371-373).
  8. Lint and security stay as they are (:181-187, :214-222). Lint runs prettier --check ., which checks Markdown (quality-lint.yml:23-27).
  9. The deploy gate stays as it is (:431-435). It needs build to succeed. Every scope is on for a (build): push, so build still runs before every deploy.
  10. No branch protection and no required status check exist. gh api repos/docs-plus/docs.plus/branches/main/protection returns 404 Branch not protected. The only ruleset, main safety (id 21301824), holds non_fast_forward and deletion rules. So a skipped job blocks no merge.
  11. The import graph, checked at the pin:
    • The webapp depends on all six extensions and @docs.plus/floating-popover (apps/webapp/package.json:27-33). @docs.plus/playground is a dev dependency (:103). packages/playground/** stays Unmatched, which turns every scope on.
    • The backend depends on @docs.plus/email-templates and three extensions: hyperlink, hypermultimedia and inline-code (apps/hocuspocus.server/package.json:45-48).
    • apps/admin-dashboard has no workspace dependency.
    • A webapp unit test reads packages/supabase SQL files (chatMediaMime.test.ts:17-20). So packages/supabase/** is in the webapp scope.
    • The extension preflight reads only the extension README (check-quickstart-example.ts:20). No app build or test reads a file under docs/.
    • One Markdown file outside apps/*/src/ is read by code: packages/document-swarm/src/fixtures/academy.md (academy.ts:30). So .md under any src/ folder counts as code.
  12. dorny/paths-filter v4.0.3 (ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d):
    • dot: true makes globs match .github/ (src/filter.ts:16). Git diffs use --no-renames, so a rename is a delete and an add (src/git.ts:65).
    • On a pull request, the API path splits a rename into the new name and previous_filename (src/main.ts:230-240).
    • list-files: json writes JSON.stringify of the file names (src/main.ts:296-297) and <key>_count (src/main.ts:274).
  13. The docs describe today's rule. .github/WORKFLOWS.md says a normal commit runs "gates only" and a pull request runs "gates + build verification" (WORKFLOWS.md:88-89). AGENTS.md warns that only the extension suites are path-gated (AGENTS.md:222).
  14. parse-build-trigger.test.sh runs in no workflow, only by hand. The new test runs the same way.

Target behaviour

This issue builds the two .github/ exceptions to the tracker's "run everything" rule: .github/ISSUE_TEMPLATE/** and .md files under .github/. No CI job reads them, except lint, which always runs.

The scope map. The script classes each changed path by the first row that matches.

Order Class Paths Effect
1 Never docs extensions/*/README.md, extensions/*/CHANGELOG.md, any path under a src/ folder, apps/webapp/public/** Skips row 2. Row 3 still applies, so anything under extensions/<ext>/assets/ is media, as in #562.
2 Docs **/*.md, docs/**, .github/ISSUE_TEMPLATE/**, root LICENSE No scope. Counts toward docs_only.
3 Extension README media extensions/<ext>/assets/**, and a .gif, .png, .jpg, .jpeg, .webp or .mp4 file at an extension root No scope
4 Shared Root package.json, bun.lock, bunfig.toml, tsconfig*.json, tsup.base.ts, .prettierrc*, .prettierignore, eslint.config.*, .stylelintrc*, docker-compose*.yml; .github/**; scripts/**; packages/eslint-config/** Every scope on
5 webapp apps/webapp/**, extensions/**, packages/floating-*/**, packages/supabase/** webapp on
5 admin apps/admin-dashboard/** admin on
5 backend apps/hocuspocus.server/**, packages/email-templates/**, packages/supabase/**, packages/floating-*/**, extensions/extension-hyperlink/**, extensions/extension-hypermultimedia/**, extensions/extension-inline-code/** backend on
6 Unmatched Any other path, for example packages/document-swarm/**, packages/playground/**, packages/release-tooling/**, Makefile, .cursor/rules/*.mdc Every scope on
  • docs_only is true only when every path is in row 2.
  • .github/WORKFLOWS.md and .github/PULL_REQUEST_TEMPLATE.md are in row 2, because row 2 comes before row 4.
  • Every scope turns on, and docs_only is false, in these cases:
    • the event is workflow_dispatch or schedule;
    • github.event.before is all zeros;
    • the event is a push with triggered == 'true';
    • the file count is missing or above 500;
    • the list is empty, or its length differs from the count.

Which jobs run on a pull request, or a push without (build)::

Job Runs when
triage, Detect Changes, lint, security Always, as today
Type Check webapp, admin or backend is on
Backend Validation Pull request: backend is on. Push without (build):: never, as today.
Ext suites The extension filter selects the suite, and docs_only is not true
Webapp Unit Tests webapp is on
Build Verification webapp or admin is on, and every gate passed or was skipped
deploy, observability, Uptime Kuma Unchanged

On a (build): push, a dispatch and the cron, every job runs or skips exactly as today.

Every run explains itself. The scopes step logs one line per path (docs, media, shared, scoped or unmatched) and the five outputs, including reason.

Implementation steps

Read every file in Read first before Step 0. "Comment here" means a comment on this GitHub issue, never a code comment.

Step 0. Read and check the base

  1. Confirm you are on an up-to-date main with git status and git log -1 --format=%H.
  2. Diff the pinned files against main:
    git diff d0a53756f8676556358b6adc6c2397d2ed7c64c8 HEAD -- .github/workflows/prod.docs.plus.yml .github/filters/extensions.yaml .github/WORKFLOWS.md AGENTS.md scripts/check-ci.ts
  3. Confirm Stop extension README media from triggering extension test suites #562 is merged:
    grep -n "predicate-quantifier" .github/workflows/prod.docs.plus.yml
    grep -n "README_MEDIA" scripts/check-ci.ts
    • Both must print a line. If not, stop and comment here.
    • Compare its README_MEDIA regex with the regex in Step 1. They must be the same pattern. If they differ, copy the merged pattern into Step 1 and write each \/ as /. Then run Step 2.
    • If nested-assets fails because a nested assets/ file now reaches row 5, change that line to expect true false true false nested-assets. Any other failure: stop and comment here.
  4. Confirm the dorny/paths-filter pin and its four facts (Current behaviour item 12):
    R=ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d
    gh api "repos/dorny/paths-filter/contents/src/filter.ts?ref=$R" -q .content | base64 -d | sed -n '14,18p'
    gh api "repos/dorny/paths-filter/contents/src/git.ts?ref=$R" -q .content | base64 -d | sed -n '65p'
    gh api "repos/dorny/paths-filter/contents/src/main.ts?ref=$R" -q .content | base64 -d | sed -n '230,240p;274p;296,297p'
    • Expect dot: true, --no-renames, the previous_filename push, _count, and JSON.stringify(fileNames).
    • If the workflow pins another SHA now, run the same reads at that SHA. If a fact fails, stop and comment here.
  5. Confirm no required status check exists:
    gh api repos/docs-plus/docs.plus/branches/main/protection
    gh api repos/docs-plus/docs.plus/rulesets/21301824 -q '[.rules[].type] | join(",")'
    • Expect 404 Branch not protected, and no required_status_checks in the rule list. Restrict the production self-hosted runner to main deploys #552 may add another rule to main safety; that is fine.
    • If a required check exists, stop and comment here. A skipped job counts as passed, but a matrix that never starts reports nothing.

Step 1. The scope script

Create .github/scripts/detect-change-scopes.sh. It is shell, not TypeScript: the changes job has no Bun, and setup-bun there would delay every gate. It runs on Bash 3.2 too, so it works on a stock macOS shell.

#!/usr/bin/env bash
# Maps changed paths (one per line on stdin) to the CI scopes a change can
# affect. If a skip is not provable, every scope turns on.
# Keep the patterns in step with the scope table in .github/WORKFLOWS.md.
set -uo pipefail

MAX_FILES=500

emit() { # key value
  [ -n "${GITHUB_OUTPUT:-}" ] && printf '%s=%s\n' "$1" "$2" >> "${GITHUB_OUTPUT}"
  printf '%s=%s\n' "$1" "$2"
}
emit_all() { # webapp admin backend docs_only reason
  emit webapp "$1"; emit admin "$2"; emit backend "$3"; emit docs_only "$4"; emit reason "$5"
}
# Drain stdin first, so the writer upstream never dies of SIGPIPE under pipefail.
everything() { cat > /dev/null; emit_all true true true false "$1"; exit 0; }

[ "${ALL_SCOPES:-false}" = true ] && everything forced
case "${FILE_COUNT:-}" in ''|*[!0-9]*) everything no-file-count ;; esac
[ "${FILE_COUNT}" -gt "${MAX_FILES}" ] && everything too-many-files

webapp=false; admin=false; backend=false; total=0; docs=0
while IFS= read -r f; do
  [ -n "${f}" ] || continue
  total=$((total + 1))

  # Docs are neutral, except preflight inputs and anything under a src/ or the
  # served public/ folder. A case `*` also matches `/`, which only widens these.
  case "${f}" in
    extensions/*/README.md|extensions/*/CHANGELOG.md|src/*|*/src/*|apps/webapp/public/*) : ;;
    *.md|docs/*|.github/ISSUE_TEMPLATE/*|LICENSE) docs=$((docs + 1)); echo "docs      ${f}" >&2; continue ;;
  esac
  # Extension README media is neutral. Same set as README_MEDIA in scripts/check-ci.ts.
  if [[ "${f}" =~ ^extensions/[^/]+/(assets/|[^/]+\.(gif|png|jpe?g|webp|mp4)$) ]]; then
    echo "media     ${f}" >&2; continue
  fi

  case "${f}" in
    package.json|bun.lock|bunfig.toml|tsconfig*.json|tsup.base.ts|.github/*|scripts/*|packages/eslint-config/*|.prettierrc*|.prettierignore|eslint.config.*|.stylelintrc*|docker-compose*.yml)
      webapp=true; admin=true; backend=true; echo "shared    ${f}" >&2; continue ;;
  esac

  hit=false
  case "${f}" in apps/webapp/*|extensions/*|packages/floating-*|packages/supabase/*) webapp=true; hit=true ;; esac
  case "${f}" in apps/admin-dashboard/*) admin=true; hit=true ;; esac
  case "${f}" in
    apps/hocuspocus.server/*|packages/email-templates/*|packages/supabase/*|packages/floating-*|extensions/extension-hyperlink/*|extensions/extension-hypermultimedia/*|extensions/extension-inline-code/*)
      backend=true; hit=true ;;
  esac
  if [ "${hit}" = true ]; then
    echo "scoped    ${f}" >&2
  else
    webapp=true; admin=true; backend=true; echo "unmatched ${f}" >&2
  fi
done

[ "${total}" -gt 0 ] || everything no-files
[ "${total}" -eq "${FILE_COUNT}" ] || everything count-mismatch
docs_only=false
[ "${docs}" -eq "${total}" ] && docs_only=true
emit_all "${webapp}" "${admin}" "${backend}" "${docs_only}" "files=${total}"
  • A case pattern anchors at the start of the path, so package.json and tsconfig*.json match only root files.
  • In a case pattern, * also matches /. That only widens rows 1, 4 and 5, which makes more suites run. Row 3 uses a regex, because a wider match there would skip suites.
  • Check: shellcheck .github/scripts/detect-change-scopes.sh prints nothing, if shellcheck is installed.

Step 2. The test, and prove it by sabotage

AGENTS.md §Test Policy case (c) applies. The failure mode is a parsing bug: a path classed as docs or media silently skips a suite that its change can affect.

Create .github/scripts/detect-change-scopes.test.sh, in the same shape as parse-build-trigger.test.sh:

#!/usr/bin/env bash
# Self-contained test harness for detect-change-scopes.sh (no bats dependency).
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="${HERE}/detect-change-scopes.sh"
PASS=0; FAIL=0

# run <files, one per line> [ALL_SCOPES] [FILE_COUNT, default: the line count] -> sets OUT
run() {
  local count="${3-auto}"
  [ "${count}" != auto ] || count="$(printf '%s' "$1" | grep -c . || true)"
  OUT="$(printf '%s\n' "$1" | ALL_SCOPES="${2:-false}" FILE_COUNT="${count}" GITHUB_OUTPUT='' bash "${SCRIPT}" 2>/dev/null)"
}
# expect <webapp> <admin> <backend> <docs_only> <label>
expect() {
  local got; got="$(printf '%s\n' "${OUT}" | grep -E '^(webapp|admin|backend|docs_only)=' | cut -d= -f2 | tr '\n' ' ')"
  if [ "${got}" = "$1 $2 $3 $4 " ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL [$5]: expected '$1 $2 $3 $4' got '${got}'"; fi
}

run $'README.md\ndocs/api/README.md\ndocs/assets/flow.png\n.github/ISSUE_TEMPLATE/bug_report.md\nLICENSE\n.github/WORKFLOWS.md'
expect false false false true docs-only
run 'apps/webapp/src/utils/titleWrite.ts';                   expect true false false false webapp-only
run 'extensions/extension-mermaid/assets/demo-dark.gif';     expect false false false false gif-only
run 'bun.lock';                                              expect true true true false lockfile
run 'extensions/extension-mermaid/README.md';                expect true false false false ext-readme
run 'extensions/extension-hyperlink/CHANGELOG.md';           expect true false true false backend-ext-changelog
run 'extensions/extension-mermaid/src/assets/icon.svg';      expect true false false false src-assets-not-media
run 'apps/webapp/src/notes.md';                              expect true false false false md-under-src
run 'apps/webapp/public/robots.md';                          expect true false false false md-under-public
run 'apps/admin-dashboard/src/app/page.tsx';                 expect false true false false admin-only
run 'apps/hocuspocus.server/src/index.ts';                   expect false false true false backend-only
run 'packages/supabase/scripts/12-buckets.sql';              expect true false true false supabase
run 'packages/floating-popover/src/index.ts';                expect true false true false floating
run 'packages/email-templates/src/index.ts';                 expect false false true false email-templates
run '.github/workflows/prod.docs.plus.yml';                  expect true true true false workflow-file
run 'tsconfig.base.json';                                    expect true true true false root-tsconfig
run 'apps/webapp/tsconfig.json';                             expect true false false false app-tsconfig
run 'packages/document-swarm/src/run.ts';                    expect true true true false unmatched
run $'README.md\napps/webapp/src/a.ts';                      expect true false false false docs-plus-code
run '' false 0;                                              expect true true true false no-files
run 'README.md' true;                                        expect true true true false forced
run 'packages/document-swarm/src/fixtures/academy.md';       expect true true true false md-under-package-src
run 'extensions/extension-mermaid/docs/guide.md';            expect false false false true ext-docs-folder
run 'extensions/extension-hypermultimedia/assets/readme-media/sample.mp4'; expect false false false false nested-assets
run 'extensions/extension-mermaid/demo.webp';                expect false false false false ext-root-media
run 'extensions/extension-hypermultimedia/cypress/fixtures/x.png'; expect true false true false media-outside-assets
run '.github/assets/social-preview.png';                     expect true true true false github-assets
run 'README.md' false 501;                                   expect true true true false too-many-files
run 'README.md' false 2;                                     expect true true true false count-mismatch
run 'README.md' false '';                                    expect true true true false no-file-count

echo "PASS=${PASS} FAIL=${FAIL}"
[ "${FAIL}" -eq 0 ]
  1. Run bash .github/scripts/detect-change-scopes.test.sh. Expect PASS=30 FAIL=0 and exit 0.
  2. Sabotage. In the script, delete extensions/*/README.md| from the row 1 pattern. Run the test again. Expect FAIL [ext-readme] and exit 1.
  3. Restore the line. Run the test again and expect PASS=30 FAIL=0. Paste both outputs in the review.
  4. Do not sabotage with bun.lock. Removing it from row 4 sends it to row 6, which also turns every scope on, so the test cannot see it.

Step 3. The changes job

  1. The header comment at :120-122: replace only the sentence that says Type Check stays always-on. Leave the "~14 min" text to Make CI failures reach Telegram, and fix stale CI facts #557.
      # extension-tests job keys off. It also emits the webapp, admin, backend and
      # docs_only scopes from detect-change-scopes.sh. Lint and security always
      # run. On a PR or a push without `(build):`, typecheck, backend validation,
      # webapp unit tests and Build Verification run only for scopes that are on.
  2. The outputs at :140-141:
        outputs:
          changed_extensions: ${{ steps.assemble.outputs.list }}
          webapp: ${{ steps.scopes.outputs.webapp }}
          admin: ${{ steps.scopes.outputs.admin }}
          backend: ${{ steps.scopes.outputs.backend }}
          docs_only: ${{ steps.scopes.outputs.docs_only }}
  3. Two new steps after the assemble step (after :176). Do not change filter or assemble.
          # Every changed path, for the scope map. Its own step: the extension filter
          # above has its own predicate quantifier. A `(build):` push needs no list.
          - id: files
            if: github.event_name == 'pull_request' || (github.event_name == 'push' && needs.triage.outputs.triggered != 'true')
            uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
            with:
              list-files: json
              filters: |
                all:
                  - '**'
    
          - id: scopes
            env:
              # The `assemble` fallback, plus every `(build):` push: scopes never
              # narrow a deploy push's gate set.
              ALL_SCOPES: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event.before == '0000000000000000000000000000000000000000' || (github.event_name == 'push' && needs.triage.outputs.triggered == 'true') }}
              FILE_COUNT: ${{ steps.files.outputs.all_count }}
              # Over 500 paths the list could pass the env size limit; the script turns every scope on.
              FILES_JSON: ${{ fromJSON(steps.files.outputs.all_count || '0') <= 500 && steps.files.outputs.all_files || '[]' }}
            run: |
              set -euo pipefail
              printf '%s' "${FILES_JSON}" | jq -r '.[]' | bash .github/scripts/detect-change-scopes.sh
    • Use the same dorny/paths-filter SHA as the filter step. If Step 0 found a new pin, use it.
    • Do not name the filter key changes. That name is a reserved output of the action.
    • The file list goes through env, never into the script text. File names in a pull request are untrusted input.
    • Why 500: the longest repo path is 144 characters. 500 paths are about 74 KB, under the 128 KB limit for one environment value.
    • jq is preinstalled on ubuntu-latest.

Step 4. Type Check

At :193-197:

    needs: [triage, changes]
    # Skip on infra-only commits (skip_app_ci=true), schedule, or no code scope.
    if: |
      github.event_name != 'schedule' &&
      needs.triage.outputs.skip_app_ci != 'true' &&
      (needs.changes.outputs.webapp == 'true' || needs.changes.outputs.admin == 'true' || needs.changes.outputs.backend == 'true')
  • With no status function, the if also needs triage and changes to succeed. If changes fails, Type Check skips, and Build Verification then blocks the deploy. Today a failed changes blocks Build Verification too.

Step 5. Backend Validation

At :262-267. Keep the four comment lines and add one:

    needs: [triage, changes]
    # `app_deploy` is the value the deploy job gates on, and the deploy always
    # runs the Prisma migration and rolls all three backend services. Gating on
    # `has_back` alone let a `(build): front` push deploy backend code no test
    # had ever touched. `has_back` stays so `(build): back no-deploy` still runs.
    # A PR runs it only for the backend scope; a `(build):` push turns every scope on.
    if: |
      (github.event_name == 'pull_request' || needs.triage.outputs.has_back == 'true' || needs.triage.outputs.app_deploy == 'true') &&
      needs.changes.outputs.backend == 'true'
  • Do not change run_e2e. A push without (build): stays without Backend Validation, so it never runs Backend E2E.

Step 6. Ext suites skip docs-only changes

In the extension-tests if (:276-281 at the pin), add one line after the changed_extensions line:

      needs.changes.outputs.changed_extensions != '[]' &&
      needs.changes.outputs.docs_only != 'true' &&

Step 7. Webapp Unit Tests

At :312-318. #558 has removed the back-validation wait. Add changes to needs and one clause:

    needs: [triage, changes]
    if: |
      always() &&
      github.event_name != 'schedule' &&
      needs.triage.outputs.skip_app_ci != 'true' &&
      needs.changes.outputs.webapp == 'true' &&
      (needs.triage.outputs.has_front == 'true' || needs.triage.outputs.triggered != 'true' || github.event_name == 'pull_request')
  • Keep back-validation out of needs and keep no needs.back-validation.result line.
  • A (build): back push still skips this job. Its has_front is false and triggered is true, as today.

Step 8. Build Verification

In the build if (:366-376), add one line after needs.changes.result == 'success' &&:

      needs.changes.result == 'success' &&
      (needs.changes.outputs.webapp == 'true' || needs.changes.outputs.admin == 'true') &&
  • Do not touch the typecheck clause. When webapp or admin is on, Type Check runs, so its success demand stays right.

Step 9. Docs

  1. .github/WORKFLOWS.md:88-89. Replace the two rows:
    | `fix: …` (normal commit)                        | typecheck by scope, no backend-ci | ⏭️         | ⏭️  | ⏭️  | gates for changed scopes, no deploy |
    | PR to `main`                                    | changed scopes | ⏭️         | ⏭️  | ⏭️  | gates + build verification for changed scopes |
  2. .github/WORKFLOWS.md, new section between the truth table and ## Related files:
    ## Change scopes (PRs and pushes without `(build):`)
    
    `🔎 Detect Changes` runs `.github/scripts/detect-change-scopes.sh` on the changed files. It emits `webapp`, `admin`, `backend` and `docs_only`. Lint and security always run.
    
    <the scope-map table and the job table from this issue's Target behaviour>
    
    Every scope is on for a `(build):` push, a `workflow_dispatch`, the weekly cron, a missing diff base, more than 500 files, or an empty list.
    Paste both tables from Target behaviour in place of the angle-bracket line.
  3. .github/WORKFLOWS.md §Related files. Add - Change-scope classifier: .github/scripts/detect-change-scopes.sh (test: detect-change-scopes.test.sh).
  4. AGENTS.md:222. Find the sentence "Confirm the job actually executed in the run you cite before calling a suite healthy." Add these two sentences after it:

    On a PR or a push without (build):, Type Check, Backend Validation, Webapp Unit Tests and Build Verification also skip by changed path (.github/scripts/detect-change-scopes.sh). A green PR therefore does not prove that those jobs ran.

  5. Format the touched files: bunx prettier --write .github/WORKFLOWS.md AGENTS.md .github/workflows/prod.docs.plus.yml.

Step 10. Local checks

  1. actionlint -config-file .github/actionlint.yaml .github/workflows/prod.docs.plus.yml prints nothing.
  2. bash .github/scripts/detect-change-scopes.test.sh prints PASS=30 FAIL=0.
  3. bash .github/scripts/parse-build-trigger.test.sh still prints FAIL=0. Triage does not change, so this is a guard.
  4. Replay four real file lists through the script. A dry_run_subject dispatch cannot test scopes, because a dispatch turns every scope on.
    replay() { FILE_COUNT=$(printf '%s\n' "$1" | grep -c .) bash .github/scripts/detect-change-scopes.sh <<< "$1"; }
    replay "$(git diff --name-only --no-renames 1abb90a62^ 1abb90a62)"
    replay "$(git diff --name-only --no-renames 0918031d0^ 0918031d0)"
    replay $'extensions/extension-mermaid/assets/demo-dark.gif\nextensions/extension-mermaid/assets/demo-light.gif'
    replay "$(gh api repos/docs-plus/docs.plus/pulls/540/files -q '.[].filename')"
    Replay Files Expected output Jobs that then run
    Docs-only, 1abb90a62 (run 38364279436) AGENTS.md, CONTRIBUTING.md, README.md, apps/webapp/README.md webapp=false admin=false backend=false docs_only=true triage, Detect Changes, lint, security
    Webapp-only, 0918031d0 7 files under apps/webapp/src/, plus .cursor/docs/design-system.md webapp=true admin=false backend=false docs_only=false the above, plus Type Check, Webapp Unit Tests, Build Verification
    GIF-only, synthetic two extensions/extension-mermaid/assets/demo-*.gif files webapp=false admin=false backend=false docs_only=false triage, Detect Changes, lint, security. Stop extension README media from triggering extension test suites #562 skips the mermaid suite.
    bun.lock, PR chore(deps): bump cypress from 15.21.1 to 16.2.0 #540 apps/webapp/package.json, bun.lock, package.json webapp=true admin=true backend=true docs_only=false every gate and all six Ext suites, as today
  5. bun run format passes.
  6. bun run check:agent-docs passes.

Review checkpoint

Show git diff --stat and the full diff of .github/workflows/prod.docs.plus.yml. Paste the outputs of Steps 2 and 10. Then stop. Do not run git add, git commit, git push or git stash. Do not create a branch.

The maintainer reviews the change set, then commits and pushes it per SP2 (#554), with a subject that does not start with (build):.

Acceptance criteria

  • .github/scripts/detect-change-scopes.sh and .github/scripts/detect-change-scopes.test.sh exist. The test prints PASS=30 FAIL=0 and failed once under the Step 2 sabotage.
  • 🔎 Detect Changes outputs webapp, admin, backend and docs_only. The scopes step log shows one class line per path and a reason line.
  • ALL_SCOPES is true for workflow_dispatch, schedule, an all-zeros before and every push with triggered == 'true'.
  • Type Check, Backend Validation, Webapp Unit Tests and Build Verification gate on the scopes as in Steps 4–8. Ext suites skip when docs_only is true.
  • actionlint prints nothing for prod.docs.plus.yml.
  • The four Step 10 replays print the expected outputs.
  • The two Verification dispatches run and skip the jobs that Verification lists. In the (build): front dispatch, lint and security also run.
  • The next docs-only push runs only triage, Detect Changes, lint and security, and its M6 is 120 s or less.
  • The next (build): deploy runs lint, security, Type Check, Backend Validation and Build Verification. It runs Webapp Unit Tests when has_front is true. Its Ext matrix equals the changed_extensions list in its assemble log.
  • The next weekly cron runs 🔒 Security Audit and one Ext suite for each name in the all list of the assemble step. Type Check, Webapp Unit Tests and Build Verification skip, as in run 39089384453.
  • .github/WORKFLOWS.md and AGENTS.md:222 say what Step 9 says.
  • The result is recorded per SP3 ([Tracking] Faster, change-aware CI/CD pipeline #554), as in Accountability.

Verification

Before the push (the builder):

actionlint -config-file .github/actionlint.yaml .github/workflows/prod.docs.plus.yml
bash .github/scripts/detect-change-scopes.test.sh
bash .github/scripts/parse-build-trigger.test.sh
bun run format
bun run check:agent-docs

Then the four replays in Step 10.

After the push (the maintainer). A dispatch turns every scope on, so these two dispatches prove the unchanged paths only.

  1. Run SP1 ([Tracking] Faster, change-aware CI/CD pipeline #554) before each dispatch. The deploy job comment at prod.docs.plus.yml:415-418 says a push also cancels a running roll. Build the deploy images while the quality gates run #565 shows that a running deploy survives (run 34223310158).
  2. Run one dispatch at a time. Wait for it with gh run watch <id> --repo docs-plus/docs.plus before you start the next one.
  3. Nobody pushes to main while a dispatch runs.
gh workflow run prod.docs.plus.yml --repo docs-plus/docs.plus --ref main -f dry_run_subject='(build): front'
# wait for it to end, repeat check 1, then:
gh workflow run prod.docs.plus.yml --repo docs-plus/docs.plus --ref main -f dry_run_subject='fix: scope check'
  • (build): front. The scopes log shows reason=forced. Type Check, Backend Validation, every Ext suite, Webapp Unit Tests and Build Verification run. 🚀 Deploy Production skips, because force_deploy is false.
  • fix: scope check. The scopes log shows reason=forced. Type Check, every Ext suite, Webapp Unit Tests and Build Verification run. Backend Validation skips, as today.

Accountability

  • Owner: the person who pushes this change set: the maintainer, or a builder the maintainer names.

  • Record per SP3 ([Tracking] Faster, change-aware CI/CD pipeline #554) within 7 days of the push. If a run type has not happened by then, say so and add it later.

    • Also record these items. SP3 has no slot for them.
      • Both dispatch runs: the run ids and the job list with results.
      • The next docs-only push and the next single-area pull request: the run id, M6 and the skipped jobs.
      • The next (build): deploy: the run id and the Type Check start offset.
      • The next weekly cron: the run id.
    • For the deploy, confirm the job set in the acceptance criteria. For the next weekly cron, confirm Security and one Ext suite per name in the assemble all list.
  • Metric rows. Measure the CI/CD after benchmark for v2.3 #569 defines every metric and holds the full baseline table. Read each with gh run view <id> --repo docs-plus/docs.plus --json createdAt,jobs.

    Metric Run to read Baseline Expected
    M6, docs-only push The next push whose files are all docs 217 s, run 38364279436 120 s or less; about 85–95 s, when lint ends
    M6, single-area pull request The next pull request that touches no Shared file 519 s median of 10 pull requests. None of them was single-area. Below 519 s. Record it as the first sample.
    M6, bun.lock pull request The next Dependabot bun pull request 461–579 s across the 10 baseline pull requests (run 39284030984: 461 s) Unchanged; every suite runs
    Type Check start offset, (build): deploy The next (build): deploy +12 s; changes ended at +19 s (run 39398067267) About +20 s
    M2g The next (build): deploy with no Ext suite The "M2g, no Ext suite" value in the Start the extension and webapp unit tests without waiting for backend validation #558 SP3 comment, about 303–344 s. Before set B it was 379 s over 9 runs: 35723691814, 36473904247, 36550492709, 37440315600, 37976785981, 38068638114, 38239392350, 38392614215, 39275929831. Up to about +10 s
    M1 The next (build): deploy Median 1090 s, p90 1386 s, range 777–1510 s, over 14 runs: the 9 above plus 36324777594, 36437965916, 37431581531, 39297468969, 39398067267 Within the normal range. One deploy cannot show a 10 s change, so judge by M2g.
    W, M2i, M3, M4, M5 — — Not touched by this issue. No read needed.
  • Stop condition, meaning revert (see Rollback):

    • A (build): push or a dispatch skips a job that runs there today. Check lint, security, Type Check, Backend Validation and Build Verification, and Webapp Unit Tests when has_front is true. The Ext matrix must equal the changed_extensions list in the assemble log.
    • A pull request or push skips a suite in the scope of one of its changed files, per the scope map.
    • 🔎 Detect Changes fails on a valid pull request or push.
  • Not a revert: M2g grows by more than 20 s on two deploys in a row. Comment here instead, to reopen the needs: changes edge.

Rollback

  1. Revert per SP4 ([Tracking] Faster, change-aware CI/CD pipeline #554), with git revert --no-edit <commit>. If this issue landed as several commits, revert all of them, newest first. Do not revert Stop extension README media from triggering extension test suites #562, and do not revert part of this issue.
  2. Push the revert with its default Revert "…" subject. This change touches CI files only, so no (build): deploy is needed.
  3. Check the revert. grep -c 'steps.scopes' .github/workflows/prod.docs.plus.yml prints 0. In the revert run (gh run view <id> --repo docs-plus/docs.plus --json jobs), Type Check, Webapp Unit Tests and Build Verification run and pass.
  4. There is no host step, secret or GitHub setting to undo.

Docs to update

  • .github/WORKFLOWS.md:88-89, a new §Change scopes section, and §Related files (Step 9).
  • AGENTS.md:222: one sentence (Step 9).
  • The changes job header comment in prod.docs.plus.yml (Step 3).
  • No CHANGELOG. This is a CI-only change.

Out of scope

Read first

Activity

  1. added this to the v2.3.0 milestone on Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions