diff --git a/.github/scripts/classify_ci_scope.py b/.github/scripts/classify_ci_scope.py index dd030fa8..c859ed9b 100644 --- a/.github/scripts/classify_ci_scope.py +++ b/.github/scripts/classify_ci_scope.py @@ -8,6 +8,8 @@ import sys from pathlib import PurePosixPath +FOCUSED_HOTFIX_PREFIX = "hotfix/focused/" + def normalize(path: str) -> str: normalized = path.strip().replace("\\", "/") @@ -35,16 +37,37 @@ def is_human_documentation(path: str) -> bool: ) -def classify(paths: list[str]) -> dict[str, bool]: +def blocks_focused_hotfix(path: str) -> bool: + return ( + path.startswith(".github/") + or path.startswith(".agents/") + or path.startswith("tests/+labkittest/") + or path.startswith("tools/") + or path.startswith("resources/") + or path in {"AGENTS.md", "buildfile.m", "tests/AGENTS.md"} + ) + + +def classify(paths: list[str], head_ref: str = "") -> dict[str, bool]: normalized = [normalize(path) for path in paths] normalized = [path for path in normalized if path] docs = any(is_human_documentation(path) for path in normalized) governance = any(is_governance_document(path) for path in normalized) - full = any( + has_executable_change = any( not is_human_documentation(path) and not is_governance_document(path) for path in normalized ) - return {"full": full, "docs": docs, "governance": governance} + focused = ( + head_ref.startswith(FOCUSED_HOTFIX_PREFIX) + and has_executable_change + and not any(blocks_focused_hotfix(path) for path in normalized) + ) + return { + "full": has_executable_change and not focused, + "focused": focused, + "docs": docs, + "governance": governance, + } def parse_args() -> argparse.Namespace: @@ -58,6 +81,11 @@ def parse_args() -> argparse.Namespace: "--github-output", help="Append full/docs/governance outputs to this GitHub output file.", ) + parser.add_argument( + "--head-ref", + default="", + help="Pull-request head branch used to recognize focused hotfixes.", + ) return parser.parse_args() @@ -65,7 +93,7 @@ def main() -> int: args = parse_args() separator = "\0" if args.null else "\n" paths = sys.stdin.read().split(separator) - scope = classify(paths) + scope = classify(paths, args.head_ref) lines = [f"{name}={str(value).lower()}" for name, value in scope.items()] if args.github_output: with open(args.github_output, "a", encoding="utf-8") as stream: diff --git a/.github/scripts/test_classify_ci_scope.py b/.github/scripts/test_classify_ci_scope.py index f77a7b45..ed271f4e 100644 --- a/.github/scripts/test_classify_ci_scope.py +++ b/.github/scripts/test_classify_ci_scope.py @@ -21,19 +21,19 @@ def test_normalization_preserves_dot_directories_on_every_platform(self): ) self.assertEqual( MODULE.classify([r".agents\dos-and-donts.md"]), - {"full": False, "docs": False, "governance": True}, + {"full": False, "focused": False, "docs": False, "governance": True}, ) def test_agent_guidance_uses_only_governance_check(self): self.assertEqual( MODULE.classify(["AGENTS.md", ".agents/dos-and-donts.md"]), - {"full": False, "docs": False, "governance": True}, + {"full": False, "focused": False, "docs": False, "governance": True}, ) def test_human_docs_request_docs_check_without_full_matrix(self): self.assertEqual( MODULE.classify(["docs/framework/README.md", "site/index.html"]), - {"full": False, "docs": True, "governance": False}, + {"full": False, "focused": False, "docs": True, "governance": False}, ) def test_source_or_ci_configuration_requires_full_matrix(self): @@ -45,15 +45,52 @@ def test_source_or_ci_configuration_requires_full_matrix(self): with self.subTest(path=path): self.assertEqual( MODULE.classify([path]), - {"full": True, "docs": False, "governance": False}, + {"full": True, "focused": False, "docs": False, "governance": False}, ) def test_mixed_docs_and_source_run_both_relevant_profiles(self): self.assertEqual( MODULE.classify(["docs/apps/README.md", "labkit_launcher.m"]), - {"full": True, "docs": True, "governance": False}, + {"full": True, "focused": False, "docs": True, "governance": False}, ) + def test_focused_hotfix_uses_bounded_product_evidence(self): + self.assertEqual( + MODULE.classify( + [ + "+labkit/+app/+internal/+launcher/dispatch.m", + "tests/specs/system/launcher/LauncherDispatchSpec.m", + "docs/apps/labkit-core/launcher/README.md", + ], + "hotfix/focused/local-docs-generation", + ), + {"full": False, "focused": True, "docs": True, "governance": False}, + ) + + def test_ordinary_hotfix_keeps_full_matrix(self): + scope = MODULE.classify( + ["apps/image/crop/run.m"], + "hotfix/crop-repair", + ) + self.assertTrue(scope["full"]) + self.assertFalse(scope["focused"]) + + def test_focused_name_cannot_downgrade_infrastructure_changes(self): + for path in [ + ".github/workflows/ci.yml", + ".github/scripts/classify_ci_scope.py", + "buildfile.m", + "tests/+labkittest/run.m", + "tools/docs/renderLabKitDocs.m", + ]: + with self.subTest(path=path): + scope = MODULE.classify( + [path], + "hotfix/focused/unsafe-change", + ) + self.assertTrue(scope["full"]) + self.assertFalse(scope["focused"]) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6d89b2c..e3bef027 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: runs-on: ubuntu-latest outputs: full: ${{ steps.scope.outputs.full }} + focused: ${{ steps.scope.outputs.focused }} docs: ${{ steps.scope.outputs.docs }} governance: ${{ steps.scope.outputs.governance }} steps: @@ -52,7 +53,8 @@ jobs: --head-sha "${HEAD_SHA}" git diff --name-only -z "${BASE_SHA}" "${HEAD_SHA}" | python .github/scripts/classify_ci_scope.py \ - --null --github-output "${GITHUB_OUTPUT}" + --null --head-ref "${{ github.head_ref }}" \ + --github-output "${GITHUB_OUTPUT}" platform-matrix: name: MATLAB / ${{ matrix.label }} / ${{ matrix.release }} / ${{ matrix.shard }} @@ -272,6 +274,30 @@ jobs: with: tasks: docsCheck + focused-hotfix: + name: Focused hotfix validation + needs: change-scope + if: >- + github.event_name == 'pull_request' && + needs.change-scope.outputs.focused == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up clean MATLAB runtime + uses: matlab-actions/setup-matlab@v3 + with: + release: latest + + - name: Run changed-file evidence closure + uses: matlab-actions/run-build@v3 + with: + tasks: changedFast + ci-gate: name: CI Gate if: always() @@ -279,6 +305,7 @@ jobs: - change-scope - platform-matrix - docs-check + - focused-hotfix runs-on: ubuntu-latest steps: - name: Require all validation profiles @@ -289,6 +316,10 @@ jobs: [ '${{ needs.change-scope.outputs.full }}' = 'true' ]; then test '${{ needs.platform-matrix.result }}' = 'success' fi + if [ '${{ github.event_name }}' = 'pull_request' ] && + [ '${{ needs.change-scope.outputs.focused }}' = 'true' ]; then + test '${{ needs.focused-hotfix.result }}' = 'success' + fi if [ '${{ github.event_name }}' = 'pull_request' ] && { [ '${{ needs.change-scope.outputs.full }}' = 'true' ] || [ '${{ needs.change-scope.outputs.docs }}' = 'true' ]; }; then diff --git a/AGENTS.md b/AGENTS.md index 6b66c91a..6a0de89f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,11 @@ tests, history, and details out of the public repository. created from aligned `origin/main`. Keep it bounded to the repair, validate it like `develop`, and merge it only through a `hotfix/* -> main` PR. Ordinary features, maintenance, documentation, and CI work are not hotfixes. + Ordinary hotfixes retain the full matrix. Reserve + `hotfix/focused/` for a small product repair whose repository-owned + changed-file closure is sufficient. CI, Build, test-framework, tool, + resource, and dependency-governance changes cannot use that focused route + and automatically fall back to the full matrix. 5. Keep branch work stable with small logical purpose-based commits and focused validation. Prepare user docs, component versions, and structured history as the single net change that the PR will squash into; do not accumulate diff --git a/docs/development/maintain-and-release/testing.md b/docs/development/maintain-and-release/testing.md index 6d480482..4e3155ad 100644 --- a/docs/development/maintain-and-release/testing.md +++ b/docs/development/maintain-and-release/testing.md @@ -193,7 +193,17 @@ explicit report, not a duplicate CI gate. `main` is release-only and accepts pull requests from the repository-owned permanent `develop` branch or a bounded `hotfix/*` branch. The lightweight -policy job rejects every other PR source before MATLAB setup. It also compares +policy job rejects every other PR source before MATLAB setup. An ordinary +`hotfix/` retains complete compatibility validation. A deliberately +named `hotfix/focused/` may use one clean latest-MATLAB `changedFast` +job plus any required documentation check when its diff is limited to ordinary +product source, its owned specifications, and authored documentation. CI, +Build, test-catalog/framework, maintainer-tool, resource, and +dependency-governance paths make that branch ineligible and automatically +restore the full matrix. The focused namespace is an explicit review claim +about a bounded evidence closure, not a general hotfix exemption. + +The policy job also compares the PR base and head for App, facade, and launcher source ownership, direct semantic version steps, and matching component-history transitions. Branch protection separately rejects direct pushes, including administrator pushes. diff --git a/docs/history/records/2026/07/LK-20260730-focused-hotfix-validation.md b/docs/history/records/2026/07/LK-20260730-focused-hotfix-validation.md new file mode 100644 index 00000000..1bdf3600 --- /dev/null +++ b/docs/history/records/2026/07/LK-20260730-focused-hotfix-validation.md @@ -0,0 +1,70 @@ +# Focused hotfix validation + +```labkit-change +id: LK-20260730-focused-hotfix-validation +date: 2026-07-30 +sequence: 164 +type: ci +compatibility: compatible +scope: Explicit bounded CI route for small product hotfixes +``` + +## Context + +Every executable-source pull request previously scheduled the complete +cross-platform MATLAB matrix. That remains appropriate for ordinary +development and compatibility-sensitive hotfixes, but it repeated all seven +platform shards for small repairs whose repository-owned changed-file closure +already names the complete relevant evidence. + +## Decision and rationale + +Reserve `hotfix/focused/` for an explicit bounded-validation claim. An +eligible pull request runs `changedFast` in one clean latest-MATLAB Linux +environment and still runs the documentation check when authored docs change. +Ordinary hotfixes retain the full matrix. + +The focused route cannot include CI workflow or helper changes, Build logic, +test framework/catalog code, maintainer tools, resources, or dependency +governance. Any such path automatically restores the full matrix. This keeps +the branch name from becoming an unrestricted validation bypass. + +## Changes + +- Added a focused-hotfix result to CI path classification. +- Added one clean-runtime `changedFast` job and made `CI Gate` require it when + selected. +- Kept full-matrix fallback for ordinary hotfixes and ineligible focused + diffs. +- Added classifier and repository-architecture regression coverage. + +## User and data impact + +Small product repairs can reach protected `main` without paying for unrelated +platform sessions while still producing hosted evidence for their exact +changed-file closure. Product behavior, data, and scientific calculations are +unchanged. + +## Compatibility and migration + +Existing `develop` and `hotfix/` workflows are unchanged. Maintainers +choose the focused route only by using the reserved nested branch namespace. + +## Validation + +Python specifications cover eligible, ordinary, and forbidden focused +classifications. Repository architecture specifications cover the focused job +and aggregate gate. The governance change itself receives the complete matrix +before the focused route is used. + +## Evidence + +The classifier, CI workflow, Python tests, repository architecture +specifications, testing manual, and this record are the reviewable evidence. + +## Known limitations and follow-up + +Focused validation is intentionally not a cross-platform compatibility claim. +Reviewers must use an ordinary hotfix whenever the repair depends on native +dialogs, filesystems, graphics, packaging, external processes, or +release-specific behavior. diff --git a/tests/specs/system/repository/TestArchitectureSpec.m b/tests/specs/system/repository/TestArchitectureSpec.m index 912630f5..6c7bac78 100644 --- a/tests/specs/system/repository/TestArchitectureSpec.m +++ b/tests/specs/system/repository/TestArchitectureSpec.m @@ -81,7 +81,7 @@ function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) testCase.verifyEqual(count(workflow, ... "release: R2022b"), 2); testCase.verifyEqual(count(workflow, ... - "release: latest"), 6); + "release: latest"), 7); testCase.verifyEqual(count(workflow, ... "shard: All profiles"), 3); testCase.verifyEqual(count(workflow, ... @@ -124,6 +124,12 @@ function ciRoutesDocumentationWithoutWeakeningAggregateGate(testCase) "github.event_name == 'pull_request'"), 2); testCase.verifySubstring(workflow, ... "needs.platform-matrix.result"); + testCase.verifySubstring(workflow, ... + "needs.focused-hotfix.result"); + testCase.verifySubstring(workflow, ... + "tasks: changedFast"); + testCase.verifySubstring(workflow, ... + "needs.change-scope.outputs.focused"); testCase.verifySubstring(workflow, "ci-gate:"); testCase.verifySubstring(workflow, "name: CI Gate"); testCase.verifySubstring(workflow, "needs.change-scope.result");