Repository navigation
Run only the CI suites a pull request or push can affect #563
Description
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 Changesalso emitswebapp,admin,backendanddocs_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 forworkflow_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:
- 0 of the 10 baseline pull requests would skip a suite. They are the successful pull request runs from 2026-09-20 to 2026-10-20, with a median of 519 s. Each touches
bun.lock, the rootpackage.jsonor.github/workflows/, which are Shared:- 39284030984 and 39275480131 (chore(deps): bump cypress from 15.21.1 to 16.2.0 #540), 39274647538 (chore(deps): bump tailwind-merge from 3.6.0 to 3.7.0 #538), 39274590009 (chore(deps): bump the dev-dependencies group with 5 updates #536), 38445029108 (Bump TipTap packages to 3.32.0 for the remote-cursor fix #473), 37907249191 (chore(deps): bump cypress from 15.21.1 to 16.1.1 #385), 37904633920 (chore(deps): bump the dev-dependencies group across 1 directory with 16 updates #468), 37432063919 (chore(deps): bump the dev-dependencies group across 1 directory with 15 updates #395):
bun.lockandpackage.jsonfiles. - 39274304937 (chore(deps): bump actions/setup-node from 6.5.0 to 7.1.0 #535,
actions/setup-nodebump) and 39274283996 (chore(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 #534,actions/upload-artifactbump):.github/workflows/chatroom-e2e.yml, and for chore(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 #534 also.github/workflows/prod.docs.plus.yml.
- 39284030984 and 39275480131 (chore(deps): bump cypress from 15.21.1 to 16.2.0 #540), 39274647538 (chore(deps): bump tailwind-merge from 3.6.0 to 3.7.0 #538), 39274590009 (chore(deps): bump the dev-dependencies group with 5 updates #536), 38445029108 (Bump TipTap packages to 3.32.0 for the remote-cursor fix #473), 37907249191 (chore(deps): bump cypress from 15.21.1 to 16.1.1 #385), 37904633920 (chore(deps): bump the dev-dependencies group across 1 directory with 16 updates #468), 37432063919 (chore(deps): bump the dev-dependencies group across 1 directory with 15 updates #395):
- Push 39283678757 still runs Build Verification (161 s) and the mermaid suite (194 s). Its 17 files include
extensions/extension-mermaid/package.jsonand two Cypress.tsfiles underextensions/extension-mermaid/cypress/docs/. Those are in the webapp scope. - An extension
README.mdchange still runs the webapp scope, because the preflight reads the README. - A media-only extension change runs only lint and security. The baseline has none. Step 10 replays a synthetic one.
New cost on deploys. Type Check and Backend Validation gain a needs: changes edge.
- In deploy run 39398067267,
🔎 Detect Changesended 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
- Depends on Stop extension README media from triggering extension test suites #562. It adds
predicate-quantifier: 'some-with-excludes'to thefilterstep in the same job. It also defines the README media set, which this issue copies. - Lands after set B, which holds Start the extension and webapp unit tests without waiting for backend validation #558. The Ext suites then no longer wait for Backend Validation.
- Blocks: nothing.
- Touches the same lines as Start the extension and webapp unit tests without waiting for backend validation #558 (the
extension-testsandwebapp-unit-testsblocks) and Make CI failures reach Telegram, and fix stale CI facts #557 (thechangesjob header comment). Step 0 re-derives those lines. - Read by Measure the CI/CD after benchmark for v2.3 #569, which uses M6 from this issue's runs.
Current behaviour
Links are pinned to d0a53756f8676556358b6adc6c2397d2ed7c64c8. All paths are in .github/workflows/prod.docs.plus.yml unless named.
- Triage emits
triggered. It istrueonly for a(build):subject (prod.docs.plus.yml:97-105). Any other subject emitstriggered=false(parse-build-trigger.sh:30-34). Onpull_request,head_commitis empty, sotriggeredisfalse(:111). 🔎 Detect Changesemits only the extension list (:124-176).- Outputs:
changed_extensionsonly (: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.
- Outputs:
- Type Check runs on every non-cron event (
:189-197):needs: [triage],if: github.event_name != 'schedule' && needs.triage.outputs.skip_app_ci != 'true'. - 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_e2eisgithub.event_name != 'pull_request'(:270), so any non-PR run of it also runs Backend E2E (backend-ci.yml:44-46).
- A push without
- The Ext suites follow the extension filter only (
:272-281). A change toextensions/<ext>/docs/*.mdstarts that suite today, about 194 s for mermaid. - 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'). - Build Verification runs whenever its gates pass (
:351-376). It accepts askippedBackend Validation, Ext suite or Webapp Unit Tests result (:374-376). It demandssuccessfrom lint, typecheck and security (:371-373). - Lint and security stay as they are (
:181-187,:214-222). Lint runsprettier --check ., which checks Markdown (quality-lint.yml:23-27). - The deploy gate stays as it is (
:431-435). It needsbuildto succeed. Every scope is on for a(build):push, sobuildstill runs before every deploy. - No branch protection and no required status check exist.
gh api repos/docs-plus/docs.plus/branches/main/protectionreturns404 Branch not protected. The only ruleset,main safety(id21301824), holdsnon_fast_forwardanddeletionrules. So a skipped job blocks no merge. - 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/playgroundis a dev dependency (:103).packages/playground/**stays Unmatched, which turns every scope on. - The backend depends on
@docs.plus/email-templatesand three extensions: hyperlink, hypermultimedia and inline-code (apps/hocuspocus.server/package.json:45-48). apps/admin-dashboardhas no workspace dependency.- A webapp unit test reads
packages/supabaseSQL files (chatMediaMime.test.ts:17-20). Sopackages/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 underdocs/. - One Markdown file outside
apps/*/src/is read by code:packages/document-swarm/src/fixtures/academy.md(academy.ts:30). So.mdunder anysrc/folder counts as code.
- The webapp depends on all six extensions and
dorny/paths-filterv4.0.3 (ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d):dot: truemakes 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: jsonwritesJSON.stringifyof the file names (src/main.ts:296-297) and<key>_count(src/main.ts:274).
- The docs describe today's rule.
.github/WORKFLOWS.mdsays a normal commit runs "gates only" and a pull request runs "gates + build verification" (WORKFLOWS.md:88-89).AGENTS.mdwarns that only the extension suites are path-gated (AGENTS.md:222). parse-build-trigger.test.shruns 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_onlyistrueonly when every path is in row 2..github/WORKFLOWS.mdand.github/PULL_REQUEST_TEMPLATE.mdare in row 2, because row 2 comes before row 4.- Every scope turns on, and
docs_onlyisfalse, in these cases:- the event is
workflow_dispatchorschedule; github.event.beforeis all zeros;- the event is a
pushwithtriggered == 'true'; - the file count is missing or above 500;
- the list is empty, or its length differs from the count.
- the event is
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
- Confirm you are on an up-to-date
mainwithgit statusandgit log -1 --format=%H. - 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
- Expected sibling edits: Stop extension README media from triggering extension test suites #562 (the
filterstep,extensions.yaml,check-ci.ts), Start the extension and webapp unit tests without waiting for backend validation #558 (theextension-testsandwebapp-unit-testsneedsandif), Make CI failures reach Telegram, and fix stale CI facts #557 (comments and timeouts), Write a timing table to every CI/CD run summary #556 (a new job), Roll back only after the deploy has started rolling services #555 (deploy steps). Re-derive every line number this issue cites. - If any other edit touches the triage outputs, the
changesjob, or thetypecheck,back-validation,extension-tests,webapp-unit-testsorbuildblocks, stop and comment here.
- Expected sibling edits: Stop extension README media from triggering extension test suites #562 (the
- 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_MEDIAregex 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-assetsfails because a nestedassets/file now reaches row 5, change that line toexpect true false true false nested-assets. Any other failure: stop and comment here.
- Confirm the
dorny/paths-filterpin 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, theprevious_filenamepush,_count, andJSON.stringify(fileNames). - If the workflow pins another SHA now, run the same reads at that SHA. If a fact fails, stop and comment here.
- Expect
- 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 norequired_status_checksin the rule list. Restrict the production self-hosted runner to main deploys #552 may add another rule tomain 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.
- Expect
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
casepattern anchors at the start of the path, sopackage.jsonandtsconfig*.jsonmatch only root files. - In a
casepattern,*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.shprints nothing, ifshellcheckis 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 ]- Run
bash .github/scripts/detect-change-scopes.test.sh. ExpectPASS=30 FAIL=0and exit 0. - Sabotage. In the script, delete
extensions/*/README.md|from the row 1 pattern. Run the test again. ExpectFAIL [ext-readme]and exit 1. - Restore the line. Run the test again and expect
PASS=30 FAIL=0. Paste both outputs in the review. - 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
- 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.
- 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 }}
- Two new steps after the
assemblestep (after:176). Do not changefilterorassemble.# 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-filterSHA as thefilterstep. 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.
jqis preinstalled onubuntu-latest.
- Use the same
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
ifalso needstriageandchangesto succeed. Ifchangesfails, Type Check skips, and Build Verification then blocks the deploy. Today a failedchangesblocks 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' &&- Keep every other line as Start the extension and webapp unit tests without waiting for backend validation #558 left it.
docs_onlyis alwaysfalseon a(build):push, a dispatch and the cron, so those keep today's suites.
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-validationout ofneedsand keep noneeds.back-validation.resultline. - A
(build): backpush still skips this job. Itshas_frontisfalseandtriggeredistrue, 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
typecheckclause. Whenwebapporadminis on, Type Check runs, so itssuccessdemand stays right.
Step 9. Docs
.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 |
.github/WORKFLOWS.md, new section between the truth table and## Related files:Paste both tables from Target behaviour in place of the angle-bracket line.## 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.
.github/WORKFLOWS.md§Related files. Add- Change-scope classifier: .github/scripts/detect-change-scopes.sh (test: detect-change-scopes.test.sh).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.- Format the touched files:
bunx prettier --write .github/WORKFLOWS.md AGENTS.md .github/workflows/prod.docs.plus.yml.
Step 10. Local checks
actionlint -config-file .github/actionlint.yaml .github/workflows/prod.docs.plus.ymlprints nothing.bash .github/scripts/detect-change-scopes.test.shprintsPASS=30 FAIL=0.bash .github/scripts/parse-build-trigger.test.shstill printsFAIL=0. Triage does not change, so this is a guard.- Replay four real file lists through the script. A
dry_run_subjectdispatch 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.mdwebapp=false admin=false backend=false docs_only=truetriage, Detect Changes, lint, security Webapp-only, 0918031d07 files under apps/webapp/src/, plus.cursor/docs/design-system.mdwebapp=true admin=false backend=false docs_only=falsethe above, plus Type Check, Webapp Unit Tests, Build Verification GIF-only, synthetic two extensions/extension-mermaid/assets/demo-*.giffileswebapp=false admin=false backend=false docs_only=falsetriage, 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 #540apps/webapp/package.json,bun.lock,package.jsonwebapp=true admin=true backend=true docs_only=falseevery gate and all six Ext suites, as today bun run formatpasses.bun run check:agent-docspasses.
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.shand.github/scripts/detect-change-scopes.test.shexist. The test printsPASS=30 FAIL=0and failed once under the Step 2 sabotage. -
🔎 Detect Changesoutputswebapp,admin,backendanddocs_only. Thescopesstep log shows one class line per path and areasonline. -
ALL_SCOPESistrueforworkflow_dispatch,schedule, an all-zerosbeforeand everypushwithtriggered == '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_onlyistrue. -
actionlintprints nothing forprod.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): frontdispatch, 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 whenhas_frontistrue. Its Ext matrix equals thechanged_extensionslist in itsassemblelog. - The next weekly cron runs
🔒 Security Auditand one Ext suite for each name in thealllist of theassemblestep. Type Check, Webapp Unit Tests and Build Verification skip, as in run 39089384453. -
.github/WORKFLOWS.mdandAGENTS.md:222say 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-docsThen 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.
- Run SP1 ([Tracking] Faster, change-aware CI/CD pipeline #554) before each dispatch. The
deployjob comment atprod.docs.plus.yml:415-418says a push also cancels a running roll. Build the deploy images while the quality gates run #565 shows that a runningdeploysurvives (run 34223310158). - Run one dispatch at a time. Wait for it with
gh run watch <id> --repo docs-plus/docs.plusbefore you start the next one. - Nobody pushes to
mainwhile 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. Thescopeslog showsreason=forced. Type Check, Backend Validation, every Ext suite, Webapp Unit Tests and Build Verification run.🚀 Deploy Productionskips, becauseforce_deployisfalse.fix: scope check. Thescopeslog showsreason=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
assemblealllist.
- Also record these items. SP3 has no slot for them.
-
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.lockpull requestThe next Dependabot bunpull request461–579 s across the 10 baseline pull requests (run 39284030984: 461 s) Unchanged; every suite runs Type Check start offset, (build):deployThe next (build):deploy+12 s; changesended at +19 s (run 39398067267)About +20 s M2g The next (build):deploy with no Ext suiteThe "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):deployMedian 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 whenhas_frontistrue. The Ext matrix must equal thechanged_extensionslist in theassemblelog. - A pull request or push skips a suite in the scope of one of its changed files, per the scope map.
🔎 Detect Changesfails on a valid pull request or push.
- A
-
Not a revert: M2g grows by more than 20 s on two deploys in a row. Comment here instead, to reopen the
needs: changesedge.
Rollback
- 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. - Push the revert with its default
Revert "…"subject. This change touches CI files only, so no(build):deploy is needed. - Check the revert.
grep -c 'steps.scopes' .github/workflows/prod.docs.plus.ymlprints0. 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. - 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
changesjob header comment inprod.docs.plus.yml(Step 3). - No CHANGELOG. This is a CI-only change.
Out of scope
- The extension filter, its media excludes and
predicate-quantifier. Stop extension README media from triggering extension test suites #562 owns them. - Removing the
back-validationwait from the Ext suites and Webapp Unit Tests. Start the extension and webapp unit tests without waiting for backend validation #558 owns it. - The "~14 min" comment and other stale facts. Make CI failures reach Telegram, and fix stale CI facts #557 owns them.
- The run timing table and the
reportjob. Write a timing table to every CI/CD run summary #556 owns them. - Skipping anything on a
(build):push, including the backend images or Backend Validation on(build): front. - Gating lint or security on scopes. A gate on
changeswould add 7–15 s before the image build and save 0 s. - Running Build Verification beside the gates. The tracker rejects it.
- Changing
scripts/check-ci.ts. It runs every non-extension gate locally, a safe superset. stage.docs.plus.ymlandchatroom-e2e.yml. They keep their own triggers.- Branch protection or required status checks. None exist. Adding them is a separate maintainer decision.
- Narrowing
.github/**, and adding Backend Validation to plain pushes. - A Bun or TypeScript version of the script, and any Docker change. AGENTS.md §Docker, the metascraper pin and the Bun floor 1.4.0 do not change here.
Read first
AGENTS.md: §Test Policy, §Workflow And Review Expectations, §Simplified English Mandate, and the paragraph at line 222.CLAUDE.md§Hard invariants: Bun only, no commits..github/WORKFLOWS.md, in full..github/workflows/prod.docs.plus.ymllines 82–405, atmain..github/filters/extensions.yamlandscripts/check-ci.ts, as Stop extension README media from triggering extension test suites #562 left them..github/scripts/parse-build-trigger.shand.github/scripts/parse-build-trigger.test.sh..github/workflows/backend-ci.ymland.github/workflows/quality-lint.yml.- Stop extension README media from triggering extension test suites #562 and Start the extension and webapp unit tests without waiting for backend validation #558, in full.
- The
dorny/paths-filterv4.0.3 README, section "list-files".
Activity