diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index fa545bfde3..46bee9ebbe 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -79,6 +79,11 @@ jobs: persist-credentials: false fetch-depth: 0 + # Reject deterministic docs drift before queuing builds/tests. Keep the + # independent per-push secret scan and Public docs check unchanged. + - name: Check shipped docs before dependency work + run: python3 scripts/check_docs_mirror.py + - name: Classify changed files id: filter run: | diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index ec492a715d..03d1b1b11c 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -47,6 +47,10 @@ jobs: with: persist-credentials: false + # Fail before installs, Docker builds or the eight-shard fan-out. + - name: Check shipped docs before dependency work + run: python3 scripts/check_docs_mirror.py + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -82,6 +86,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # Fail before installs, Docker builds or the eight-shard fan-out. + - name: Check shipped docs before dependency work + run: python3 scripts/check_docs_mirror.py + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -297,6 +305,10 @@ jobs: with: persist-credentials: false + # Fail before installs, Docker builds or the eight-shard fan-out. + - name: Check shipped docs before dependency work + run: python3 scripts/check_docs_mirror.py + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index db7d27ba06..a8dbfbd5f0 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,23 @@ ## Purpose +### Shipped documentation is checked before CI dependency work + +Build and Test checks every shipped Markdown mirror immediately after the +`changes` job's checkout, before classification can admit frontend, backend, +script/build or benchmark work. Core E2E does the same before dependency setup +in tier selection and both independent provisioning jobs; a failing tier check +does not admit the eight browser shards. Existing check names, secret scanning, +Public docs validation, test coverage and failure verdicts remain unchanged. + +The check is blocking and unconditional, including dispatches. It uses the +whole-tree mode of `scripts/check_docs_mirror.py`, not staged-only tolerance. +`TestCIDocsMirrorPreflight` parses all four actual workflow entrypoints and +executes their command with the real checker: stale or orphaned shipped guides +stop before a dependency-work marker, while identical copies allow it. This +prevents deterministic documentation faults from consuming expensive CI work; +local controls do not establish hosted queue-time improvement or publication. + ### Native helper verification follows helper-only changes Unified Agent Native Verification admits helper command, protocol, shared diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 438cfb1bb1..da492f2c72 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3786,6 +3786,106 @@ func TestReleaseTrainCITriggersIncludeBuildAndE2E(t *testing.T) { } } +// Documentation drift is deterministic and cheap to reject. It must not wait +// behind dependency installation or fan out into builds that cannot land. +func TestCIDocsMirrorPreflight(t *testing.T) { + type step struct { + Name string `yaml:"name"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + If string `yaml:"if"` + ContinueOnError bool `yaml:"continue-on-error"` + Directory string `yaml:"working-directory"` + } + type job struct { + Steps []step `yaml:"steps"` + } + for _, site := range []struct{ workflow, job string }{ + {"build-and-test.yml", "changes"}, + {"test-e2e.yml", "tier-selection"}, + {"test-e2e.yml", "offline-org-provisioning"}, + {"test-e2e.yml", "agent-registration"}, + } { + t.Run(site.workflow+"/"+site.job, func(t *testing.T) { + content, err := os.ReadFile(repoFile(".github", "workflows", site.workflow)) + if err != nil { + t.Fatal(err) + } + var workflow struct { + Jobs map[string]job `yaml:"jobs"` + } + if err := yaml.Unmarshal(content, &workflow); err != nil { + t.Fatal(err) + } + steps := workflow.Jobs[site.job].Steps + if len(steps) < 2 || !strings.HasPrefix(steps[0].Uses, "actions/checkout@") { + t.Fatal("preflight must follow source checkout") + } + guard := steps[1] + if guard.Run != "python3 scripts/check_docs_mirror.py" || guard.If != "" || + guard.ContinueOnError || guard.Directory != "" || guard.Uses != "" { + t.Fatal("whole-tree mirror check must be an unconditional blocking step immediately after checkout") + } + checker, err := os.ReadFile(repoFile("scripts", "check_docs_mirror.py")) + if err != nil { + t.Fatal(err) + } + for _, fixture := range []struct { + name, source, mirror, diagnostic string + wantExit int + }{ + {"synced", "# Safe guidance\n", "# Safe guidance\n", "passed (1 shipped docs)", 0}, + {"stale-shipped-guide", "# Safe guidance\nDo not repeat a guest probe.\n", "# Safe guidance\n", "docs/GUIDE.md and frontend-modern/public/docs/GUIDE.md differ", 1}, + {"missing-source", "", "# Orphan guide\n", "shipped copy has no repo source docs/GUIDE.md", 1}, + } { + t.Run(fixture.name, func(t *testing.T) { + root := t.TempDir() + write := func(path string, data []byte) { + t.Helper() + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, data, 0600); err != nil { + t.Fatal(err) + } + } + write("scripts/check_docs_mirror.py", checker) + write("frontend-modern/public/docs/GUIDE.md", []byte(fixture.mirror)) + if fixture.source != "" { + write("docs/GUIDE.md", []byte(fixture.source)) + } + // Run the workflow's actual command and a harmless stand-in for + // the next dependency step under GitHub's fail-fast shell mode. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", guard.Run+"\nprintf started > dependency-work") + cmd.Dir = root + output, err := cmd.CombinedOutput() + exit := 0 + if err != nil { + failure, ok := err.(*exec.ExitError) + if !ok { + t.Fatal(err) + } + exit = failure.ExitCode() + } + if exit != fixture.wantExit || !strings.Contains(string(output), fixture.diagnostic) { + t.Fatalf("exit=%d, want=%d, output=%s", exit, fixture.wantExit, output) + } + _, markerErr := os.Stat(filepath.Join(root, "dependency-work")) + if fixture.wantExit == 0 && markerErr != nil { + t.Fatal("synced documentation must allow later dependency work") + } + if fixture.wantExit != 0 && !os.IsNotExist(markerErr) { + t.Fatal("failed preflight must stop before dependency work") + } + }) + } + }) + } +} + func TestProviderPairDockerProofRunsBeforeFreezeAndOnExactCandidate(t *testing.T) { const testName = "TestIntegrationProviderPairNetworkIsolation" const helperImage = "alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6"