Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions .github/scripts/classify_ci_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import sys
from pathlib import PurePosixPath

FOCUSED_HOTFIX_PREFIX = "hotfix/focused/"


def normalize(path: str) -> str:
normalized = path.strip().replace("\\", "/")
Expand Down Expand Up @@ -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:
Expand All @@ -58,14 +81,19 @@ 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()


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:
Expand Down
47 changes: 42 additions & 5 deletions .github/scripts/test_classify_ci_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -272,13 +274,38 @@ 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()
needs:
- change-scope
- platform-matrix
- docs-check
- focused-hotfix
runs-on: ubuntu-latest
steps:
- name: Require all validation profiles
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<topic>` 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
Expand Down
12 changes: 11 additions & 1 deletion docs/development/maintain-and-release/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` retains complete compatibility validation. A deliberately
named `hotfix/focused/<name>` 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<name>` 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/<name>` 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.
8 changes: 7 additions & 1 deletion tests/specs/system/repository/TestArchitectureSpec.m
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...
Expand Down Expand Up @@ -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");
Expand Down