mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 03:00:26 +01:00
Let pull requests that only change human-facing docs skip the test suites (GST-1951) (#4178)
* Let pull requests that only change human-facing docs skip the test suites (GST-1951) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Quote the docs test list in the docs lane so shellcheck passes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the Windows smoke check green on docs-only pull requests and update the workflow contract tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the merged test check when the base gained a reader of a changed doc, hold the land policy key until a release reads it, and register two more docs reader tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Check links in untracked docs and docs with quoted or spaced names, and report an undecodable anchor instead of crashing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Point the flaky-exception anchor back at its section and test the link checker's exit codes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b6155921a5
commit
e077053c3c
32 files changed
+1136
-70
No files matched your search
@@ -0,0 +1,33 @@
|
||||
name: docs-scope
|
||||
description: >-
|
||||
R81 (docs/pregate.md#docs-only). Classify a pull request's merge commit against its first parent with the base
|
||||
branch's bin/gstack-docs-only. The caller checks out the merge ref with fetch-depth 2 and sets up Bun. A base without
|
||||
the classifier, a classifier error or a crash all read as not docs-only, so the full suites run.
|
||||
outputs:
|
||||
docs_only:
|
||||
description: true only when every changed path is human docs
|
||||
value: ${{ steps.scope.outputs.docs_only }}
|
||||
tests:
|
||||
description: space-separated docs lane tests (tests that read docs by directory)
|
||||
value: ${{ steps.scope.outputs.tests }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: .docs-scope-base
|
||||
persist-credentials: false
|
||||
- id: scope
|
||||
shell: bash
|
||||
run: |
|
||||
set -u
|
||||
if [ "$GITHUB_EVENT_NAME" != pull_request ] || [ ! -f .docs-scope-base/bin/gstack-docs-only ]; then
|
||||
echo "DOCS_ONLY: no (event $GITHUB_EVENT_NAME, or the base branch has no gstack-docs-only yet)"
|
||||
echo 'docs_only=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
code=0
|
||||
bun --no-env-file --config=/dev/null .docs-scope-base/bin/gstack-docs-only --repo "$GITHUB_WORKSPACE" --base HEAD^1 --head HEAD --github-output "$GITHUB_OUTPUT" || code=$?
|
||||
if [ "$code" -gt 1 ]; then echo "::warning::gstack-docs-only exited $code; the full suites run (docs/troubleshooting.md#docs-only-error)"; fi
|
||||
exit 0
|
||||
@@ -154,9 +154,61 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bun --no-env-file --config=/dev/null run scripts/ci-tree-reuse.ts find --workflow free-tests.yml --profile ci-reuse/free-tests --pull-request
|
||||
|
||||
# R81 (docs/pregate.md#docs-only): a pull request whose every path is human docs runs
|
||||
# docs-lane instead of the suites. The classifier is the base branch's copy, run on
|
||||
# the merge commit against its first parent; any error reads as not docs-only.
|
||||
docs-scope:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
docs_only: ${{ steps.scope.outputs.docs_only }}
|
||||
tests: ${{ steps.scope.outputs.tests }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- id: scope
|
||||
uses: ./.github/actions/docs-scope
|
||||
|
||||
docs-lane:
|
||||
needs: docs-scope
|
||||
if: needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- name: Install frozen dependencies
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
- name: Configure git identity (docs reader tests init temp repos)
|
||||
run: |
|
||||
git config --global user.email "free-tests-ci@gstack.test"
|
||||
git config --global user.name "Free Tests CI"
|
||||
git config --global init.defaultBranch main
|
||||
- name: Links on the lines this pull request added resolve
|
||||
run: git diff --name-only -z --diff-filter=AMR HEAD^1 HEAD -- '*.md' | xargs -0 -r bun run scripts/check-doc-links.ts --base HEAD^1
|
||||
- name: Run the tests that read docs by directory
|
||||
env:
|
||||
DOCS_TESTS: ${{ needs.docs-scope.outputs.tests }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "$DOCS_TESTS" ]; then exit 0; fi
|
||||
read -ra tests <<< "$DOCS_TESTS"
|
||||
bun --no-env-file test --timeout=30000 "${tests[@]}"
|
||||
|
||||
free-plan:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
needs: [reuse, docs-scope]
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -214,8 +266,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
cso-macos-launcher:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
needs: [reuse, docs-scope]
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -255,8 +307,8 @@ jobs:
|
||||
# the Codex probe from zsh, macOS's default shell (#3024; no paid call), and
|
||||
# runs ubi-runner under macOS's bsdtar and one-true-awk (#3680, #3679).
|
||||
macos-named-regressions:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
needs: [reuse, docs-scope]
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -301,8 +353,8 @@ jobs:
|
||||
bun test --timeout=30000 "${files[@]}"
|
||||
|
||||
cso-windows-launcher:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
needs: [reuse, docs-scope]
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -331,8 +383,8 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
cso-docker-integration:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
needs: [reuse, docs-scope]
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -363,8 +415,8 @@ jobs:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
typecheck:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
needs: [reuse, docs-scope]
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -388,8 +440,8 @@ jobs:
|
||||
# the quick tier also runs on it. Its pin equals GSTACK_BUN_DEFAULT;
|
||||
# test/bun-version-drift.test.ts checks this job exists and agrees.
|
||||
quick-bun-default:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
needs: [reuse, docs-scope]
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -719,13 +771,16 @@ jobs:
|
||||
# else takes the strict path below unchanged, which alone uploads tested-tree.
|
||||
free-tests:
|
||||
if: always()
|
||||
needs: [reuse, free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
|
||||
needs: [reuse, docs-scope, docs-lane, free-suite, typecheck, quick-bun-default, cso-macos-launcher, macos-named-regressions, cso-windows-launcher, cso-docker-integration]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Take the reuse path only for a verified main-push or pull request reuse
|
||||
- name: Take the docs-only path for a docs-only pull request, or the reuse path only for a verified main-push or pull request reuse
|
||||
id: route
|
||||
env:
|
||||
DOCS_SCOPE_RESULT: ${{ needs.docs-scope.result }}
|
||||
DOCS_ONLY: ${{ needs.docs-scope.outputs.docs_only }}
|
||||
DOCS_LANE_RESULT: ${{ needs.docs-lane.result }}
|
||||
REUSE_RESULT: ${{ needs.reuse.result }}
|
||||
REUSED: ${{ needs.reuse.outputs.reused }}
|
||||
REUSE_RUN_ID: ${{ needs.reuse.outputs.run_id }}
|
||||
@@ -733,8 +788,17 @@ jobs:
|
||||
REUSE_TREE: ${{ needs.reuse.outputs.tree }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "$DOCS_SCOPE_RESULT" = success ] && [ "$DOCS_ONLY" = true ]; then
|
||||
test "$GITHUB_EVENT_NAME" = pull_request || { echo "::error::docs-only claimed on a $GITHUB_EVENT_NAME run"; exit 1; }
|
||||
test "$DOCS_LANE_RESULT" = success || { echo "::error::docs-lane concluded $DOCS_LANE_RESULT"; exit 1; }
|
||||
echo 'Docs-only pull request: the docs lane passed and the suites were skipped; this run records no tested tree (docs/pregate.md#docs-only).' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo 'reused=false' >> "$GITHUB_OUTPUT"
|
||||
echo 'skip=true' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then
|
||||
echo 'reused=false' >> "$GITHUB_OUTPUT"
|
||||
echo 'skip=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
case "$GITHUB_EVENT_NAME:$GITHUB_REF" in
|
||||
@@ -746,8 +810,9 @@ jobs:
|
||||
test "$REUSE_RUN_URL" = "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$REUSE_RUN_ID"
|
||||
echo "Reused $REUSE_RUN_URL, which passed on tree $REUSE_TREE; this push ran no suite of its own." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo 'reused=true' >> "$GITHUB_OUTPUT"
|
||||
echo 'skip=true' >> "$GITHUB_OUTPUT"
|
||||
- name: Require the free suite, typecheck, the quick tier on the default Bun, macOS regressions, and every CSO platform gate
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
env:
|
||||
FREE_SUITE_RESULT: ${{ needs.free-suite.result }}
|
||||
TYPECHECK_RESULT: ${{ needs.typecheck.result }}
|
||||
@@ -766,20 +831,20 @@ jobs:
|
||||
test "$CSO_WINDOWS_RESULT" = success
|
||||
test "$CSO_DOCKER_RESULT" = success
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
with:
|
||||
name: free-plan
|
||||
path: ${{ runner.temp }}
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
with:
|
||||
pattern: free-result-*
|
||||
merge-multiple: true
|
||||
@@ -788,26 +853,26 @@ jobs:
|
||||
# flake-ledger.jsonl). A green run is where a retried flake hides, so the
|
||||
# summary runs before strict verification.
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
with:
|
||||
pattern: flake-ledger-*
|
||||
path: ${{ runner.temp }}/flake-ledgers
|
||||
- name: Summarize flaky passes absorbed by the serial retry
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
run: bun run scripts/test-health-report.ts flake-summary "$RUNNER_TEMP/flake-ledgers" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Require exact coverage and complete strict results
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
run: bun run scripts/test-free-shards.ts --ci-verify "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results"
|
||||
# Only after strict verification passed, so a red or partial run never
|
||||
# leaves a record; the reuse path never reaches here.
|
||||
- name: Record the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
SIZING_OVERRIDE: ${{ inputs.free_runner }}${{ inputs.free_shards }}${{ inputs.free_jobs }}
|
||||
run: bun run scripts/ci-tree-reuse.ts record --plan "$RUNNER_TEMP/free-plan.json" --results "$RUNNER_TEMP/free-results" --os Linux,macOS,Windows --out "$RUNNER_TEMP/tested-tree/tree.json"
|
||||
- name: Upload the tested tree
|
||||
if: steps.route.outputs.reused != 'true'
|
||||
if: steps.route.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: tested-tree
|
||||
|
||||
@@ -62,19 +62,53 @@ jobs:
|
||||
github-token: ''
|
||||
enable-annotation: 'true'
|
||||
|
||||
windows-smoke:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
# R81 (docs/pregate.md#docs-only): windows-smoke is a required check, and gstack's
|
||||
# land counts only a check that concluded success, so the job always runs; its steps
|
||||
# skip only when docs-scope succeeded and said docs-only. A crashed or failed
|
||||
# classifier runs the smoke.
|
||||
docs-scope:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
docs_only: ${{ steps.scope.outputs.docs_only }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- id: scope
|
||||
uses: ./.github/actions/docs-scope
|
||||
|
||||
windows-smoke:
|
||||
needs: docs-scope
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
DOCS_ONLY: ${{ needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true' }}
|
||||
steps:
|
||||
- name: A docs-only pull request owes no Windows smoke
|
||||
if: env.DOCS_ONLY == 'true'
|
||||
run: |
|
||||
echo 'Docs-only pull request (docs/pregate.md#docs-only): docs-scope passed, so the smoke steps were skipped.' >> "$GITHUB_STEP_SUMMARY"
|
||||
shell: bash
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
|
||||
- name: Configure git identity (required by tests that init temp repos)
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
run: |
|
||||
git config --global user.email "windows-smoke@gstack.test"
|
||||
git config --global user.name "Windows Smoke"
|
||||
@@ -82,10 +116,12 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
run: bun install --frozen-lockfile
|
||||
shell: bash
|
||||
|
||||
- name: ./setup installs into an isolated HOME
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
|
||||
@@ -95,6 +131,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: The core skills start
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
|
||||
@@ -112,6 +149,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: /browse on gstack's own browser runs a piped chain flow from an unrelated directory
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SMOKE_HOME="$RUNNER_TEMP/smoke-home"
|
||||
@@ -137,6 +175,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Fastest Windows-safe free tests for install and the core skills
|
||||
if: env.DOCS_ONLY != 'true'
|
||||
# Windows-measured seconds from scripts/free-test-durations-windows.json.
|
||||
# setup-windows-fallback (0.1): setup's _link_or_copy copies, never links, on Windows
|
||||
# setup-staged-copy (0.6): a copy install is staged and swapped in only when complete
|
||||
@@ -181,7 +220,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Upload full test logs
|
||||
if: always()
|
||||
if: always() && env.DOCS_ONLY != 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: windows-smoke-logs
|
||||
@@ -192,7 +231,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload flake ledger
|
||||
if: always()
|
||||
if: always() && env.DOCS_ONLY != 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: flake-ledger-windows-smoke
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"free-tests.yml/free-suite": "bun run scripts/test-free-shards.ts --list"
|
||||
},
|
||||
"run_profiles": {
|
||||
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux" },
|
||||
"ci-reuse/free-tests": { "workflow": "free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration", "free-tests"], "artifact": "tested-tree", "os": "Linux", "skippable": { "jobs": ["free-plan", "typecheck", "quick-bun-default", "cso-macos-launcher", "macos-named-regressions", "cso-windows-launcher", "cso-docker-integration"], "gate": "docs-scope", "scripts": [".github/actions/docs-scope/action.yml", "bin/gstack-docs-only", "lib/docs-only.ts", "lib/review-depth.ts", "lib/ship-policy.ts"] } },
|
||||
"ci-reuse/windows-free-tests": { "workflow": "windows-free-tests.yml", "events": ["pull_request", "workflow_dispatch"], "jobs": ["windows-plan", "windows-free-tests"], "artifact": "tested-tree", "os": "Windows" }
|
||||
},
|
||||
"remote_gate_runs": {
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-docs-only — is this change human-facing docs only? (R81; docs/pregate.md#docs-only; logic: lib/docs-only.ts)
|
||||
*
|
||||
* gstack-docs-only --base <ref> [--head <ref>] [--repo <dir>] [--json] [--github-output <file>]
|
||||
*
|
||||
* --head given: the diff between the two commits (CI: --base HEAD^1 --head HEAD on the pull request merge commit).
|
||||
* --head omitted: the merge base of <ref> and HEAD to the working tree, untracked files included (pregate, ci-carry).
|
||||
* Prints DOCS_ONLY: yes (...) and exits 0, DOCS_ONLY: no (...) and exits 1, or DOCS_ONLY: error (DOCS_ONLY_ERROR ...)
|
||||
* and exits 2. --github-output appends docs_only=true|false and tests=<files> to that file; it writes true only for a
|
||||
* pull_request event (GITHUB_EVENT_NAME), so a push, dispatch or merge-group run always takes the full suites.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyDocsOnly, DocsOnlyError, DOCS_ONLY_ANCHOR, DOCS_ONLY_ERROR } from '../lib/docs-only';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
||||
const base = flag('--base');
|
||||
const head = flag('--head');
|
||||
const ghOut = flag('--github-output');
|
||||
const repo = path.resolve(flag('--repo') ?? process.cwd());
|
||||
const write = (docsOnly: boolean, tests: string[] = []) => { if (ghOut) fs.appendFileSync(ghOut, `docs_only=${docsOnly}\ntests=${tests.join(' ')}\n`); };
|
||||
|
||||
if (!base || args.includes('--help')) {
|
||||
console.log('usage: gstack-docs-only --base <ref> [--head <ref>] [--repo <dir>] [--json] [--github-output <file>]');
|
||||
process.exit(base ? 0 : 2);
|
||||
}
|
||||
const event = process.env.GITHUB_EVENT_NAME;
|
||||
if (ghOut && event !== 'pull_request') {
|
||||
write(false);
|
||||
console.log(`DOCS_ONLY: no (event ${event || 'unset'}: only a pull_request run may skip the test suites)`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const r = classifyDocsOnly({ root: repo, base, head });
|
||||
write(r.docsOnly, r.tests);
|
||||
if (args.includes('--json')) console.log(JSON.stringify(r));
|
||||
if (r.docsOnly) {
|
||||
console.log(`DOCS_ONLY: yes (${r.files.length} file(s); docs lane tests: ${r.tests.join(' ') || 'none'})`);
|
||||
process.exit(0);
|
||||
}
|
||||
const [first] = r.refusals;
|
||||
console.log(`DOCS_ONLY: no (${r.files.length} file(s); ${first!.path}: ${first!.reason}${r.refusals.length > 1 ? `; +${r.refusals.length - 1} more` : ''})`);
|
||||
process.exit(1);
|
||||
} catch (e) {
|
||||
write(false);
|
||||
const err = e instanceof DocsOnlyError ? e : new DocsOnlyError('gstack-docs-only', String((e as Error)?.message ?? e), 'rerun in a git checkout of this repository');
|
||||
console.log(`DOCS_ONLY: error (${DOCS_ONLY_ERROR}: ${err.command}: ${err.detail}; fix: ${err.fix}; see ${DOCS_ONLY_ANCHOR})`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
# How we work: gstack's factory on Capy
|
||||
|
||||
What seven Captain crews agreed on. Your crew's CREW.md and recorded approvals decide who may merge, update a base, spend or touch production; urgency changes priority, never permissions or gates. With no Captain, ask the person; never merge without their word. Story: capy/README.md; examples: org Drive software-factory/README.md.
|
||||
Your crew's CREW.md and recorded approvals decide who may merge, update a base, spend or touch production; urgency changes priority, never permissions or gates. With no Captain, ask the person; never merge without their word. Story: capy/README.md; examples: org Drive software-factory/README.md.
|
||||
|
||||
1. **Plan, then a yes.** New functionality starts with /autoplan sized to the work; the build waits for "do it". [SKILL.md: Plan, approve, ship]
|
||||
2. **Someone else merges.** Run /ship to a receipt on your final pushed head, send one READY quoting gstack's verify output verbatim, and stop. The merge owner merges on green and assigns versions. [crew/docs/captain.md: The READY contract]
|
||||
2. **Someone else merges.** Run /ship to a receipt on your final pushed head, send one READY quoting gstack's verify output verbatim, and stop. The merge owner merges on green and assigns versions; docs-only PRs run only docs checks. [crew/docs/captain.md: The READY contract]
|
||||
3. **Block only on the floor.** A finding blocks if it would fail a test, break production, or lose data or secrets; the rest goes in the PR body or next wave. [docs/review-policy.md]
|
||||
4. **Fix waves.** When main goes red, hold /ship while one owner finds the root cause; bring main in once, as your rules allow. Fold deferred findings and test follow-ups into one PR. [docs/triage.md]
|
||||
5. **Severe first, same gates.** A severe user-facing bug jumps the queue with a short plan; receipt, checks and merge owner stay.
|
||||
@@ -13,5 +13,5 @@ What seven Captain crews agreed on. Your crew's CREW.md and recorded approvals d
|
||||
9. **Flakes: one rerun, a ledger, a daily fix.** Only the policy's rerun, never for a harness or infra red; a second red is real; never loosen a test. [crew/docs/captain.md: The daily flaky pass]
|
||||
10. **Quiet.** Messages wake the Captain cold: send READY, a blocker, done or a Captain or owner call, else end_turn; no acks or progress notes. Broadcasts go in its notes.
|
||||
11. **Ask once, plainly.** One ask per decision, explained like the owner is five: your Captain's name, then a lettered line each: "A: <code> hit problem p, which happens when h. We could do 1) x, 2) y or 3) z. Recommendation x because why. A1 to take recommendation." "do it" takes every recommendation; "A2" takes option 2. Owner tasks: the exact link (in Capy: path and field), numbered steps, the reply word. Never re-ask a standing rule; check what you can yourself. [crew/docs/captain.md: The ask format]
|
||||
12. **Fresh threads, closed out.** One thread per unit of work; past 500M tokens, rotate [/runaway-stopper]. Crew threads never create threads: ask your Captain (Captain to Captain across projects), or report one you made by code for crew.add. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff; read the meter. [docs/ship-budget.md; crew/docs/captain.md: New threads join the crew]
|
||||
12. **Fresh threads, closed out.** One thread per unit of work; past 500M tokens, rotate [/runaway-stopper]. Crew threads never create threads: ask your Captain (Captain to Captain across projects), or report one you made by code for crew.add. Its Captain archives only its crew's threads; threads outside every crew are the owner's, even its automation's. Size reviews to the diff. [docs/ship-budget.md; crew/docs/captain.md: New threads join the crew]
|
||||
13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference]
|
||||
@@ -0,0 +1 @@
|
||||
- **A pull request that only changes human-facing docs now skips the test suites.** It runs the docs lane instead: links on the lines it added resolve, generated pages stay fresh, the credential scan passes, and the few tests that read docs by directory run. Required checks still report success, so branch protection doesn't hang, and pushes to main keep the full suites. Most Markdown in gstack is agent instructions, so the class is a short allowlist (root README.md, docs/**/*.md, changelog.d/*.md) with a denylist on top. SKILL.md, templates, sections, policy docs, captain and crew files, pinned results, preregistrations, generated pages, and any doc a script or test names all still run full CI. One classifier (`gstack-docs-only`) decides the class for CI, pregate, CI carry, /ship's lanes and land. A crashed classifier, an unknown ref, or a push to main always means the full suites run. Garry, 2026-11-08: "For docs changes pls stop doing full CI and merge without tests. If there are really only docs." (GST-1951)
|
||||
@@ -1432,6 +1432,63 @@ on the head (a branch dispatch) or runs every job. The usual case is a branch
|
||||
that already holds main's tip. Dispatch Free Tests on the branch, and the
|
||||
pull request run that follows reuses it instead of running the suite again.
|
||||
|
||||
<a id="docs-only"></a>
|
||||
## Docs-only pull requests (R81)
|
||||
|
||||
A pull request whose every changed path is human-facing documentation skips the test
|
||||
suites. It runs the docs lane instead: links on the lines it added resolve, generated pages
|
||||
are fresh, the credential scan passes, and the tests that read docs by directory run. Pushes
|
||||
to the base branch always run the full suites.
|
||||
|
||||
**One classifier.** `lib/docs-only.ts` (`bin/gstack-docs-only`) decides the class for CI,
|
||||
pregate, `gstack-ci-carry`, /ship's lanes and land. A path is docs only when every one of
|
||||
these holds:
|
||||
|
||||
- It is a plain file (mode 100644). Executables, symlinks, submodules and type changes refuse.
|
||||
- It is on the allowlist, matched case-sensitively: root `README.md`, `docs/**/*.md`, `changelog.d/*.md`.
|
||||
- review-depth's `classifyPath` calls it docs, so agent prose, skill directories and policy docs stay code.
|
||||
- It matches no denylist entry. The denylist covers agent-instruction names matched case-insensitively
|
||||
(`SKILL.md`, `AGENTS.md`, `CLAUDE.md`, `ETHOS.md`, `CREW.md`, `captain.md`, `factory.md`, `pregate.md`,
|
||||
`unattended.md` and others), `*-policy.md`, preferences, `sections/`, `templates/`, `*.tmpl`, pinned
|
||||
results (`docs/evals/`, `docs/inventory/`), preregistrations, and every output in `.gstack/generated.json`.
|
||||
- No tracked file outside human prose names its path. A script, test, workflow or skill that reads a doc pins it.
|
||||
`CHANGELOG.md`, `TODOS.md` and other allowlisted docs don't pin. The match is a substring match, so it errs
|
||||
toward pinning.
|
||||
|
||||
A rename needs both sides to pass, and a deletion needs the old path to pass. An empty diff, an
|
||||
unknown ref or any git failure is never docs-only: the CLI prints `DOCS_ONLY: error (DOCS_ONLY_ERROR …)`,
|
||||
exits 2 and writes `docs_only=false` ([troubleshooting](troubleshooting.md#docs-only-error)).
|
||||
|
||||
**Tests that read docs by directory.** A literal-path scan can't see a test that walks `docs/` or
|
||||
lists every tracked file. `DOCS_READER_TESTS` in `lib/docs-only.ts` names them (for example
|
||||
`test/docs-config-keys.test.ts`), and the docs lane runs the ones whose tree the change touches.
|
||||
`test/docs-only.test.ts` fails on a new reader that is in neither `DOCS_READER_TESTS` nor `DOCS_READER_EXEMPT`.
|
||||
|
||||
**CI.** In `free-tests.yml` and `windows-smoke.yml`, a `docs-scope` job checks out the merge ref
|
||||
with `fetch-depth: 2` and runs the base branch's classifier (`.github/actions/docs-scope`) on
|
||||
`HEAD^1..HEAD`. That is the exact tree CI tests, and a branch can't change the classifier that judges it.
|
||||
Each suite job skips only when `docs-scope` succeeded and said docs-only, so a crashed classifier runs
|
||||
everything. A required check that is itself the suite, `windows-smoke`, always runs and skips its steps
|
||||
instead (`env.DOCS_ONLY`), because gstack's land counts only a check that concluded success, never a skipped
|
||||
one. `docs-lane` runs `scripts/check-doc-links.ts --base HEAD^1` and the docs reader tests. The required
|
||||
`free-tests` aggregator always runs. On a docs-only pull request it passes only after `docs-lane` passed, and it
|
||||
records no `tested-tree`, so a push to main never reuses that run. Use the same jobs in another
|
||||
repository: copy `.github/actions/docs-scope`, add a `docs-scope` job, and gate each suite job with
|
||||
`${{ !cancelled() && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}`.
|
||||
Never use a workflow-level `paths-ignore`: a required check that never starts stays pending.
|
||||
|
||||
**Pregate and /ship.** On a docs-only change the `lanes` check runs the docs lane in place of the
|
||||
test lanes and owes no remote gate. The `regen` and `secrets` checks run as always. `ci-reuse/free-tests`
|
||||
declares `docs-scope` as its `skippable` gate. A pull request run that skipped every suite carries as not
|
||||
owed (`CI_CARRY: accepted ci-carry tree=… not_owed=docs-only`), but only when this checkout's classifier
|
||||
also calls the head docs-only. Without that check, the run is never evidence (`artifact_missing`).
|
||||
|
||||
**Land.** `land.docs_only_skip` in `.gstack/ship-policy.json` names the `merged_checks` commands land
|
||||
skips for a docs-only pin, such as the test list, keeping typecheck and the freshness check. Land keeps every
|
||||
check when a non-docs file on the base tip names a changed doc, because the pin merges onto that tip. A gstack
|
||||
release older than this change refuses the key as unknown, so main's policy adds it only with `min_gstack_version`
|
||||
raised to the first release that reads it; until then land runs every merged check on a docs-only pin.
|
||||
|
||||
<a id="flaky-exception"></a>
|
||||
## Owner-granted flaky exception
|
||||
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ branch that changes the policy.
|
||||
| `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base <base>` (the caps come from `origin/<base>`'s policy, never the branch's copy; `--policy <file>` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: <path> <bytes>/<max> ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. |
|
||||
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd, land? }`, `{ gates, land, max_size, fill_hours }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. A block with `gates` ([any repository](#train-gates)) takes `max_size` 1 to 5 and needs `min_gstack_version` at least `TRAIN_GATES_KEY_VERSION`. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
|
||||
| `merge` | `train` \| `direct` | `train` | `direct` lands each READY PR at its own green head with [`land --pr <n> --pin <sha>`](#land-direct): no train and no stamp commit, and `train build` refuses. A PR then leaves VERSION, the `package.json` version and the CHANGELOG at its merge base, and the release sets them. Needs `changelog_fragments`, and the stamping `land --pr <n>` refuses under it. `train` keeps merge trains. Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `land` | `{ merged_checks }` or `null` | `null` | Under `merge: direct`, the shell commands (1 to 16, one line of at most 512 characters each, 15 minutes each) [`land --pin`](#land-direct) runs on the pin merged onto the base tip when the base moved past the pin's merge base; it lands only when every one exits 0. They are repo commands, so land runs them only with `--allow-repo-commands`, never under `--dry-run`. `null` lands a stale base untested with a `STALE_BASE: warn` line. Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `land` | `{ merged_checks, docs_only_skip? }` or `null` | `null` | Under `merge: direct`, the shell commands (1 to 16, one line of at most 512 characters each, 15 minutes each) [`land --pin`](#land-direct) runs on the pin merged onto the base tip when the base moved past the pin's merge base; it lands only when every one exits 0. They are repo commands, so land runs them only with `--allow-repo-commands`, never under `--dry-run`. `null` lands a stale base untested with a `STALE_BASE: warn` line. `docs_only_skip` lists `merged_checks` commands to skip for a docs-only pin ([pregate.md](pregate.md#docs-only)). Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `release_gate` | `{ cadence_hours, gate_workflow, branch, stable_ref, check_name, on_red, bisect_spend_cap_usd }` or `null` | `null` | One full paid gate on main every `cadence_hours` (8 to 168) instead of the per-push main backstop, feeding the stable release; see [release_gate](#release-gate). Every field is required: `on_red` is `hold-release`, `bisect_spend_cap_usd` 0 to 500 (0 skips the bisect). `null` keeps the per-push backstop. Needs `min_gstack_version`. |
|
||||
| `bookkeeping` | glob list | `[]` | <a id="bookkeeping"></a>Root-anchored globs of bookkeeping tables a verify or ratchet step rewrites (size baselines, serial lists, limits), each ending in `.tsv`, `.csv` or `.txt` and never under `.gstack/` or `.github/` (`["scripts/*-baseline.tsv"]`). A matching file changed at most 20 lines after review binds, first touched or not, takes finish's [one scoped reader](review-policy.md#bounded-post-review-edits) instead of Steps 5–11.5; the reader still reads every changed row. Read from the base revision only. Needs `min_gstack_version`. |
|
||||
| `mechanical_retake` | `[{ path, template, fields, regen }]` | `[]` | A version or rules constant a branch may have to retake after a base merge, next to `bookkeeping`'s bounded kind: `path` is one literal file, `template` the whole line with one `{n}` (`const RULE_NUMBERS := {n}`), `fields` the `{ glob, field }` fixture fields that carry the same value, and `regen` the [`.gstack/generated.json`](pregate.md#generated) entry ids whose command regenerates its goldens. A post-review edit finish proves to be only that retake routes as `mechanical=`, with no reader and no review round; see [mechanical retakes](#mechanical-retake). At most 8 constants and 16 fields or ids each; no glob, `..`, `.gstack/` or `.github/` path. Read from the base revision only. Needs `min_gstack_version`. |
|
||||
|
||||
@@ -7434,3 +7434,11 @@ These codes come from `bin/gstack-plain-title`, which /ship, gstack-post and the
|
||||
### `gstack-plain-title: .gstack/plain-titles.json is not valid JSON or breaks the schema … (PLAIN_POLICY_INVALID)`
|
||||
|
||||
**Meaning.** The policy file does not parse, has an unknown key, a `codes` entry that is not a valid regex or matches empty text, an `allow` entry without a reason, or a `since` that is not a date. Exit 3; nothing was checked. **Fix.** Fix the key the line names, then run `gstack-plain-title validate`.
|
||||
|
||||
<a id="docs-only-error"></a>
|
||||
### `DOCS_ONLY: error (DOCS_ONLY_ERROR: git … ; fix: …)`
|
||||
|
||||
**Meaning.** `gstack-docs-only` could not read the change. Usually a ref is missing: CI checked out one
|
||||
commit, or `origin/<base>` was never fetched. It exits 2, writes `docs_only=false`, and every caller runs
|
||||
the full suites. **Fix.** Fetch both commits (`actions/checkout` with `fetch-depth: 2` on the merge ref, or
|
||||
`git fetch origin <base>`), then rerun. See [pregate.md](pregate.md#docs-only).
|
||||
+8
-3
@@ -56,7 +56,7 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
|
||||
if (!listed.length) return no('no_run', `no ${profile.workflow} run (${profile.events.join(', ')}) on ${head.slice(0, 12)} yet`);
|
||||
const refusals: string[] = [];
|
||||
for (const run of listed) {
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged });
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged, docsOnly: src.docsOnly });
|
||||
if (v.ok) return v;
|
||||
refusals.push(`run ${run.id}: ${v.reason}${v.fix ? ` (the pull request's base moved after the run; fix: ${v.fix})` : ''}`);
|
||||
}
|
||||
@@ -92,9 +92,10 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
|
||||
}
|
||||
const other = runs.find(e => e.tree !== o.tree);
|
||||
if (other) return no('tree_mismatch', `run ${other.run_id} tested tree ${other.tree.slice(0, 12)}, not ${o.tree.slice(0, 12)}`);
|
||||
const empty = jobs.length ? undefined : runs.find(e => !e.files.length || e.tests === 0);
|
||||
const docsOnly = runs.every(e => e.docs_only);
|
||||
const empty = jobs.length || docsOnly ? undefined : runs.find(e => !e.files.length || e.tests === 0);
|
||||
if (empty) return no('no_tests', `run ${empty.run_id}'s tree.json ${empty.files.length ? 'records 0 tests' : `lists no test files (a rerun of only the failed jobs records none): dispatch a fresh full run of ${src.profiles[empty.profile]?.workflow ?? empty.profile} (gh workflow run ${src.profiles[empty.profile]?.workflow ?? '<workflow>'} --ref <branch>) and carry from that`}`);
|
||||
if (o.inventory) {
|
||||
if (o.inventory && !docsOnly) {
|
||||
const listed = o.inventory();
|
||||
if ('error' in listed) return no('inventory_failed', listed.error);
|
||||
if (!listed.length) return no('inventory_empty', 'the inventory command printed no files');
|
||||
@@ -123,6 +124,10 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
|
||||
}
|
||||
const after = o.wtree();
|
||||
if (after !== o.tree) return no('tree_changed', `the working tree changed while the runs were verified (now ${after ? after.slice(0, 12) : 'unreadable'})`);
|
||||
if (runs.every(e => e.docs_only)) {
|
||||
const gates = [...new Set(o.profiles.map(n => src.profiles[n]?.skippable?.gate).filter(Boolean))];
|
||||
return { ok: true, runs, tests: 'n/a', notOwed: `docs-only change: CI skipped the suites at this tree (gate job ${gates.join(', ')}; docs/pregate.md#docs-only)`, receipt: `ci-carry tree=${o.tree.slice(0, 12)} not_owed=docs-only ${runReceiptFields(runs, src.now)}` };
|
||||
}
|
||||
if (jobs.length) {
|
||||
const gates = [...new Set(o.profiles.map(n => src.profiles[n]?.skippable?.gate).filter(Boolean))];
|
||||
const allSkipped = runs.every(e => jobs.every(j => e.skipped_jobs?.includes(j)));
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* doc-links — relative links and #anchors in Markdown resolve (the docs lane's link check, R81; docs/pregate.md#docs-only).
|
||||
* Offline: links with a scheme (https:, mailto:) or a protocol-relative // are not checked. Links inside fenced code
|
||||
* blocks and inline code are ignored. An anchor into a Markdown file must match a heading slug (GitHub style, with -1,
|
||||
* -2 suffixes for repeats) or an explicit <a id="..."> / <a name="..."> in that file.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface BrokenLink { file: string; line: number; target: string; reason: string }
|
||||
|
||||
export function githubSlug(heading: string): string {
|
||||
return heading.trim().toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
|
||||
}
|
||||
|
||||
function unfenced(text: string): string[] {
|
||||
let fence: string | null = null;
|
||||
return text.split('\n').map(line => {
|
||||
const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
||||
if (m) { if (!fence) { fence = m[1]![0]!; return ''; } if (m[1]![0] === fence) { fence = null; return ''; } }
|
||||
return fence ? '' : line;
|
||||
});
|
||||
}
|
||||
|
||||
export function anchorsOf(text: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const seen = new Map<string, number>();
|
||||
for (const line of unfenced(text)) {
|
||||
const h = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
|
||||
if (h) {
|
||||
const base = githubSlug(h[1]!);
|
||||
const n = seen.get(base) ?? 0;
|
||||
seen.set(base, n + 1);
|
||||
out.add(n ? `${base}-${n}` : base);
|
||||
}
|
||||
for (const a of line.matchAll(/<a\s+(?:id|name)=["']([^"']+)["']/gi)) out.add(a[1]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Broken relative links in `files` (repo-relative Markdown paths) under `root`; with `added`, only on those 1-based lines of each file. */
|
||||
export function brokenLinks(root: string, files: readonly string[], added?: ReadonlyMap<string, ReadonlySet<number>>): BrokenLink[] {
|
||||
const broken: BrokenLink[] = [];
|
||||
const cache = new Map<string, Set<string>>();
|
||||
const anchorsAt = (abs: string) => { if (!cache.has(abs)) cache.set(abs, anchorsOf(fs.readFileSync(abs, 'utf8'))); return cache.get(abs)!; };
|
||||
for (const file of files) {
|
||||
const abs = path.join(root, file);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
unfenced(fs.readFileSync(abs, 'utf8')).forEach((raw, i) => {
|
||||
if (added && !added.get(file)?.has(i + 1)) return;
|
||||
const line = raw.replace(/(`+)[^`]*?\1/g, '');
|
||||
for (const m of line.matchAll(/!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+["'(][^)]*)?\)/g)) {
|
||||
const target = m[1]!;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) continue;
|
||||
const [withLine, anchor] = target.split('#', 2) as [string, string | undefined];
|
||||
const rel = /\.md$/i.test(withLine) ? withLine : withLine.replace(/:\d+(?:-\d+)?$/, '');
|
||||
let dest: string;
|
||||
try { dest = rel ? (rel.startsWith('/') ? path.join(root, decodeURIComponent(rel)) : path.resolve(path.dirname(abs), decodeURIComponent(rel))) : abs; }
|
||||
catch { broken.push({ file, line: i + 1, target, reason: 'undecodable path' }); continue; }
|
||||
if (path.relative(root, dest).startsWith('..')) { broken.push({ file, line: i + 1, target, reason: 'points outside the repository' }); continue; }
|
||||
if (!fs.existsSync(dest)) { broken.push({ file, line: i + 1, target, reason: 'no such file' }); continue; }
|
||||
if (!anchor || !/\.md$/i.test(dest) || !fs.statSync(dest).isFile()) continue;
|
||||
let wanted: string;
|
||||
try { wanted = decodeURIComponent(anchor).toLowerCase(); }
|
||||
catch { broken.push({ file, line: i + 1, target, reason: 'undecodable anchor' }); continue; }
|
||||
if (!anchorsAt(dest).has(wanted) && !anchorsAt(dest).has(anchor)) broken.push({ file, line: i + 1, target, reason: `no heading or <a id> for #${anchor}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
return broken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1-based lines each file added since `base` (diffed against the working tree). A file absent at `base`, tracked or
|
||||
* not, is all added. Each file is diffed on its own, so no path is parsed back out of patch headers (quoted, prefixed
|
||||
* or tab-suffixed names). Throws when `base` does not resolve or git fails.
|
||||
*/
|
||||
export function addedLines(root: string, base: string, files: readonly string[]): Map<string, Set<number>> {
|
||||
const added = new Map<string, Set<number>>();
|
||||
const fail = (what: string, r: ReturnType<typeof spawnSync>) => new Error(`${what}: ${(String(r.stderr ?? '') || r.error?.message || '').trim().split('\n')[0]}`);
|
||||
if (!files.length) return added;
|
||||
const ref = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
if (ref.status !== 0) throw fail(`git diff ${base}: unknown revision`, ref);
|
||||
for (const f of files) {
|
||||
const lines = new Set<number>();
|
||||
added.set(f, lines);
|
||||
if (spawnSync('git', ['cat-file', '-e', `${base}:${f}`], { cwd: root, timeout: 30_000 }).status !== 0) {
|
||||
const abs = path.join(root, f);
|
||||
if (fs.existsSync(abs)) fs.readFileSync(abs, 'utf8').split('\n').forEach((_, i) => lines.add(i + 1));
|
||||
continue;
|
||||
}
|
||||
const d = spawnSync('git', ['diff', '-U0', '--no-ext-diff', '--no-color', base, '--', f], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (d.status !== 0) throw fail(`git diff ${base}`, d);
|
||||
for (const line of d.stdout.split('\n')) {
|
||||
const h = /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/.exec(line);
|
||||
if (h) for (let n = 0, start = Number(h[1]); n < Number(h[2] ?? 1); n++) lines.add(start + n);
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* docs-only — the one classifier for a change whose every path is human-facing documentation (R81; docs/pregate.md#docs-only).
|
||||
*
|
||||
* A docs-only pull request runs the docs lane (link check, generated-page freshness, the credential scan and the tests
|
||||
* registered below that read docs by directory) instead of the test suites. Pregate, gstack-ci-carry, land and the CI
|
||||
* workflows all call classifyDocsOnly through bin/gstack-docs-only; nothing else decides the class.
|
||||
*
|
||||
* In gstack most Markdown is agent instructions, so the rule is an allowlist with overrides, never a "*.md" glob. A path
|
||||
* is docs only when all of these hold: its mode is a plain file (100644; executables, symlinks, submodules and type
|
||||
* changes refuse); it is on the allowlist (root README.md, docs/**.md, changelog.d/*.md, case-sensitive); review-depth's
|
||||
* classifyPath calls it docs (agent prose, skill directories, policy and rule docs are code there); it matches no
|
||||
* denylist entry (agent-prose names case-insensitively, pinned results, preregistrations, generated outputs); and no
|
||||
* tracked file outside human prose names its path (a script, test, workflow or skill that reads it pins it). A rename
|
||||
* needs both sides to pass and a deletion needs the old path to pass. Unsure, or any git failure, is never docs-only.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { matchesAny } from './ship-policy';
|
||||
|
||||
export const DOCS_ONLY_ERROR = 'DOCS_ONLY_ERROR';
|
||||
export const DOCS_ONLY_ANCHOR = 'docs/troubleshooting.md#docs-only-error';
|
||||
|
||||
const ALLOW = [/^README\.md$/, /^docs\/(?:[^/]+\/)*[^/]+\.md$/, /^changelog\.d\/[^/]+\.md$/];
|
||||
const DENY: readonly [RegExp, string][] = [
|
||||
[/(?:^|\/)(?:skill|agents|claude|gemini|ethos|crew|captain|factory|driver|checklist|unattended|pregate|lane-card|notes|digest|soul)\.md$/i, 'agent instructions'],
|
||||
[/-policy\.md$/i, 'policy doc'],
|
||||
[/(?:^|\/)preferences[^/]*$/i, 'preferences'],
|
||||
[/(?:^|\/)(?:sections|templates|specialists|skills)\//i, 'agent instructions'],
|
||||
[/\.tmpl$/i, 'template'],
|
||||
[/^docs\/(?:evals|inventory)\//, 'pinned results'],
|
||||
[/prereg/i, 'preregistration'],
|
||||
[/^changelog\.d\/README\.md$/i, 'fragment instructions'],
|
||||
];
|
||||
const PROSE_REFERENCE = /^(?:CHANGELOG|TODOS)\.md$/;
|
||||
|
||||
export interface DiffEntry { status: string; path: string; from?: string; mode: string; oldMode: string }
|
||||
export interface Refusal { path: string; reason: string }
|
||||
export interface DocsOnlyResult { docsOnly: boolean; files: string[]; refusals: Refusal[]; tests: string[] }
|
||||
|
||||
/**
|
||||
* Tests that read docs by directory or every tracked file, so a literal-path reference scan cannot see them. The docs
|
||||
* lane runs the ones whose tree the change touches. test/docs-only.test.ts fails on a new repo-wide or docs-tree reader
|
||||
* that is in neither list.
|
||||
*/
|
||||
export const DOCS_READER_TESTS: readonly { tree: RegExp; tests: readonly string[] }[] = [
|
||||
{ tree: /^docs\//, tests: ['test/docs-config-keys.test.ts'] },
|
||||
{ tree: /^(?:docs\/|README\.md$)/, tests: ['test/no-stale-gstack-brain-refs.test.ts'] },
|
||||
{ tree: /./, tests: ['test/bun-entry-isolation.test.ts', 'test/gitattributes-eol.test.ts', 'test/ci-paid-coordination.test.ts', 'test/review-packets-removed.test.ts', 'test/state-root-ratchet.test.ts', 'test/codex-web-search-flag.test.ts'] },
|
||||
];
|
||||
/** Repo-wide listers that never read an allowlisted doc, with the reason. */
|
||||
export const DOCS_READER_EXEMPT: Readonly<Record<string, string>> = {
|
||||
'test/bash32-case-in-substitution.test.ts': 'skips .md files',
|
||||
'test/branch-slug-hygiene.test.ts': 'lists SKILL.md and sections only',
|
||||
'test/cso-cli.test.ts': 'lists a fixture repository',
|
||||
'test/free-fixtures.test.ts': 'reads test/fixtures only',
|
||||
'test/helpers/docsync-repo.ts': 'lists a fixture repository',
|
||||
};
|
||||
|
||||
export class DocsOnlyError extends Error {
|
||||
constructor(readonly command: string, readonly detail: string, readonly fix: string) { super(`${command}: ${detail}`); }
|
||||
}
|
||||
|
||||
function git(root: string, args: string[], okCodes = [0]): string {
|
||||
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
if (r.error || r.status === null || !okCodes.includes(r.status)) {
|
||||
const detail = (r.error?.message ?? r.stderr ?? '').trim().split('\n')[0] || `exit ${r.status}`;
|
||||
throw new DocsOnlyError(`git ${args.join(' ')}`, detail, /bad revision|unknown revision|no merge base|ambiguous argument/i.test(detail)
|
||||
? 'fetch both commits (actions/checkout fetch-depth: 2 on the merge ref, or git fetch origin <base>)' : 'rerun in a git checkout of this repository');
|
||||
}
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
/** Parse `git diff --raw -z -M` output. */
|
||||
export function parseRaw(raw: string): DiffEntry[] {
|
||||
const parts = raw.split('\0');
|
||||
const out: DiffEntry[] = [];
|
||||
for (let i = 0; i < parts.length - 1;) {
|
||||
const head = parts[i++]!;
|
||||
if (!head.startsWith(':')) continue;
|
||||
const [oldMode, mode, , , status] = head.slice(1).split(' ');
|
||||
const s = status ?? '';
|
||||
if (/^[RC]/.test(s)) out.push({ status: s[0]!, from: parts[i++]!, path: parts[i++]!, mode: mode!, oldMode: oldMode! });
|
||||
else out.push({ status: s[0] ?? '?', path: parts[i++]!, mode: mode!, oldMode: oldMode! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function untracked(root: string): DiffEntry[] {
|
||||
return git(root, ['ls-files', '-z', '--others', '--exclude-standard']).split('\0').filter(Boolean).map(p => {
|
||||
const st = fs.lstatSync(path.join(root, p));
|
||||
const mode = st.isSymbolicLink() ? '120000' : st.mode & 0o111 ? '100755' : '100644';
|
||||
return { status: 'A', path: p, mode, oldMode: '000000' };
|
||||
});
|
||||
}
|
||||
|
||||
/** The changed paths: base..head between commits, or the merge base of base and HEAD to the working tree (tracked and untracked) when head is omitted. */
|
||||
export function readDiff(root: string, base: string, head?: string): DiffEntry[] {
|
||||
if (head) return parseRaw(git(root, ['diff', '--raw', '-z', '-M', '--no-ext-diff', base, head, '--']));
|
||||
const mb = git(root, ['merge-base', base, 'HEAD']).trim();
|
||||
return [...parseRaw(git(root, ['diff', '--raw', '-z', '-M', '--no-ext-diff', mb, '--'])), ...untracked(root)];
|
||||
}
|
||||
|
||||
function generatedOutputs(root: string): string[] {
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(path.join(root, '.gstack', 'generated.json'), 'utf8'));
|
||||
return (j.entries ?? []).flatMap((e: { outputs?: string[] }) => e.outputs ?? []);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Why one path is not human docs, or null when its name and place qualify. */
|
||||
export function pathRefusal(p: string, root: string, generated: readonly string[] = generatedOutputs(root)): string | null {
|
||||
if (!ALLOW.some(r => r.test(p))) return 'not on the docs allowlist (README.md, docs/**.md, changelog.d/*.md)';
|
||||
for (const [r, why] of DENY) if (r.test(p)) return why;
|
||||
if (classifyPath(p, root) !== 'docs') return 'agent-read prose (review-depth classifyPath)';
|
||||
if (generated.length && matchesAny(generated, p)) return 'generated output';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Tracked files outside human prose that name `p` (the caller excludes changed docs). */
|
||||
export function referencesTo(root: string, p: string, tree?: string): string[] {
|
||||
const out = git(root, ['grep', '-l', '-z', '-F', '-I', '-e', p, ...(tree ? [tree] : []), '--'], [0, 1]);
|
||||
const prefix = tree ? `${tree}:` : '';
|
||||
const gen = generatedOutputs(root);
|
||||
return out.split('\0').filter(Boolean).map(f => f.startsWith(prefix) ? f.slice(prefix.length) : f)
|
||||
.filter(f => f !== p && !PROSE_REFERENCE.test(f) && pathRefusal(f, root, gen) !== null);
|
||||
}
|
||||
|
||||
/** Classify a parsed diff. `refs` lists the non-docs files naming a path. */
|
||||
export function classifyEntries(entries: readonly DiffEntry[], root: string, refs: (p: string) => string[]): DocsOnlyResult {
|
||||
const refusals: Refusal[] = [];
|
||||
const files: string[] = [];
|
||||
const gen = generatedOutputs(root);
|
||||
const check = (p: string) => {
|
||||
const why = pathRefusal(p, root, gen);
|
||||
if (why) return why;
|
||||
const by = refs(p);
|
||||
return by.length ? `named by ${by.slice(0, 3).join(', ')}${by.length > 3 ? ', …' : ''}` : null;
|
||||
};
|
||||
for (const e of entries) {
|
||||
files.push(e.path);
|
||||
const mode = e.status === 'D' ? e.oldMode : e.mode;
|
||||
let why: string | null = null;
|
||||
if (!['A', 'M', 'D', 'R', 'C'].includes(e.status)) why = `change type ${e.status}`;
|
||||
else if (mode !== '100644' || (e.status !== 'A' && e.status !== 'D' && e.oldMode !== '100644' && e.oldMode !== '000000')) why = `mode ${e.oldMode}->${e.mode}`;
|
||||
else if (e.from) why = check(e.from) ?? check(e.path);
|
||||
else why = check(e.path);
|
||||
if (why) refusals.push({ path: e.path, reason: why });
|
||||
}
|
||||
if (!entries.length) refusals.push({ path: '-', reason: 'no changed paths' });
|
||||
const docsOnly = refusals.length === 0;
|
||||
const tests = docsOnly ? [...new Set(DOCS_READER_TESTS.filter(t => files.some(f => t.tree.test(f))).flatMap(t => t.tests))] : [];
|
||||
return { docsOnly, files, refusals, tests };
|
||||
}
|
||||
|
||||
/** The whole decision for base..head (commits) or base to the working tree. Throws DocsOnlyError on any git failure. */
|
||||
export function classifyDocsOnly(o: { root: string; base: string; head?: string }): DocsOnlyResult {
|
||||
const entries = readDiff(o.root, o.base, o.head);
|
||||
const changed = new Set(entries.flatMap(e => [e.path, ...(e.from ? [e.from] : [])]));
|
||||
return classifyEntries(entries, o.root, p => referencesTo(o.root, p, o.head).filter(f => !changed.has(f) || pathRefusal(f, o.root) !== null));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* land.docs_only_skip (R81; docs/pregate.md#docs-only). Its own module: lib/land-merged-checks.ts is imported by
|
||||
* lib/ship-policy.ts, which lib/docs-only.ts imports, so the classifier cannot be imported there.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { classifyDocsOnly, readDiff, referencesTo } from './docs-only';
|
||||
|
||||
/**
|
||||
* The merged checks for `pin`: land.merged_checks without the commands land.docs_only_skip names when lib/docs-only.ts
|
||||
* calls the pin's change since its merge base with `base` human docs only (R81; docs/pregate.md#docs-only), and no
|
||||
* non-docs file at `base` names a changed path: land merges onto `base`, so a reader the base gained after the pin
|
||||
* branched keeps the checks. Any git or classifier failure keeps every check.
|
||||
*/
|
||||
export function mergedChecksFor(root: string, land: { merged_checks?: string[]; docs_only_skip?: string[] } | null | undefined, base: string, pin: string): string[] {
|
||||
const checks = land?.merged_checks ?? [];
|
||||
const skip = land?.docs_only_skip ?? [];
|
||||
if (!skip.length) return checks;
|
||||
const mb = spawnSync('git', ['merge-base', base, pin], { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
try {
|
||||
if (mb.status !== 0 || !classifyDocsOnly({ root, base: mb.stdout.trim(), head: pin }).docsOnly) return checks;
|
||||
const changed = readDiff(root, mb.stdout.trim(), pin).flatMap(e => [e.path, ...(e.from ? [e.from] : [])]);
|
||||
if (changed.some(p => referencesTo(root, p, base).length)) return checks;
|
||||
} catch { return checks; }
|
||||
return checks.filter(c => !skip.includes(c));
|
||||
}
|
||||
@@ -45,9 +45,11 @@ export const LAND_EXPLANATION = '{ merged_checks } or null, read from the base r
|
||||
export function landPolicyErrors(raw: unknown): PolicyError[] {
|
||||
const l = raw as Record<string, unknown> | null;
|
||||
if (!l || typeof l !== 'object' || Array.isArray(l)) return [{ code: 'POLICY_INVALID', path: 'land', message: 'must be { merged_checks } or null' }];
|
||||
const out: PolicyError[] = Object.keys(l).filter(k => k !== 'merged_checks').map(k => ({ code: 'POLICY_INVALID', path: `land.${k}`, message: 'unknown key' }));
|
||||
const out: PolicyError[] = Object.keys(l).filter(k => k !== 'merged_checks' && k !== 'docs_only_skip').map(k => ({ code: 'POLICY_INVALID', path: `land.${k}`, message: 'unknown key' }));
|
||||
const c = l.merged_checks;
|
||||
if (!Array.isArray(c) || !c.length || c.length > MAX_CHECKS) return [...out, { code: 'POLICY_INVALID', path: 'land.merged_checks', message: `must list 1 to ${MAX_CHECKS} commands` }];
|
||||
const skip = l.docs_only_skip;
|
||||
if (skip !== undefined && (!Array.isArray(skip) || skip.some(s => !c.includes(s)) || skip.length >= c.length)) out.push({ code: 'POLICY_INVALID', path: 'land.docs_only_skip', message: 'must list merged_checks commands, leaving at least one to run' });
|
||||
c.forEach((cmd, i) => {
|
||||
if (typeof cmd !== 'string' || !cmd.trim()) out.push({ code: 'POLICY_INVALID', path: `land.merged_checks[${i}]`, message: 'must be a non-empty command' });
|
||||
else if (cmd.length > MAX_COMMAND || /[\0\r\n]/.test(cmd)) out.push({ code: 'POLICY_CONTAINMENT', path: `land.merged_checks[${i}]`, message: `must be one line of at most ${MAX_COMMAND} characters` });
|
||||
|
||||
@@ -36,6 +36,7 @@ import { classifyCommandEnvironment, classifyEnvironment, environmentLine, envir
|
||||
import { checkScratch } from '../run-scratch';
|
||||
import { starterPolicy } from '../starter';
|
||||
import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph';
|
||||
import { docsOnlyLane } from '../docs-only-lane';
|
||||
import { selectLanes, type Selection } from '../lane-selection';
|
||||
import { launchRuntime } from '../runtime';
|
||||
import { cargoModel, type CargoModel } from '../rust';
|
||||
@@ -625,6 +626,8 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
|
||||
const declared = ctx.configKind !== 'none' && ctx.policyDeclaresLanes !== false;
|
||||
const mode: NoLaneMode = !declared ? 'no-policy' : ctx.config.warn.includes('lanes') ? 'warned' : 'strict';
|
||||
const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(ctx, sel, mode), remote: sel.remote };
|
||||
const docs = docsOnlyLane(ctx, base);
|
||||
if (docs) return docs;
|
||||
const lost = new Set(sel.removed.flatMap(r => r.lostCoverage)).size;
|
||||
const removedNote = sel.removed.length ? `${sel.removed.length} removed path(s)${lost ? `; ${lost} subject(s) lost their last test` : ''}` : '';
|
||||
const withRemoved = (r: CheckResult): CheckResult => (removedNote ? { ...r, detail: r.detail ? `${r.detail}; ${removedNote}` : removedNote } : r);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* pregate's docs-only route (R81; docs/pregate.md#docs-only). When lib/docs-only.ts classifies the change from the
|
||||
* merge base with origin/<base> to the working tree as human docs only, the lanes check runs the docs lane here
|
||||
* instead of the test lanes: links on added lines must resolve, and the tests that read docs by directory
|
||||
* (DOCS_READER_TESTS, those present in this repository) run locally. It owes no remote gate, so no CI carry is needed;
|
||||
* pregate's regen (generated-page freshness) and secrets (credential scan) checks run as always.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../bun-isolation';
|
||||
import { addedLines, brokenLinks } from '../doc-links';
|
||||
import { classifyDocsOnly } from '../docs-only';
|
||||
import type { CheckResult, PregateContext } from './types';
|
||||
|
||||
/** The lanes result for a docs-only change, or null when the change is not docs-only (or cannot be classified). */
|
||||
export function docsOnlyLane(ctx: PregateContext, base: CheckResult): CheckResult | null {
|
||||
const ref = `origin/${ctx.base}`;
|
||||
let r;
|
||||
try { r = classifyDocsOnly({ root: ctx.repoRoot, base: ref }); } catch { return null; }
|
||||
if (!r.docsOnly) return null;
|
||||
ctx.explain.push(`docs-only: ${r.files.length} file(s) (lib/docs-only.ts); the test lanes are not owed`);
|
||||
const md = r.files.filter(f => /\.md$/i.test(f) && fs.existsSync(path.join(ctx.repoRoot, f)));
|
||||
let broken;
|
||||
try {
|
||||
const mb = spawnSync('git', ['merge-base', ref, 'HEAD'], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 30_000 }).stdout.trim();
|
||||
broken = brokenLinks(ctx.repoRoot, md, addedLines(ctx.repoRoot, mb || ref, md));
|
||||
} catch (e) {
|
||||
return { ...base, status: 'incomplete', code: 'PREGATE_INCOMPLETE', detail: `docs-only: the link check could not read the diff: ${(e as Error).message}`, fix: `git fetch origin ${ctx.base}, then rerun gstack-pregate` };
|
||||
}
|
||||
const lines = [...(base.lines ?? []), `docs-only: ${r.files.join(' ')}`, ...broken.map(b => `${b.file}:${b.line}: ${b.target}: ${b.reason}`)];
|
||||
if (broken.length) return { ...base, lines, status: 'fail', cause: 'diff', code: 'PREGATE_LANE_FAILED', detail: `docs-only: ${broken.length} broken link(s) on added lines`, fix: 'correct each link or add the heading it names, then rerun gstack-pregate' };
|
||||
const tests = r.tests.filter(t => fs.existsSync(path.join(ctx.repoRoot, t)));
|
||||
const done = (detail: string): CheckResult => ({ ...base, lines, status: 'pass', remote: [], detail });
|
||||
if (!tests.length) return done(`docs-only: ${r.files.length} file(s); links on added lines resolve; no docs reader tests; the test suites are not owed`);
|
||||
if (!ctx.allowRepoCommands) return { ...base, lines, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `docs-only: ${tests.length} docs reader test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' };
|
||||
const run = spawnSync(process.execPath, [...bunIsolationArgs(), 'test', '--timeout=30000', ...tests], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 600_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const tail = `${run.stdout ?? ''}${run.stderr ?? ''}`.trim().split('\n').slice(-3).join(' | ');
|
||||
if (run.status !== 0) return { ...base, lines: [...lines, tail], status: 'fail', cause: 'diff', code: 'PREGATE_LANE_FAILED', detail: `docs-only: docs reader tests failed (${run.error ? run.error.message : `exit ${run.status}`})`, fix: `bun test ${tests.join(' ')}` };
|
||||
return done(`docs-only: ${r.files.length} file(s); links on added lines resolve; ${tests.length} docs reader test file(s) passed; the test suites are not owed`);
|
||||
}
|
||||
@@ -273,7 +273,7 @@ function userPolicyChange(file: PregateFile): string | null {
|
||||
|
||||
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck; flaky?: FlakyInput }
|
||||
/** What a run clear needs: the base branch's run profiles, the verifier's dependencies, the clock, and the remote gate's inventory. */
|
||||
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; unchanged?: (p: RunProfile) => string | null; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string }; failures?: FailureDeps }
|
||||
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; unchanged?: (p: RunProfile) => string | null; docsOnly?: (head: string) => boolean; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string }; failures?: FailureDeps }
|
||||
export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution; exception?: PregateException } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ghArtifactDeps, ghRunDeps, runInventory, type RunProfile } from '../run-evidence';
|
||||
import { classifyDocsOnly } from '../docs-only';
|
||||
import { wtreeFingerprint } from '../script-invocation';
|
||||
import { resolveBaseBranch } from '../ship-policy';
|
||||
import { normalizeRemoteUrl } from '../../crew/lib/ship-queue-train-record';
|
||||
@@ -71,6 +72,10 @@ export function buildRunCheck(root: string, lane: string | null, allowRepoComman
|
||||
return {
|
||||
repo, deps: ghRunDeps(repo, root), failures: ghArtifactDeps(repo), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
|
||||
unchanged: p => gateInputsChanged(root, base, p),
|
||||
docsOnly: head => {
|
||||
const mb = spawnSync('git', ['merge-base', `origin/${base}`, head], { cwd: root, encoding: 'utf8', timeout: 30_000 });
|
||||
try { return mb.status === 0 && classifyDocsOnly({ root, base: mb.stdout.trim(), head }).docsOnly; } catch { return false; }
|
||||
},
|
||||
inventory: (command, tree, selected) => {
|
||||
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
|
||||
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
|
||||
+11
-3
@@ -77,7 +77,8 @@ export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | '
|
||||
| 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_skipped' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch'
|
||||
| 'artifact_reused' | 'sizing_experiment' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error';
|
||||
/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */
|
||||
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[]; skipped_jobs?: string[] }
|
||||
/** `docs_only`: a pull_request run whose gate skipped every skippable job on a change lib/docs-only.ts calls human docs only; it ran and recorded no tests (R81). */
|
||||
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[]; skipped_jobs?: string[]; docs_only?: true }
|
||||
/** `fix`: the one command that clears a refusal the caller can repair, such as a pull_request run that tested a merge with an older base. */
|
||||
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string; fix?: string };
|
||||
|
||||
@@ -156,7 +157,7 @@ export const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
|
||||
* The verdict for run `runRef` under `profile`. `vetoHeads` are further heads
|
||||
* whose newer runs of the same workflow can veto (the consumer's own head).
|
||||
*/
|
||||
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; ignoreRun?: number; now: Date; unchanged?: (p: RunProfile) => string | null }): RunVerdict {
|
||||
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; ignoreRun?: number; now: Date; unchanged?: (p: RunProfile) => string | null; docsOnly?: (head: string) => boolean }): RunVerdict {
|
||||
const id = parseRunRef(o.runRef, o.repo);
|
||||
if (id === null) return fail('run_ref_invalid', `${JSON.stringify(o.runRef)} is not a run id or a run URL on ${o.repo}`);
|
||||
const p = o.profile;
|
||||
@@ -200,6 +201,13 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
const newer = deps.runsForHead(p.workflow, head).filter(r => !(r.id === id && r.run_attempt === run.run_attempt) && r.id !== o.ignoreRun && started(r) >= (run.created_at ?? '') && !(r.status === 'completed' && r.conclusion === 'success'));
|
||||
if (newer.length) return fail('newer_run_vetoes', `newer run ${newer[0]!.id} of ${p.workflow} on ${head.slice(0, 12)} is ${newer[0]!.status === 'completed' ? newer[0]!.conclusion : newer[0]!.status}`);
|
||||
}
|
||||
if (p.skippable && p.skippable.jobs.every(j => skippedJobs.includes(j)) && o.docsOnly?.(run.head_sha)) {
|
||||
const tree = deps.commitTree(run.head_sha);
|
||||
if (!tree) return fail('api_error', `git cannot read the tree of ${run.head_sha}`);
|
||||
const changed = runChanged(deps, id, run.run_attempt);
|
||||
if (changed) return fail('run_changed', changed);
|
||||
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files: [], tests: null, skipped_jobs: skippedJobs, docs_only: true } };
|
||||
}
|
||||
let rec: TreeRecord;
|
||||
try { rec = deps.artifact(id, p.artifact); }
|
||||
catch (e: any) { return fail('artifact_missing', `${at} has no readable ${p.artifact} tree.json: ${String(e?.message ?? e).slice(0, 200)}`); }
|
||||
@@ -268,7 +276,7 @@ export function uncoveredFiles(inventory: string[], evidence: RunEvidence[], ski
|
||||
|
||||
/** What a run check reads: the repository, the verifier's dependencies, the base branch's profiles and the clock. */
|
||||
/** `unchanged` (findCarry only): null when a profile's workflow and gate inputs match the base, else which file differs. */
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date; unchanged?: (p: RunProfile) => string | null }
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date; unchanged?: (p: RunProfile) => string | null; docsOnly?: (head: string) => boolean }
|
||||
export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string };
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* rerun after GitHub merged goes straight to the tree check.
|
||||
*/
|
||||
import { MIN_POLL_MS, pollDelayMs } from './gh-poll';
|
||||
import { mergedChecksFor } from './land-docs-only';
|
||||
import { runMergedChecks } from './land-merged-checks';
|
||||
import { landPlan } from './plan-record';
|
||||
import { classifyRelease, RELEASE_BRANCH } from './release-stamp';
|
||||
@@ -136,7 +137,7 @@ export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput {
|
||||
if (info.state === 'MERGED' && info.headRefOid === o.pin && isSha(info.mergeCommit?.oid)) {
|
||||
lines.push(`#${o.pr} is already merged as ${info.mergeCommit!.oid.slice(0, 12)}; checking the tree`);
|
||||
if (o.dryRun) return { ok: true, run, lines: [...lines, 'DRY-RUN: nothing verified (a rerun without --dry-run runs the tree check and writes the land note)'] };
|
||||
const out = verifyMerge(ctx, root, o.pr, o.pin, info.mergeCommit!.oid, lines, run, now(), RELEASE_BRANCH.test(info.headRefName) ? 'pin-parent' : undefined, { checks: policy.land?.merged_checks ?? [], allow: o.allowRepoCommands });
|
||||
const out = verifyMerge(ctx, root, o.pr, o.pin, info.mergeCommit!.oid, lines, run, now(), RELEASE_BRANCH.test(info.headRefName) ? 'pin-parent' : undefined, { checks: mergedChecksFor(root, policy.land, `origin/${ctx.base}`, o.pin), allow: o.allowRepoCommands });
|
||||
const left = readLock(root, remote);
|
||||
if (left.state !== 'held' || !ownsLock(left.lock, { pr: o.pr }, journal)) return out;
|
||||
const got = takeLock();
|
||||
@@ -208,7 +209,7 @@ export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput {
|
||||
const current = loadBasePolicy(ctx);
|
||||
if ('message' in current) return fail(lines, current);
|
||||
if (current.policy.merge !== 'direct') return fail(lines, { code: 'QUEUE_NOT_READY', message: `the base policy at ${tip.slice(0, 12)} no longer sets merge: direct`, fix: 'land through the train' });
|
||||
const checks = current.policy.land?.merged_checks ?? [];
|
||||
const checks = mergedChecksFor(root, current.policy.land, tip, o.pin);
|
||||
const staleRelease = releaseMoved(tip);
|
||||
if (staleRelease) return fail(lines, staleRelease);
|
||||
if (mergeBase !== tip) {
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* check-doc-links — the docs lane's link check (lib/doc-links.ts; docs/pregate.md#docs-only).
|
||||
* bun scripts/check-doc-links.ts [--base <ref>] <file.md>...
|
||||
* With --base, only links on lines added since <ref> (diffed against the working tree) are checked, so a docs edit is
|
||||
* not blocked by an older broken link it did not touch. Prints DOC_LINKS: ok (<n> file(s)) and exits 0, or one line per
|
||||
* broken link and DOC_LINKS: broken <n> and exits 1; a git failure prints DOC_LINKS: error and exits 2.
|
||||
*/
|
||||
import { addedLines, brokenLinks } from '../lib/doc-links';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const at = args.indexOf('--base');
|
||||
const base = at >= 0 ? args[at + 1] : undefined;
|
||||
const files = args.filter((a, i) => (at < 0 || (i !== at && i !== at + 1)) && /\.md$/i.test(a));
|
||||
let added: Map<string, Set<number>> | undefined;
|
||||
try { added = base ? addedLines(process.cwd(), base, files) : undefined; }
|
||||
catch (e) { console.log(`DOC_LINKS: error (${(e as Error).message})`); process.exit(2); }
|
||||
const broken = brokenLinks(process.cwd(), files, added);
|
||||
for (const b of broken) console.log(`${b.file}:${b.line}: ${b.target}: ${b.reason}; fix: correct the link or add the heading it names`);
|
||||
console.log(broken.length ? `DOC_LINKS: broken ${broken.length}` : `DOC_LINKS: ok (${files.length} file(s))`);
|
||||
process.exit(broken.length ? 1 : 0);
|
||||
@@ -234,7 +234,8 @@ merge-check code: `lib/ship-*`, `lib/pregate/*`, the ship and pregate bins,
|
||||
(`gstack-post pr-create --base <base> --draft --title-file <f> --body-file <f>`), then `gstack-ci-carry wait` in
|
||||
the background; once it prints `CI_WAIT: green`, rerun `--tests-only` and the mapped lane is carried. A red
|
||||
run is a failed lane: triage it below. Batch review fixes into one commit and one push. `CI_CARRY: none
|
||||
label=<l> (<reason>)` names why a mapped lane ran here. Any other lane that already passed at this
|
||||
label=<l> (<reason>)` names why a mapped lane ran here. A docs-only change ([docs/pregate.md#docs-only](../../docs/pregate.md#docs-only)) owes no test lane: pregate runs
|
||||
the docs lane, and a mapped lane prints `LANE: <label> not owed: docs-only change …` from CI's skipped run. Any other lane that already passed at this
|
||||
tree, command and runtime is reused (`reused=<time>`). In Step 16, `finish` defers a mapped
|
||||
lane it cannot carry (`EVIDENCE: DEFERRED`); after Step 17's push, `gstack-ship-release accept --base <base>
|
||||
--lane …` carries it from the green run at the final head and reruns the pregate there.
|
||||
|
||||
@@ -30,7 +30,8 @@ merge-check code: `lib/ship-*`, `lib/pregate/*`, the ship and pregate bins,
|
||||
(`gstack-post pr-create --base <base> --draft --title-file <f> --body-file <f>`), then `gstack-ci-carry wait` in
|
||||
the background; once it prints `CI_WAIT: green`, rerun `--tests-only` and the mapped lane is carried. A red
|
||||
run is a failed lane: triage it below. Batch review fixes into one commit and one push. `CI_CARRY: none
|
||||
label=<l> (<reason>)` names why a mapped lane ran here. Any other lane that already passed at this
|
||||
label=<l> (<reason>)` names why a mapped lane ran here. A docs-only change ([docs/pregate.md#docs-only](../../docs/pregate.md#docs-only)) owes no test lane: pregate runs
|
||||
the docs lane, and a mapped lane prints `LANE: <label> not owed: docs-only change …` from CI's skipped run. Any other lane that already passed at this
|
||||
tree, command and runtime is reused (`reused=<time>`). In Step 16, `finish` defers a mapped
|
||||
lane it cannot carry (`EVIDENCE: DEFERRED`); after Step 17's push, `gstack-ship-release accept --base <base>
|
||||
--lane …` carries it from the green run at the final head and reruns the pregate there.
|
||||
|
||||
@@ -19,18 +19,18 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
const MAIN_PUSH = "github.event_name == 'push' && github.ref == 'refs/heads/main'";
|
||||
const FILES = {
|
||||
'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: ['trunk-dynamic-ci'], observer: 'trunk-dynamic-ci', manual: false, reuseIf: `(${MAIN_PUSH}) || github.event_name == 'pull_request'`, pullRequest: true },
|
||||
'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'], observer: null, manual: true, reuseIf: MAIN_PUSH, pullRequest: false },
|
||||
'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: ['trunk-dynamic-ci', 'docs-scope', 'docs-lane'], observer: 'trunk-dynamic-ci', manual: false, reuseIf: `(${MAIN_PUSH}) || github.event_name == 'pull_request'`, pullRequest: true, docsScope: true, skip: "steps.route.outputs.skip != 'true'" },
|
||||
'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'], observer: null, manual: true, reuseIf: MAIN_PUSH, pullRequest: false, docsScope: false, skip: "steps.route.outputs.reused != 'true'" },
|
||||
};
|
||||
const SKIP = "steps.route.outputs.reused != 'true'";
|
||||
const PROFILES = JSON.parse(read('.gstack/pregate.json')).run_profiles as Record<string, { workflow: string; jobs: string[]; artifact: string; events: string[]; os: string }>;
|
||||
|
||||
/** Evaluate a job condition the way the runner would for one reuse outcome (status functions true: nothing cancelled). */
|
||||
function runs(cond: string, s: { result: string; reused: string; plan?: string }): boolean {
|
||||
function runs(cond: string, s: { result: string; reused: string; plan?: string; docsScope?: string; docsOnly?: string }): boolean {
|
||||
const expr = cond.replace(/^\$\{\{\s*|\s*\}\}$/g, '')
|
||||
.replace(/!cancelled\(\)|always\(\)/g, 'true')
|
||||
.replace(/needs\.reuse\.result/g, JSON.stringify(s.result)).replace(/needs\.reuse\.outputs\.reused/g, JSON.stringify(s.reused))
|
||||
.replace(/needs\.(free|windows)-plan\.result/g, JSON.stringify(s.plan ?? 'success')).replace(/inputs\.record_durations/g, 'false');
|
||||
.replace(/needs\.(free|windows)-plan\.result/g, JSON.stringify(s.plan ?? 'success')).replace(/inputs\.record_durations/g, 'false')
|
||||
.replace(/needs\.docs-scope\.result/g, JSON.stringify(s.docsScope ?? 'skipped')).replace(/needs\.docs-scope\.outputs\.docs_only/g, JSON.stringify(s.docsOnly ?? ''));
|
||||
expect(expr).not.toMatch(/needs\.|inputs\.|github\.|steps\./);
|
||||
return new Function(`return (${expr});`)() as boolean;
|
||||
}
|
||||
@@ -72,6 +72,13 @@ for (const [file, spec] of Object.entries(FILES)) {
|
||||
expect([name, runs(cond, { result: 'success', reused: 'true' })]).toEqual([name, false]);
|
||||
}
|
||||
for (const name of suite.filter(n => needs(n).includes(spec.plan))) expect([name, runs(jobs[name].if, { result: 'skipped', reused: '', plan: 'failure' })]).toEqual([name, false]);
|
||||
const docsGated = suite.filter(n => needs(n).includes('docs-scope'));
|
||||
expect(docsGated.length > 0).toBe(spec.docsScope);
|
||||
for (const name of docsGated) {
|
||||
expect([name, runs(jobs[name].if, { result: 'skipped', reused: '', docsScope: 'success', docsOnly: 'true' })]).toEqual([name, false]);
|
||||
expect([name, runs(jobs[name].if, { result: 'skipped', reused: '', docsScope: 'failure', docsOnly: 'true' })]).toEqual([name, true]);
|
||||
expect([name, runs(jobs[name].if, { result: 'skipped', reused: '', docsScope: 'success', docsOnly: 'false' })]).toEqual([name, true]);
|
||||
}
|
||||
expect(needs(spec.aggregate)).toContain('reuse');
|
||||
expect(runs(jobs[spec.aggregate].if, { result: 'success', reused: 'true' })).toBe(true);
|
||||
});
|
||||
@@ -91,7 +98,7 @@ for (const [file, spec] of Object.entries(FILES)) {
|
||||
const steps = jobs[spec.aggregate].steps as any[];
|
||||
expect(steps[0].id).toBe('route');
|
||||
expect(steps[0].if).toBeUndefined();
|
||||
for (const step of steps.slice(1)) expect([step.name ?? step.uses, step.if]).toEqual([step.name ?? step.uses, SKIP]);
|
||||
for (const step of steps.slice(1)) expect([step.name ?? step.uses, step.if]).toEqual([step.name ?? step.uses, spec.skip]);
|
||||
const verify = steps.findIndex(s => s.run?.includes('--ci-verify'));
|
||||
const record = steps.findIndex(s => s.run?.includes('scripts/ci-tree-reuse.ts record'));
|
||||
const upload = steps.findIndex(s => s.with?.name === 'tested-tree');
|
||||
@@ -114,18 +121,30 @@ for (const [file, spec] of Object.entries(FILES)) {
|
||||
const go = (env: Record<string, string>) => {
|
||||
const out = path.join(dir, 'out'); const summary = path.join(dir, 'summary');
|
||||
fs.writeFileSync(out, ''); fs.writeFileSync(summary, '');
|
||||
const r = spawnSync('bash', ['-e', '-c', route.run], { env: { PATH: process.env.PATH ?? '', GITHUB_OUTPUT: shell(out), GITHUB_STEP_SUMMARY: shell(summary), GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', GITHUB_SERVER_URL: 'https://github.com', GITHUB_REPOSITORY: 'garrytan/gstack', REUSE_RESULT: '', REUSED: '', REUSE_RUN_ID: '', REUSE_RUN_URL: '', REUSE_TREE: '', ...env }, encoding: 'utf8', timeout: 5000 });
|
||||
const r = spawnSync('bash', ['-e', '-c', route.run], { env: { PATH: process.env.PATH ?? '', GITHUB_OUTPUT: shell(out), GITHUB_STEP_SUMMARY: shell(summary), GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', GITHUB_SERVER_URL: 'https://github.com', GITHUB_REPOSITORY: 'garrytan/gstack', REUSE_RESULT: '', REUSED: '', REUSE_RUN_ID: '', REUSE_RUN_URL: '', REUSE_TREE: '', DOCS_SCOPE_RESULT: '', DOCS_ONLY: '', DOCS_LANE_RESULT: '', ...env }, encoding: 'utf8', timeout: 5000 });
|
||||
return { status: r.status, out: fs.readFileSync(out, 'utf8'), summary: fs.readFileSync(summary, 'utf8') };
|
||||
};
|
||||
const good = { REUSE_RESULT: 'success', REUSED: 'true', REUSE_RUN_ID: '123', REUSE_RUN_URL: 'https://github.com/garrytan/gstack/actions/runs/123', REUSE_TREE: 'a'.repeat(40) };
|
||||
expect(go({ REUSE_RESULT: 'skipped', GITHUB_EVENT_NAME: 'pull_request' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
expect(go({ REUSE_RESULT: 'success', REUSED: 'false' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
expect(go({ ...good, REUSE_RESULT: 'failure' })).toEqual({ status: 0, out: 'reused=false\n', summary: '' });
|
||||
const routed = (reused: boolean) => `reused=${reused}\n${spec.docsScope ? `skip=${reused}\n` : ''}`;
|
||||
expect(go({ REUSE_RESULT: 'skipped', GITHUB_EVENT_NAME: 'pull_request' })).toEqual({ status: 0, out: routed(false), summary: '' });
|
||||
expect(go({ REUSE_RESULT: 'success', REUSED: 'false' })).toEqual({ status: 0, out: routed(false), summary: '' });
|
||||
expect(go({ ...good, REUSE_RESULT: 'failure' })).toEqual({ status: 0, out: routed(false), summary: '' });
|
||||
const reused = go(good);
|
||||
expect([reused.status, reused.out]).toEqual([0, 'reused=true\n']);
|
||||
expect([reused.status, reused.out]).toEqual([0, routed(true)]);
|
||||
expect(reused.summary).toContain('Reused https://github.com/garrytan/gstack/actions/runs/123');
|
||||
const prRun = { GITHUB_EVENT_NAME: 'pull_request', GITHUB_REF: 'refs/pull/7/merge' };
|
||||
if (spec.pullRequest) expect(go({ ...good, ...prRun }).out).toBe('reused=true\n');
|
||||
if (spec.pullRequest) expect(go({ ...good, ...prRun }).out).toBe(routed(true));
|
||||
if (spec.docsScope) {
|
||||
const docs = { ...prRun, DOCS_SCOPE_RESULT: 'success', DOCS_ONLY: 'true', DOCS_LANE_RESULT: 'success' };
|
||||
const passed = go(docs);
|
||||
expect([passed.status, passed.out]).toEqual([0, 'reused=false\nskip=true\n']);
|
||||
expect(passed.summary).toContain('Docs-only pull request');
|
||||
for (const bad of <Record<string, string>[]>[{ DOCS_LANE_RESULT: 'failure' }, { DOCS_LANE_RESULT: 'skipped' }, { GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main' }]) {
|
||||
const r = go({ ...docs, ...bad });
|
||||
expect([JSON.stringify(bad), r.status === 0, r.out]).toEqual([JSON.stringify(bad), false, '']);
|
||||
}
|
||||
expect(go({ ...docs, DOCS_SCOPE_RESULT: 'failure', REUSE_RESULT: 'skipped' }).out).toBe(routed(false));
|
||||
}
|
||||
for (const bad of <Record<string, string>[]>[{ REUSE_RUN_ID: '' }, { REUSE_RUN_ID: '12x' }, { REUSE_RUN_URL: 'https://github.com/other/repo/actions/runs/123' }, { REUSE_TREE: '' }, { GITHUB_EVENT_NAME: 'pull_request' }, { GITHUB_REF: 'refs/heads/feature' }, { GITHUB_EVENT_NAME: 'workflow_dispatch' }, ...(spec.pullRequest ? [] : [prRun])]) {
|
||||
const r = go({ ...good, ...bad });
|
||||
expect([JSON.stringify(bad), r.status === 0, r.out]).toEqual([JSON.stringify(bad), false, '']);
|
||||
|
||||
@@ -409,15 +409,16 @@ describe('CSO runtime staging gates', () => {
|
||||
expect(gate['continue-on-error']).not.toBe(true);
|
||||
const required = workflow.jobs['free-tests'];
|
||||
expect(required.if).toBe('always()');
|
||||
expect(required.needs).toEqual(['reuse', 'free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']);
|
||||
expect(job.if).toBe("${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}");
|
||||
expect(required.needs).toEqual(['reuse', 'docs-scope', 'docs-lane', 'free-suite', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration']);
|
||||
expect(job.needs).toEqual(['reuse', 'docs-scope']);
|
||||
expect(job.if).toBe("${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') && !(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true') }}");
|
||||
const route = required.steps[0];
|
||||
expect(route.id).toBe('route');
|
||||
expect(route.env.REUSE_RESULT).toBe('${{ needs.reuse.result }}');
|
||||
expect(route.run).toContain('if [ "$REUSE_RESULT" != success ] || [ "$REUSED" != true ]; then');
|
||||
const strict = required.steps.find((step: any) => step.run?.includes('test "$CSO_DOCKER_RESULT" = success'));
|
||||
expect(strict.env.CSO_DOCKER_RESULT).toBe('${{ needs.cso-docker-integration.result }}');
|
||||
expect(strict.if).toBe("steps.route.outputs.reused != 'true'");
|
||||
expect(strict.if).toBe("steps.route.outputs.skip != 'true'");
|
||||
expect(required.steps.indexOf(strict)).toBe(1);
|
||||
for (const current of Object.values(workflow.jobs) as any[]) for (const step of current.steps) {
|
||||
if (step.uses?.startsWith('oven-sh/setup-bun')) expect(step.uses).toBe('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* R81 docs-only carry (lib/run-evidence.ts verifyRun, lib/ci-carry.ts findCarry; docs/pregate.md#docs-only): a pull
|
||||
* request run whose docs-scope gate skipped every suite on a docs-only change carries as not owed, and only when this
|
||||
* checkout's classifier agrees. The same run never stands as a tested tree. Fakes only; nothing reaches GitHub.
|
||||
*/
|
||||
import { expect, test } from 'bun:test';
|
||||
import { findCarry } from '../lib/ci-carry';
|
||||
import type { ApiJob, ApiRun, RunDeps, RunProfile, RunSource } from '../lib/run-evidence';
|
||||
import { verifyRun } from '../lib/run-evidence';
|
||||
|
||||
const REPO = 'garrytan/gstack';
|
||||
const HEAD = 'a'.repeat(40);
|
||||
const TREE = 'b'.repeat(40);
|
||||
const NOW = new Date('2026-11-08T12:00:00Z');
|
||||
const SUITES = ['free-plan', 'typecheck'];
|
||||
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: [...SUITES, 'free-tests'], artifact: 'tested-tree', os: 'Linux', skippable: { jobs: SUITES, gate: 'docs-scope', scripts: ['lib/docs-only.ts'] } };
|
||||
const RUN: ApiRun = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'pull_request', head_sha: HEAD, path: '.github/workflows/free-tests.yml', created_at: '2026-11-08T11:00:00Z', updated_at: '2026-11-08T11:10:00Z', repository: { full_name: REPO }, head_repository: { full_name: REPO } } as ApiRun;
|
||||
const job = (name: string, conclusion: string): ApiJob => ({ name, conclusion, completed_at: '2026-11-08T11:10:00Z' }) as ApiJob;
|
||||
const JOBS = [job('docs-scope', 'success'), ...SUITES.map(n => job(n, 'skipped')), job('free-tests', 'success')];
|
||||
let artifactReads = 0;
|
||||
const deps: RunDeps = {
|
||||
run: () => RUN,
|
||||
runsForHead: (_wf, sha) => (sha === HEAD ? [RUN] : []),
|
||||
jobs: () => JOBS,
|
||||
artifact: () => { artifactReads++; throw new Error('no artifact'); },
|
||||
commitTree: sha => (sha === HEAD ? TREE : null),
|
||||
};
|
||||
const source = (docsOnly?: (h: string) => boolean): RunSource => ({ repo: REPO, deps, profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW, unchanged: () => null, ...(docsOnly ? { docsOnly } : {}) });
|
||||
const carry = (src: RunSource) => findCarry(src, { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: () => TREE, inventory: () => ['test/a.test.ts'] });
|
||||
|
||||
test('a docs-only pull request run carries as not owed without reading a tested tree', () => {
|
||||
artifactReads = 0;
|
||||
const v = carry(source(h => h === HEAD));
|
||||
expect(v.ok).toBe(true);
|
||||
if (!v.ok) return;
|
||||
expect(v.notOwed).toContain('docs-only change');
|
||||
expect(v.tests).toBe('n/a');
|
||||
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb not_owed=docs-only runs=7\/1 /);
|
||||
expect(artifactReads).toBe(0);
|
||||
});
|
||||
|
||||
test('the same skipped run does not carry when this checkout calls the change code (a reused run, or a moved base)', () => {
|
||||
const v = carry(source(() => false));
|
||||
expect(v.ok).toBe(false);
|
||||
if (!v.ok) expect(v.detail).toContain('artifact_missing');
|
||||
});
|
||||
|
||||
test('without a classifier (ci-tree-reuse find), a docs-only run is never a tested tree, so a push to main cannot reuse it', () => {
|
||||
const v = verifyRun(deps, { repo: REPO, runRef: '7', profileName: 'ci-reuse/free-tests', profile: PROFILE, now: NOW, unchanged: () => null });
|
||||
expect(v.ok ? 'ok' : v.reason).toBe('artifact_missing');
|
||||
});
|
||||
|
||||
test('a run whose gate job failed never counts its skipped suites', () => {
|
||||
const failed: RunDeps = { ...deps, jobs: () => [job('docs-scope', 'failure'), ...SUITES.map(n => job(n, 'skipped')), job('free-tests', 'success')] };
|
||||
const v = verifyRun(failed, { repo: REPO, runRef: '7', profileName: 'ci-reuse/free-tests', profile: PROFILE, now: NOW, unchanged: () => null, docsOnly: () => true });
|
||||
expect(v.ok ? 'ok' : v.reason).toBe('job_skipped');
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* R81 wiring (docs/pregate.md#docs-only): a docs-only pull request skips the suites, every required check still
|
||||
* concludes, a broken classifier never skips anything, and a push to main always runs the full suite.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const load = (f: string) => (Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows', f), 'utf8')) as any).jobs as Record<string, any>;
|
||||
const GATE = "!(needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true')";
|
||||
const free = load('free-tests.yml');
|
||||
const smoke = load('windows-smoke.yml');
|
||||
const needs = (j: any) => [j.needs ?? []].flat();
|
||||
|
||||
describe('docs-scope', () => {
|
||||
test('runs only on pull requests, with a two-commit checkout and the shared action', () => {
|
||||
for (const jobs of [free, smoke]) {
|
||||
const s = jobs['docs-scope'];
|
||||
expect(s.if).toBe("github.event_name == 'pull_request'");
|
||||
expect(s.permissions).toBeUndefined();
|
||||
expect(s.steps[0].with['fetch-depth']).toBe(2);
|
||||
expect(s.steps.at(-1).uses).toBe('./.github/actions/docs-scope');
|
||||
}
|
||||
});
|
||||
test('the action runs the base branch classifier and treats a missing classifier or an error as not docs-only', () => {
|
||||
const a = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/actions/docs-scope/action.yml'), 'utf8')) as any;
|
||||
expect(a.runs.steps[0].with.ref).toBe('${{ github.event.pull_request.base.sha }}');
|
||||
const run: string = a.runs.steps[1].run;
|
||||
expect(run).toContain('.docs-scope-base/bin/gstack-docs-only');
|
||||
expect(run).toContain('--base HEAD^1 --head HEAD');
|
||||
expect(run).toContain("echo 'docs_only=false'");
|
||||
expect(run).toContain('exit 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('free-tests', () => {
|
||||
const suites = ['free-plan', 'typecheck', 'quick-bun-default', 'cso-macos-launcher', 'macos-named-regressions', 'cso-windows-launcher', 'cso-docker-integration'];
|
||||
test('every suite job skips only when docs-scope succeeded and said docs-only', () => {
|
||||
for (const name of suites) {
|
||||
expect([name, needs(free[name])]).toEqual([name, expect.arrayContaining(['reuse', 'docs-scope'])]);
|
||||
expect([name, free[name].if]).toEqual([name, expect.stringContaining(GATE)]);
|
||||
expect([name, free[name].if]).toEqual([name, expect.stringContaining('!cancelled()')]);
|
||||
}
|
||||
expect(free['free-suite'].if).toContain("needs.free-plan.result == 'success'");
|
||||
});
|
||||
test('docs-lane runs the link check and the registered docs tests', () => {
|
||||
const lane = free['docs-lane'];
|
||||
expect(lane.if).toBe("needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true'");
|
||||
const runs = lane.steps.map((s: any) => s.run ?? '').join('\n');
|
||||
expect(runs).toContain('scripts/check-doc-links.ts --base HEAD^1');
|
||||
expect(runs).toContain('$DOCS_TESTS');
|
||||
});
|
||||
test('the required free-tests aggregator always runs and passes a docs-only pull request only after docs-lane passed', () => {
|
||||
const agg = free['free-tests'];
|
||||
expect(agg.if).toBe('always()');
|
||||
expect(needs(agg)).toEqual(expect.arrayContaining(['docs-scope', 'docs-lane']));
|
||||
const route = agg.steps[0];
|
||||
expect(route.id).toBe('route');
|
||||
expect(route.run).toContain('test "$GITHUB_EVENT_NAME" = pull_request');
|
||||
expect(route.run).toContain('test "$DOCS_LANE_RESULT" = success');
|
||||
expect(route.run.indexOf('DOCS_ONLY')).toBeLessThan(route.run.indexOf('REUSE_RESULT'));
|
||||
});
|
||||
test('a docs-only run never records or uploads a tested tree, so a push to main cannot reuse it', () => {
|
||||
const steps = free['free-tests'].steps.slice(1);
|
||||
for (const s of steps) expect([s.name ?? s.uses, s.if]).toEqual([s.name ?? s.uses, "steps.route.outputs.skip != 'true'"]);
|
||||
expect(steps.some((s: any) => String(s.run ?? '').includes('ci-tree-reuse.ts record'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('windows-smoke always runs, so its required check concludes success, and skips its steps only when docs-scope succeeded and said docs-only', () => {
|
||||
const job = smoke['windows-smoke'];
|
||||
expect(needs(job)).toEqual(['docs-scope']);
|
||||
expect(job.if).toBe(`${'$'}{{ !cancelled() }}`);
|
||||
expect(job.env.DOCS_ONLY).toBe("${{ needs.docs-scope.result == 'success' && needs.docs-scope.outputs.docs_only == 'true' }}");
|
||||
expect(job.steps[0].if).toBe("env.DOCS_ONLY == 'true'");
|
||||
for (const s of job.steps.slice(1)) expect([s.name ?? s.uses, s.if]).toEqual([s.name ?? s.uses, expect.stringMatching(/(^|&& )env\.DOCS_ONLY != 'true'$/)]);
|
||||
});
|
||||
|
||||
test('every job that reads docs_only uses the fail-open-to-tests gate', () => {
|
||||
for (const [file, jobs] of [['free-tests.yml', free], ['windows-smoke.yml', smoke]] as const) {
|
||||
for (const [name, job] of Object.entries(jobs)) {
|
||||
if (name === 'docs-lane' || !String(job.if ?? '').includes('docs_only')) continue;
|
||||
expect([file, name, job.if]).toEqual([file, name, expect.stringContaining(GATE)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* R81 docs-only class (lib/docs-only.ts, bin/gstack-docs-only; docs/pregate.md#docs-only). The bypass corpus comes
|
||||
* first: every way a pull request could pass code or agent instructions off as docs must classify "no".
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { classifyDocsOnly, DOCS_READER_EXEMPT, DOCS_READER_TESTS, DocsOnlyError, referencesTo } from '../lib/docs-only';
|
||||
import { addedLines, brokenLinks } from '../lib/doc-links';
|
||||
import { mergedChecksFor } from '../lib/land-docs-only';
|
||||
import { landPolicyErrors } from '../lib/land-merged-checks';
|
||||
import { docsOnlyLane } from '../lib/pregate/docs-only-lane';
|
||||
import type { CheckResult, PregateContext } from '../lib/pregate/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const ENV = { ...process.env, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test', GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test' };
|
||||
let repo = '';
|
||||
|
||||
function git(...args: string[]): string {
|
||||
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: ENV, timeout: 30_000 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout;
|
||||
}
|
||||
function put(rel: string, text = `# ${rel}\n\nprose\n`) {
|
||||
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, rel), text);
|
||||
}
|
||||
/** Commit `change` on top of the seed and classify seed..HEAD; resets afterwards. */
|
||||
function classify(change: () => void, worktree = false) {
|
||||
try {
|
||||
change();
|
||||
if (worktree) return classifyDocsOnly({ root: repo, base: 'seed' });
|
||||
git('add', '-A');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'change');
|
||||
return classifyDocsOnly({ root: repo, base: 'seed', head: 'HEAD' });
|
||||
} finally {
|
||||
git('reset', '-q', '--hard', 'seed');
|
||||
git('clean', '-qfdx');
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-only-'));
|
||||
git('init', '-q', '-b', 'main');
|
||||
for (const f of ['README.md', 'docs/guide.md', 'docs/other.md', 'docs/run-me.md', 'docs/troubleshooting.md', 'docs/captain.md', 'docs/ship-policy.md',
|
||||
'docs/evals/result.md', 'docs/generated.md', 'review/SKILL.md', 'review/README.md', 'changelog.d/a.md', 'test/fixtures/x/README.md']) put(f);
|
||||
put('docs/other.md', 'See [the guide](guide.md).\n');
|
||||
put('CHANGELOG.md', '- edited docs/guide.md\n');
|
||||
put('lib/x.ts', "export const doc = 'docs/troubleshooting.md#x';\n");
|
||||
put('scripts/run.ts', "Bun.spawn(['sh', 'docs/run-me.md']);\n");
|
||||
put('.gstack/generated.json', JSON.stringify({ entries: [{ id: 'g', command: 'gen', outputs: ['docs/generated.md'], inputs: [] }] }));
|
||||
git('add', '-A');
|
||||
git('commit', '-q', '-m', 'seed');
|
||||
git('tag', 'seed');
|
||||
});
|
||||
afterAll(() => { if (repo) fs.rmSync(repo, { recursive: true, force: true }); });
|
||||
|
||||
const reasons = (r: ReturnType<typeof classifyDocsOnly>) => r.refusals.map(x => `${x.path}: ${x.reason}`).join('; ');
|
||||
|
||||
describe('bypass corpus: never docs-only', () => {
|
||||
const cases: [string, () => void][] = [
|
||||
['docs plus code', () => { put('docs/guide.md', 'new\n'); put('lib/foo.ts', 'export {};\n'); }],
|
||||
['a doc a script executes', () => put('docs/run-me.md', 'echo pwned\n')],
|
||||
['a doc code names by path', () => put('docs/troubleshooting.md', 'changed\n')],
|
||||
['agent prose under docs/', () => put('docs/captain.md', 'changed\n')],
|
||||
['a policy doc', () => put('docs/ship-policy.md', 'changed\n')],
|
||||
['a case variant of an agent-prose name', () => put('docs/Captain.md')],
|
||||
['a lowercase CLAUDE.md', () => put('docs/claude.md')],
|
||||
['SKILL.md', () => put('review/SKILL.md', 'changed\n')],
|
||||
['prose in a skill directory', () => put('review/README.md', 'changed\n')],
|
||||
['a template', () => put('docs/page.md.tmpl')],
|
||||
['a sections file', () => put('docs/sections/a.md')],
|
||||
['factory.md, CREW.md, AGENTS.md, ETHOS.md', () => { put('docs/factory.md'); put('docs/CREW.md'); put('docs/AGENTS.md'); put('docs/ETHOS.md'); }],
|
||||
['preferences', () => put('docs/preferences.md')],
|
||||
['a generated output', () => put('docs/generated.md', 'changed\n')],
|
||||
['pinned results', () => put('docs/evals/result.md', 'changed\n')],
|
||||
['a preregistration', () => put('docs/runaway-prereg.md')],
|
||||
['root markdown off the allowlist', () => put('NOTES-x.md')],
|
||||
['a wrong-case docs tree', () => put('Docs/x.md')],
|
||||
['a wrong-case README', () => put('readme.MD')],
|
||||
['a look-alike README', () => put('READМE.md')],
|
||||
['code under a .md directory', () => put('docs/x.md/evil.ts', 'export {};\n')],
|
||||
['a fixture README', () => put('test/fixtures/x/README.md', 'changed\n')],
|
||||
['the classifier itself', () => put('lib/docs-only.ts', 'export {};\n')],
|
||||
['a workflow', () => put('.github/workflows/x.yml', 'on: push\n')],
|
||||
['an executable doc', () => { fs.chmodSync(path.join(repo, 'docs/guide.md'), 0o755); }],
|
||||
['a symlinked doc', () => fs.symlinkSync('../lib/x.ts', path.join(repo, 'docs/link.md'))],
|
||||
['code renamed to docs', () => git('mv', 'lib/x.ts', 'docs/x.md')],
|
||||
['a deleted doc that code names', () => fs.rmSync(path.join(repo, 'docs/troubleshooting.md'))],
|
||||
['an empty diff', () => {}],
|
||||
];
|
||||
for (const [name, change] of cases) test(name, () => expect(classify(change).docsOnly).toBe(false));
|
||||
|
||||
test('worktree mode sees untracked code', () => {
|
||||
expect(classify(() => { put('docs/guide.md', 'x\n'); put('lib/new.ts', 'export {};\n'); }, true).docsOnly).toBe(false);
|
||||
});
|
||||
test('a path that looks like a git option is searched, never parsed', () => {
|
||||
expect(referencesTo(repo, '-Ofoo.md')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs-only', () => {
|
||||
test('a human doc and a changelog fragment', () => {
|
||||
const r = classify(() => { put('docs/guide.md', 'new prose\n'); put('changelog.d/b.md', '- note\n'); });
|
||||
expect(reasons(r)).toBe('');
|
||||
expect(r.docsOnly).toBe(true);
|
||||
expect(r.tests).toContain('test/docs-config-keys.test.ts');
|
||||
});
|
||||
test('CHANGELOG.md and other docs naming a doc do not pin it', () => {
|
||||
expect(classify(() => put('docs/guide.md', 'x\n')).docsOnly).toBe(true);
|
||||
});
|
||||
test('a new doc and a deleted unreferenced doc', () => {
|
||||
expect(classify(() => { put('docs/new.md'); fs.rmSync(path.join(repo, 'docs/other.md')); }).docsOnly).toBe(true);
|
||||
});
|
||||
test('the reference scan is a substring match, so a path inside a longer one pins conservatively', () => {
|
||||
expect(reasons(classify(() => put('README.md', 'x\n')))).toContain('README.md: named by');
|
||||
});
|
||||
test('worktree mode counts untracked docs', () => {
|
||||
expect(classify(() => put('docs/new.md'), true).docsOnly).toBe(true);
|
||||
});
|
||||
test('a pull request merge commit classifies against its first parent, so a base that moved on does not leak in', () => {
|
||||
git('checkout', '-q', '-b', 'pr', 'seed');
|
||||
put('docs/guide.md', 'pr prose\n');
|
||||
git('commit', '-qam', 'docs');
|
||||
git('checkout', '-q', '-b', 'base', 'seed');
|
||||
put('lib/moved.ts', 'export {};\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', 'base moved');
|
||||
git('merge', '-q', '--no-ff', '-m', 'merge', 'pr');
|
||||
try {
|
||||
expect(classifyDocsOnly({ root: repo, base: 'HEAD^1', head: 'HEAD' }).docsOnly).toBe(true);
|
||||
} finally {
|
||||
git('checkout', '-q', 'main');
|
||||
git('branch', '-qD', 'pr', 'base');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors fail closed', () => {
|
||||
test('an unknown ref throws DOCS_ONLY_ERROR, and the CLI exits 2 and writes docs_only=false', () => {
|
||||
expect(() => classifyDocsOnly({ root: repo, base: 'nope', head: 'HEAD' })).toThrow(DocsOnlyError);
|
||||
const out = path.join(repo, '..', `${path.basename(repo)}-out`);
|
||||
const r = spawnSync(path.join(ROOT, 'bin/gstack-docs-only'), ['--repo', repo, '--base', 'nope', '--head', 'HEAD', '--github-output', out], { encoding: 'utf8', env: { ...ENV, GITHUB_EVENT_NAME: 'pull_request' }, timeout: 30_000 });
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stdout).toContain('DOCS_ONLY: error (DOCS_ONLY_ERROR:');
|
||||
expect(fs.readFileSync(out, 'utf8')).toContain('docs_only=false');
|
||||
fs.rmSync(out, { force: true });
|
||||
});
|
||||
test('only a pull_request event may write docs_only=true', () => {
|
||||
put('docs/guide.md', 'x\n');
|
||||
git('commit', '-qam', 'docs');
|
||||
const out = path.join(repo, '..', `${path.basename(repo)}-ev`);
|
||||
try {
|
||||
for (const [event, want, code] of [['push', 'false', 1], ['workflow_dispatch', 'false', 1], ['merge_group', 'false', 1], ['pull_request', 'true', 0]] as const) {
|
||||
fs.rmSync(out, { force: true });
|
||||
const r = spawnSync(path.join(ROOT, 'bin/gstack-docs-only'), ['--repo', repo, '--base', 'HEAD^1', '--head', 'HEAD', '--github-output', out], { encoding: 'utf8', env: { ...ENV, GITHUB_EVENT_NAME: event }, timeout: 30_000 });
|
||||
expect([event, r.status]).toEqual([event, code]);
|
||||
expect(fs.readFileSync(out, 'utf8')).toContain(`docs_only=${want}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(out, { force: true });
|
||||
git('reset', '-q', '--hard', 'seed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('link check', () => {
|
||||
test('missing files and anchors fail; headings with code, explicit ids, line suffixes and URLs pass', () => {
|
||||
put('docs/a.md', '# Who reads (R65, the `review` key)\n<a id="x-y"></a>\n\n[ok](#who-reads-r65-the-review-key) [id](#x-y) [file](../lib/x.ts:3) [web](https://e.com/z)\n[gone](missing.md) [bad](#nope)\n`[code](skip.md)`\n');
|
||||
try {
|
||||
expect(brokenLinks(repo, ['docs/a.md']).map(b => b.target)).toEqual(['missing.md', '#nope']);
|
||||
expect(brokenLinks(repo, ['docs/a.md'], new Map([['docs/a.md', new Set([4])]]))).toEqual([]);
|
||||
} finally { fs.rmSync(path.join(repo, 'docs/a.md')); }
|
||||
});
|
||||
|
||||
test('added lines cover quoted and spaced names and untracked files, an undecodable anchor is broken, and an unknown base throws', () => {
|
||||
const names = ['docs/café.md', 'docs/with space.md'];
|
||||
try {
|
||||
for (const n of names) put(n, '# T\n\n[gone](never-there.md) [pct](guide.md#100%)\n');
|
||||
const added = addedLines(repo, 'seed', names);
|
||||
expect(names.map(n => [...(added.get(n) ?? [])])).toEqual([[1, 2, 3, 4], [1, 2, 3, 4]]);
|
||||
expect(brokenLinks(repo, names, added).map(b => `${b.file}:${b.target}:${b.reason}`)).toEqual(names.flatMap(n => [`${n}:never-there.md:no such file`, `${n}:guide.md#100%:undecodable anchor`]));
|
||||
fs.appendFileSync(path.join(repo, 'docs/guide.md'), '[late](nope-late.md)\n');
|
||||
const lines = fs.readFileSync(path.join(repo, 'docs/guide.md'), 'utf8').split('\n').length - 1;
|
||||
expect([...(addedLines(repo, 'seed', ['docs/guide.md']).get('docs/guide.md') ?? [])]).toEqual([lines]);
|
||||
expect(() => addedLines(repo, 'no-such-ref', ['docs/guide.md'])).toThrow('unknown revision');
|
||||
} finally {
|
||||
git('checkout', '-q', '--', 'docs/guide.md');
|
||||
for (const n of names) fs.rmSync(path.join(repo, n), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('scripts/check-doc-links.ts exits 0 on resolving added links, 1 on a broken one and 2 on an unknown base', () => {
|
||||
const cli = (...args: string[]) => {
|
||||
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'scripts/check-doc-links.ts'), ...args], { cwd: repo, encoding: 'utf8', timeout: 30_000 });
|
||||
return [r.status, (r.stdout ?? '').trim().split('\n').at(-1)];
|
||||
};
|
||||
try {
|
||||
put('docs/cli.md', '[ok](guide.md)\n');
|
||||
expect(cli('--base', 'seed', 'docs/cli.md')).toEqual([0, 'DOC_LINKS: ok (1 file(s))']);
|
||||
put('docs/cli.md', '[gone](nope.md)\n');
|
||||
expect(cli('--base', 'seed', 'docs/cli.md')).toEqual([1, 'DOC_LINKS: broken 1']);
|
||||
expect(cli('--base', 'no-such-ref', 'docs/cli.md')[0]).toBe(2);
|
||||
} finally { fs.rmSync(path.join(repo, 'docs/cli.md'), { force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
test('every test that reads docs by directory or lists every tracked file is registered', () => {
|
||||
const READER = /\['ls-files', '-z'\]|git\(\['ls-files', '-z'\]\)|git\('ls-files', '-z'\)|'ls-files', '-z', '--cached', '--others'|join\(ROOT, '(?:docs|changelog\.d)'\)/;
|
||||
const listed = new Set([...DOCS_READER_TESTS.flatMap(t => t.tests), ...Object.keys(DOCS_READER_EXEMPT)]);
|
||||
for (const f of listed) expect(fs.existsSync(path.join(ROOT, f)), f).toBe(true);
|
||||
const files = (spawnSync('git', ['ls-files', '-z', '--', 'test'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout ?? '').split('\0')
|
||||
.filter(f => f.endsWith('.ts') && f !== 'test/docs-only.test.ts' && !f.startsWith('test/fixtures/'));
|
||||
const unregistered = files.filter(f => READER.test(fs.readFileSync(path.join(ROOT, f), 'utf8')) && !listed.has(f));
|
||||
expect(unregistered.map(f => `${f} reads docs by directory or every tracked file; add it to DOCS_READER_TESTS or DOCS_READER_EXEMPT in lib/docs-only.ts`)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('land and pregate use the same class', () => {
|
||||
test('land skips only the declared commands, only for a docs-only pin, and never when the base it lands on gained a reader of a changed doc', () => {
|
||||
const land = { merged_checks: ['typecheck', 'tests'], docs_only_skip: ['tests'] };
|
||||
put('docs/guide.md', 'land prose\n');
|
||||
git('commit', '-qam', 'docs pin');
|
||||
const docsPin = git('rev-parse', 'HEAD').trim();
|
||||
put('lib/x.ts', 'export const y = 1;\n');
|
||||
git('commit', '-qam', 'code pin');
|
||||
const codePin = git('rev-parse', 'HEAD').trim();
|
||||
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').trim();
|
||||
git('checkout', '-q', '--detach', 'seed');
|
||||
put('test/reads-guide.test.ts', "export const doc = 'docs/guide.md';\n");
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', 'base gains a reader');
|
||||
const readerTip = git('rev-parse', 'HEAD').trim();
|
||||
git('checkout', '-q', branch);
|
||||
try {
|
||||
expect(mergedChecksFor(repo, land, 'seed', docsPin)).toEqual(['typecheck']);
|
||||
expect(mergedChecksFor(repo, land, readerTip, docsPin)).toEqual(['typecheck', 'tests']);
|
||||
expect(mergedChecksFor(repo, land, 'seed', codePin)).toEqual(['typecheck', 'tests']);
|
||||
expect(mergedChecksFor(repo, land, 'nope', docsPin)).toEqual(['typecheck', 'tests']);
|
||||
expect(mergedChecksFor(repo, { merged_checks: ['tests'] }, 'seed', docsPin)).toEqual(['tests']);
|
||||
} finally { git('reset', '-q', '--hard', 'seed'); }
|
||||
expect(landPolicyErrors({ merged_checks: ['a', 'b'], docs_only_skip: ['b'] })).toEqual([]);
|
||||
expect(landPolicyErrors({ merged_checks: ['a'], docs_only_skip: ['a'] }).map(e => e.path)).toEqual(['land.docs_only_skip']);
|
||||
expect(landPolicyErrors({ merged_checks: ['a', 'b'], docs_only_skip: ['c'] }).map(e => e.path)).toEqual(['land.docs_only_skip']);
|
||||
});
|
||||
|
||||
test('pregate passes a docs-only change with no remote obligation, fails a broken added link, and leaves code to the lanes', () => {
|
||||
git('update-ref', 'refs/remotes/origin/main', 'seed');
|
||||
const ctx = (allow = true) => ({ repoRoot: repo, base: 'main', explain: [] as string[], allowRepoCommands: allow }) as unknown as PregateContext;
|
||||
const base = { id: 'lanes', stage: 'tests', status: 'pass', inputs: {}, lines: [], remote: [{ lane: 'x' }] } as unknown as CheckResult;
|
||||
try {
|
||||
put('docs/guide.md', 'fine [x](other.md)\n');
|
||||
const ok = docsOnlyLane(ctx(), base)!;
|
||||
expect([ok.status, ok.remote]).toEqual(['pass', []]);
|
||||
expect(ok.detail).toContain('docs-only: 1 file(s)');
|
||||
put('docs/guide.md', 'broken [x](nope.md)\n');
|
||||
expect(docsOnlyLane(ctx(), base)!.status).toBe('fail');
|
||||
put('lib/x.ts', 'export {};\n');
|
||||
expect(docsOnlyLane(ctx(), base)).toBeNull();
|
||||
} finally {
|
||||
git('reset', '-q', '--hard', 'seed');
|
||||
git('clean', '-qfdx');
|
||||
git('update-ref', '-d', 'refs/remotes/origin/main');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 224,
|
||||
"why": "R81: /ship Step 5 names the docs-only route (one sentence)"
|
||||
}
|
||||
@@ -112,15 +112,16 @@ test('a record_durations dispatch times each Windows-safe file alone and uploads
|
||||
expect(job.steps.at(-1).if).toBe("always() && steps.seed.outcome == 'success'");
|
||||
});
|
||||
|
||||
test('windows-smoke is the PR gate: every pull request and main push, one windows-latest job beside the Trunk observer', () => {
|
||||
test('windows-smoke is the PR gate: every pull request and main push, one windows-latest job beside the Trunk observer that always runs, its steps skipped only when docs-scope said docs-only (R81)', () => {
|
||||
expect(Object.keys(smoke.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch']);
|
||||
expect(smoke.on.pull_request).toEqual({ branches: ['main'] });
|
||||
expect(smoke.on.push).toEqual({ branches: ['main'] });
|
||||
expect(smoke.permissions).toEqual({ contents: 'read' });
|
||||
expect(Object.keys(smoke.jobs)).toEqual(['trunk-dynamic-ci', 'windows-smoke']);
|
||||
expect(Object.keys(smoke.jobs)).toEqual(['trunk-dynamic-ci', 'docs-scope', 'windows-smoke']);
|
||||
const job = smoke.jobs['windows-smoke'];
|
||||
expect(job['runs-on']).toBe('windows-latest');
|
||||
expect(job.if).toBeUndefined();
|
||||
expect(job.if).toBe('${{ !cancelled() }}');
|
||||
expect(job.needs).toBe('docs-scope');
|
||||
const step = (name: string) => job.steps.find((s: any) => s.name === name);
|
||||
expect(step('./setup installs into an isolated HOME').run).toContain('./setup --host claude');
|
||||
const start = step('The core skills start').run;
|
||||
|
||||
@@ -130,12 +130,12 @@ function prRepo(files: string[], lines: Record<string, string> = {}) {
|
||||
}
|
||||
|
||||
describe('windows-smoke stays on every pull request', () => {
|
||||
test('no paths filter, no job condition, one test job beside the Trunk observer', () => {
|
||||
test('no paths filter, one test job beside the Trunk observer, which always runs and skips its steps only when docs-scope said docs-only (R81)', () => {
|
||||
expect(smoke.on.pull_request).toEqual({ branches: ['main'] });
|
||||
expect(smoke.on.push).toEqual({ branches: ['main'] });
|
||||
expect(Object.keys(smoke.jobs)).toEqual(['trunk-dynamic-ci', 'windows-smoke']);
|
||||
expect(smoke.jobs['windows-smoke'].if).toBeUndefined();
|
||||
expect(smoke.jobs['windows-smoke'].needs).toBeUndefined();
|
||||
expect(Object.keys(smoke.jobs)).toEqual(['trunk-dynamic-ci', 'docs-scope', 'windows-smoke']);
|
||||
expect(smoke.jobs['windows-smoke'].if).toBe('${{ !cancelled() }}');
|
||||
expect(smoke.jobs['windows-smoke'].needs).toBe('docs-scope');
|
||||
});
|
||||
|
||||
test('no Windows job that can skip is a required check, and the scoped aggregate always reports', () => {
|
||||
|
||||
Reference in new issue
Block a user