diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 92705286..21e21daa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,10 @@ updates: update-types: - minor - patch + commit-message: + prefix: "ci" + prefix-development: "ci" + include: "scope" - package-ecosystem: gomod directory: /controller @@ -25,6 +29,10 @@ updates: update-types: - minor - patch + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" - package-ecosystem: docker directory: /controller @@ -38,6 +46,10 @@ updates: update-types: - minor - patch + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" - package-ecosystem: docker directory: /runner @@ -51,3 +63,7 @@ updates: update-types: - minor - patch + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1ea37151..af41e70d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,8 +2,12 @@ name: Validate ci-fleet prototype on: pull_request: + # `edited` revalidates the title when a contributor retitles the PR: the + # required check would otherwise stay green for the same head SHA. + types: [opened, synchronize, reopened, edited] push: branches: [main] + tags: ['**'] workflow_dispatch: permissions: @@ -14,8 +18,103 @@ concurrency: cancel-in-progress: true jobs: + commit-convention: + name: Enforce conventional commits and pull-request title + if: ${{ github.event_name != 'push' || github.event.deleted == false }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Validate pull-request title + if: ${{ github.event_name == 'pull_request' }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + # The convention checks must run trusted code: a PR can edit its own + # copy of the validator, so extract it from the base revision when it + # exists there. This first PR bootstraps the gate before the script + # exists on main; until then the checkout copy is the only available + # implementation, and this same commit lands it so the next merge + # base contains it and later PRs are fully trusted. + validator="$RUNNER_TEMP/trusted-validator.py" + BASE_SHA="${{ github.event.pull_request.base.sha }}" + if git cat-file -e "$BASE_SHA:scripts/validate_commits.py" 2>/dev/null; then + git show "$BASE_SHA:scripts/validate_commits.py" >"$validator" + else + cp scripts/validate_commits.py "$validator" + fi + python3 "$validator" --pr-title "$PR_TITLE" + + - name: Validate proposed commit messages + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || '' }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + # Validate only commits proposed on this ref (base..head). Commits + # already on the base branch are never re-checked. The validator is + # shallow-clone safe: an empty base or empty range falls back to the + # head commit alone. For workflow_dispatch, validate only HEAD. + # + # Like the secret scanner below, the validator itself is extracted + # from the trusted base revision when available, so a push cannot + # pass these gates by editing its own copy of the script. This PR + # still needs its checkout copy until the validator lands on main. + # A newly created tag reports an all-zero `before`, which makes + # `git rev-list ..` fail and silently reduce validation + # to the tagged commit alone. Derive the real range start from the + # merge base with origin/main so every commit behind a branch-local + # (prerelease) tag is validated too. workflow_dispatch keeps its + # intentional HEAD-only behavior. + if [[ "$EVENT_NAME" == push && ( -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ) ]]; then + BASE_SHA="$(git merge-base "$HEAD_SHA" origin/main)" + fi + validator="$RUNNER_TEMP/trusted-validator.py" + if git cat-file -e "$BASE_SHA:scripts/validate_commits.py" 2>/dev/null; then + git show "$BASE_SHA:scripts/validate_commits.py" >"$validator" + elif [[ "$EVENT_NAME" != push ]]; then + cp scripts/validate_commits.py "$validator" + else + echo "trusted commit validator unavailable at '$BASE_SHA'" >&2 + exit 1 + fi + python3 "$validator" --base "$BASE_SHA" --head "$HEAD_SHA" + + - name: Validate release tags are SemVer 2.0.0 + if: ${{ startsWith(github.ref, 'refs/tags/') }} + env: + TAG_NAME: ${{ github.ref_name }} + TAG_COMMIT: ${{ github.sha }} + run: | + # Like the commit gates above, tag policy must run trusted code: a + # branch-local tagged commit could otherwise weaken its own tag + # validation by editing scripts/validate_commits.py. Extract the + # validator from the merge base with origin/main and fail closed if + # that trusted revision predates the validator. + validator="$RUNNER_TEMP/trusted-validator.py" + TRUSTED_SHA="$(git merge-base "$TAG_COMMIT" origin/main)" + if git cat-file -e "$TRUSTED_SHA:scripts/validate_commits.py" 2>/dev/null; then + git show "$TRUSTED_SHA:scripts/validate_commits.py" >"$validator" + else + echo "trusted tag validator unavailable at '$TRUSTED_SHA'" >&2 + exit 1 + fi + RELEASE_BASE="$(python3 "$validator" --release-base-for "$TAG_COMMIT" --exclude-tag "$TAG_NAME")" + # Stable tags must point into the main line (fetch-depth 0 gives us + # origin/main); prerelease tags must NOT point into it (they are + # branch-local per docs/CONTRIBUTING.md). + python3 "$validator" --version "$TAG_NAME" --tag-commit "$TAG_COMMIT" \ + --base "$RELEASE_BASE" --head "$TAG_COMMIT" + validate: name: Build without registering a runner + needs: commit-convention + if: ${{ !cancelled() && (github.event_name != 'push' || github.event.deleted == false) }} runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -32,7 +131,10 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | scanner=scripts/scan_committed_secrets.py - if [[ "$EVENT_NAME" == pull_request ]] && git cat-file -e "$BASE_SHA:$scanner" 2>/dev/null; then + if [[ "$EVENT_NAME" == push && ( -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ) ]]; then + BASE_SHA="$(git merge-base "$HEAD_SHA" origin/main)" + fi + if [[ -n "$BASE_SHA" ]] && git cat-file -e "$BASE_SHA:$scanner" 2>/dev/null; then git show "$BASE_SHA:$scanner" >"$RUNNER_TEMP/trusted-secret-scanner.py" scanner="$RUNNER_TEMP/trusted-secret-scanner.py" fi diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 00000000..3c03aaaa --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,142 @@ +# Contributing to ci-fleet + +This document defines the contributor contract that governs every commit and +pull request in `RandomDevelopment/ci-fleet`. It is mandatory for all authors +and agents editing this repository. + +## Release model + +- **Versioning**: [Semantic Versioning 2.0.0](https://semver.org/). Tags may use + an optional leading `v` (e.g. `v1.2.3`), but the version payload is always a + valid SemVer string. +- **Commit format**: [Conventional Commits 1.0.0](https://www.conventionalcommits.org/). + The project squash-merges to `main`, so the squash title is the canonical + release entry and the PR title must itself be a conventional subject. +- **Pre-1.0 / `0.y.z`**: the project is in initial development. A `0.y.z` + release has an unstable public API: anything may change at any time without + notice. PATCH is still permitted for pure internal fixes, but MINOR and + MAJOR carry no stability guarantee until a `1.0.0` is tagged. Do not invent + or publish a release merely to satisfy versioning rules; releases are gated + by `docs/CONTRIBUTING.md` and the operator review window below. +- **No force-push** of published history and no rebased rewrites of shared + branches. Use `git revert` for corrections. + +## Conventional Commits 1.0.0 + +``` +[optional scope][!]: +``` + +- The `` MUST be one of: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, + `perf`, `refactor`, `revert`, `style`, `test`. +- The `` is optional and nested in parentheses, e.g. `feat(runner):`. +- Append `!` before the colon to mark a breaking change. +- The `` is a single line; the complete subject (type, scope, + marker, separator, and description) is limited to <=100 characters and the + description begins with a lowercase letter (lowercase ASCII type + scope is + the convention; the subject itself may contain capitals for identifiers). +- Separate the subject from the body with exactly one blank line. +- Footers use `Token: value` form. A breaking change MAY also be declared with + a `BREAKING CHANGE:` footer (uppercase, per spec). + +### SemVer mapping + +| Commit | Bump | +| --- | --- | +| `fix:` or `perf:`, `refactor:`, `chore:`, `ci:`, `build:`, `style:`, `test:`, `docs:` (no `!`) | PATCH | +| `feat:` (no `!`) | MINOR | +| `feat!:`, `fix!:`, any `!`, or `BREAKING CHANGE:` footer | MAJOR | + +### Examples + +``` +feat: add capacity telemetry endpoint +fix(runner): close leak on job cancellation +docs: record five-minute CI shard contract +ci: enforce conventional commits and semantic versioning +perf: cache host capability lookup +refactor: de-duplicate reconcile drift detection +feat!: replace the legacy controller entrypoint +``` + +``` +fix(ci): stop recommending mutable image tags + +The previous guidance used `:latest`, which violates pinning requirements. + +Closes #42 +Reviewed-by: An Operator +``` + +``` +feat(controller): drop the legacy reconcile command + +BREAKING CHANGE: `install-worker-controller.sh --reconcile` is removed. +Operators must use `--upgrade` instead. +``` + +## Public API / compatibility contract + +The versioned public API of ci-fleet consists of the following stable +interfaces. A breaking change to any entry increments the MAJOR version. + +1. **Configuration schema** — `templates/config-repository/fleet.schema.json`, + `schema_version: 3`. Managed projects submit Git-authored desired state + validated against this schema. +2. **Task-plan schema** — `examples/project/scripts/ci/plan.schema.json`, + `schema_version: 1`. The matrix-expansion contract consumed by project + workflows. +3. **Status-report evidence format** — `schemas/status-report-v1.json`, + `schema_version: 1`. The format emitted by `scripts/status_receiver.py` and + consumed by health/monitoring tooling. +4. **Engine rollout evidence format** — + `templates/config-repository/engine-rollout-evidence.json`, + `schema_version: 1`. +5. **Installer command contract** — + `scripts/install-worker-controller.sh` with `--install`, `--adopt`, + `--check`, `--upgrade`, `--rollback`, `--uninstall` and the + `--config-repo`, `--ref`, `--controller` arguments. +6. **Host-role command contracts** — the systemd unit command lines under + `host/systemd/*`, including the cleanup, health, drift, and reconcile + timer/entry-point contracts. +7. **Generated task matrix** — the `include` output produced by + `.github/actions/plan/plan.py`, consumed by project workflow matrices. + +Non-API commits (docs, tests, CI, chore, style) never bump the public version +for API purposes; CI enforces PATCH-level change at minimum. + +## Release gate + +A version is released (tagged on `main`) only when: + +- the tagged commit passes all CI checks; +- the change set is reviewed and the SemVer bump matches the Conventional + Commits classification; +- an operator has confirmed the live pilot evidence for any engine rollout + evidence schema change. + +Do not tag a release to force a version number. This repository is pre-1.0; +avoid `1.0.0` until the controlled migration and compliance checklist +(`docs/COMPLIANCE-CHECKLIST.md`) are complete. + +## Prerelease and build metadata + +- Prerelease identifiers are supported by the validator but are not used for + `main`-sourced tags. Use `.0` patch sequences or branch-local tags only. +- Build metadata (`+build.`) is permitted on tags but MUST NOT affect + SemVer precedence ordering. + +## Validation + +`scripts/validate_commits.py` enforces the Conventional Commits grammar, the +SemVer validator, and a `--suggest-bump` helper. `scripts/test_validate_commits.py` +is the regression suite. Both run in CI (see the `commit-convention` job in +`.github/workflows/validate.yml`). + +Run locally: + +```bash +python3 scripts/test_validate_commits.py +python3 scripts/validate_commits.py --message - <<< 'feat: local example' +python3 scripts/validate_commits.py --version 0.1.0 +``` diff --git a/scripts/test_validate_commits.py b/scripts/test_validate_commits.py new file mode 100644 index 00000000..755e57b3 --- /dev/null +++ b/scripts/test_validate_commits.py @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +"""Regression tests for Conventional Commits 1.0.0 + SemVer 2.0.0 validation.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +# Allow direct execution from the repo root: scripts/test_validate_commits.py +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import validate_commits as vc # noqa: E402 + + +class ConventionalCommitHeaderTests(unittest.TestCase): + def assert_valid(self, subject: str) -> None: + self.assertEqual(vc.validate_message(subject), [], subject) + + def assert_invalid(self, subject: str) -> None: + self.assertTrue(vc.validate_message(subject), subject) + + def test_feat_passes(self) -> None: + self.assert_valid("feat: add capacity telemetry") + + def test_fix_passes(self) -> None: + self.assert_valid("fix: close bootstrap lifecycle races") + + def test_all_approved_types_pass(self) -> None: + for type_name in sorted(vc.ALLOWED_TYPES): + self.assert_valid(f"{type_name}: ordinary change") + + def test_scoped_type_passes(self) -> None: + self.assert_valid("feat(runner): add shard matrix expansion") + + def test_breaking_bang_passes(self) -> None: + self.assert_valid("feat!: replace runner lifecycle API") + + def test_scoped_breaking_passes(self) -> None: + self.assert_valid("fix(controller)!: drop legacy reconcile state") + + def test_body_and_footer_pass(self) -> None: + message = ( + "feat: add capacity telemetry\n" + "\n" + "The previous telemetry was best-effort. This adds deterministic\n" + "reporting keyed to the runner lifecycle.\n" + "\n" + "Closes #42\n" + "Reviewed-by: An Operator \n" + ) + self.assertEqual(vc.validate_message(message), []) + + def test_breaking_trailer_detected(self) -> None: + message = ( + "feat: drop the legacy reconcile entrypoint\n" + "\n" + "BREAKING CHANGE: the `legacy-reconcile` command is removed.\n" + "Operators must migrate to `reconcile`.\n" + ) + self.assertEqual(vc.validate_message(message), []) + self.assertIn("MAJOR", vc.bump_kind(message)) + + def test_capitalized_type_is_rejected(self) -> None: + self.assert_invalid("Fix: close bootstrap lifecycle races") + + def test_unknown_type_is_rejected(self) -> None: + self.assert_invalid("wip: half done thing") + + def test_missing_colon_is_rejected(self) -> None: + self.assert_invalid("feat add capacity telemetry") + + def test_missing_description_is_rejected(self) -> None: + self.assert_invalid("feat:") + + def test_space_after_colon_required(self) -> None: + # CC 1.0.0 requires a space after the colon. + self.assert_invalid("feat:missing-space") + + def test_excessively_long_subject_is_rejected(self) -> None: + self.assert_invalid("feat: " + "a" * 100) + + def test_merge_commit_is_exempt(self) -> None: + # Without a sha there is no parent proof, so a "Merge " prefix is not + # exempt on its own; with a real merge sha it is (see CliTests for the + # git-backed variants). + self.assertTrue(vc.validate_message("Merge pull request #77 from RandomDevelopment/docs/x")) + + def test_chore_deps_is_validated_by_grammar(self) -> None: + # Dependabot prefixes are configured in .github/dependabot.yml; the + # messages themselves must satisfy the normal grammar, not bypass it. + self.assertEqual(vc.validate_message("chore(deps): bump golang"), []) + self.assertTrue(vc.validate_message("CHORE(DEPS): Totally invalid")) + self.assertTrue(vc.validate_message("chore(deps): Bump golang")) + + def test_empty_message_is_rejected(self) -> None: + self.assertTrue(vc.validate_message("")) + + def test_body_without_blank_separator_is_rejected(self) -> None: + self.assertTrue(vc.validate_message("feat: add telemetry\nno blank line here")) + + def test_double_blank_separator_is_rejected(self) -> None: + self.assertTrue(vc.validate_message("feat: add telemetry\n\n\nbody here")) + + def test_breaking_detection_without_exclaim(self) -> None: + message = "feat: drop legacy command\n\nBREAKING CHANGE: removed\n" + bump = vc.bump_kind(message) + self.assertIsNotNone(bump, "expected a bump from a BREAKING CHANGE commit") + self.assertEqual(bump, "MAJOR") + + def test_revert_shaped_subject_without_plumbing_form_is_validated(self) -> None: + # A revert must use the approved `revert:` type; only git's own + # generated form (subject + "This reverts commit ." proof) is + # exempt, and the proof line alone is not enough. + self.assertTrue(vc.validate_message('Revert "skip validation"')) + self.assertEqual(vc.validate_message("revert: skip validation"), []) + + def test_git_generated_revert_form_with_body_proof_is_exempt(self) -> None: + # `git revert --no-edit` produces `Revert ""` with + # a `This reverts commit .` body line; docs/CONTRIBUTING.md + # instructs its use, so this exact pair is exempt. + message = ( + 'Revert "feat: add capacity telemetry"\n' + "\n" + "This reverts commit 1234567890abcdef1234567890abcdef12345678.\n" + ) + self.assertEqual(vc.validate_message(message), []) + self.assertIsNone(vc.bump_kind(message)) + + def test_revert_subject_without_body_proof_is_validated(self) -> None: + # The subject shape alone proves nothing: without the generated body + # line it must satisfy the normal grammar. + self.assertTrue(vc.validate_message('Revert "skip validation"')) + + def test_git_generated_merge_revert_is_exempt(self) -> None: + # `git revert -m 1 --no-edit ` produces a two-line proof + # ("This reverts commit , reversing" / "changes made to ."), + # so the proof line does not end with a period. + message = ( + 'Revert "Merge branch \'feature\'"\n' + "\n" + "This reverts commit 1234567890abcdef1234567890abcdef12345678, reversing\n" + "changes made to 1.\n" + ) + self.assertEqual(vc.validate_message(message), []) + self.assertIsNone(vc.bump_kind(message)) + + def test_revert_proof_line_without_generated_subject_is_validated(self) -> None: + message = ( + "revert: skip validation\n" + "\n" + "This reverts commit 1234567890abcdef1234567890abcdef12345678.\n" + ) + self.assertEqual(vc.validate_message(message), []) # conventional anyway + self.assertEqual( + vc.validate_message("not conventional\n\nThis reverts commit " + "1234567890abcdef1234567890abcdef12345678.\n"), + ["header is not conventional: 'not conventional'. " + "Expected '[scope][!]: ' from " + "['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', " + "'refactor', 'revert', 'style', 'test']"], + ) + + def test_breaking_marker_in_body_text_is_not_a_footer(self) -> None: + # A footer-shaped line directly after body text (no second blank-line + # separator) is body prose, not a footer. + message = ( + "feat: add guard rails\n" + "\n" + "Body text explaining the change.\n" + "BREAKING CHANGE: this line is really more body prose.\n" + ) + self.assertEqual(vc.bump_kind(message), "MINOR") + + def test_footer_after_body_still_counts(self) -> None: + message = ( + "feat: add guard rails\n" + "\n" + "Body text explaining the change.\n" + "\n" + "BREAKING CHANGE: the old flag is gone.\n" + ) + self.assertEqual(vc.bump_kind(message), "MAJOR") + + def test_git_trailer_first_then_breaking_change_counts(self) -> None: + # A footer block may begin with a conventional git trailer + # (lowercase-with-hyphen token); a later BREAKING CHANGE trailer in + # the same block still classifies MAJOR. + for opener in ( + "Reviewed-by: An Operator \n", + "Closes #42\n", + "Co-authored-by: Someone \n", + ): + message = ( + "feat: add guard rails\n" + "\n" + + opener + + "BREAKING CHANGE: the old flag is gone.\n" + ) + self.assertEqual(vc.validate_message(message), [], message) + self.assertEqual(vc.bump_kind(message), "MAJOR", message) + + +class PullRequestTitleTests(unittest.TestCase): + def test_conventional_pr_title_passes(self) -> None: + self.assertEqual(vc.validate_title("feat: add capacity telemetry"), []) + + def test_merge_pr_title_fails(self) -> None: + self.assertTrue(vc.validate_title("Merge branch 'main'")) + + def test_empty_pr_title_fails(self) -> None: + self.assertTrue(vc.validate_title("")) + + +class SemVerValidationTests(unittest.TestCase): + def test_valid_plain_version(self) -> None: + self.assertEqual(vc.validate_version("1.2.3"), []) + + def test_valid_zero_major_version(self) -> None: + self.assertEqual(vc.validate_version("0.1.0"), []) + + def test_valid_leading_v(self) -> None: + self.assertEqual(vc.validate_version("v1.0.0"), []) + + def test_valid_prerelease(self) -> None: + self.assertEqual(vc.validate_version("1.0.0-alpha"), []) + self.assertEqual(vc.validate_version("v1.0.0-alpha.1"), []) + self.assertEqual(vc.validate_version("1.0.0-alpha.beta.1"), []) + + def test_valid_build_metadata(self) -> None: + self.assertEqual(vc.validate_version("1.0.0+build.123"), []) + self.assertEqual(vc.validate_version("1.0.0-alpha+001"), []) + + def test_valid_numeric_identifier_prerelease(self) -> None: + self.assertEqual(vc.validate_version("1.0.0-0.3.7"), []) + + def test_invalid_leading_zero(self) -> None: + self.assertTrue(vc.validate_version("1.02.3")) + + def test_invalid_not_semver(self) -> None: + self.assertTrue(vc.validate_version("1.2")) + self.assertTrue(vc.validate_version("1.2.x")) + self.assertTrue(vc.validate_version("v1.2.3.4")) + + def test_unicode_digits_are_rejected(self) -> None: + # SemVer numeric identifiers are ASCII-only; Python's \d is not. + self.assertTrue(vc.validate_version("1.٢.3")) + self.assertIsNone(vc.parse_version("1.٢.3")) + + def test_ascii_digits_still_accepted(self) -> None: + self.assertEqual(vc.parse_version("1.22.3"), (1, 22, 3)) + + def test_invalid_empty(self) -> None: + self.assertTrue(vc.validate_version("")) + + def test_trailing_newline_is_rejected(self) -> None: + # '$' matches before a final newline; SemVer must match the exact string. + self.assertTrue(vc.validate_version("1.2.3\n")) + self.assertIsNone(vc.parse_version("v1.2.3\n")) + + def test_zero_major_detection(self) -> None: + self.assertTrue(vc.is_zero_major("0.1.0")) + self.assertTrue(vc.is_zero_major("0.0.1")) + self.assertFalse(vc.is_zero_major("1.0.0")) + + def test_parse_version(self) -> None: + self.assertEqual(vc.parse_version("1.2.3"), (1, 2, 3)) + self.assertEqual(vc.parse_version("v0.1.0"), (0, 1, 0)) + self.assertIsNone(vc.parse_version("not-a-version")) + + +class BumpSuggestionTests(unittest.TestCase): + def test_breaking_commit_suggests_major(self) -> None: + self.assertEqual( + vc.suggest_bump(["feat: add X", "fix!: break Y"]), + "MAJOR", + ) + + def test_feat_suggests_minor(self) -> None: + self.assertEqual(vc.suggest_bump(["feat: add X"]), "MINOR") + + def test_fix_suggests_patch(self) -> None: + self.assertEqual(vc.suggest_bump(["fix: repair Y"]), "PATCH") + + def test_refactor_suggests_patch(self) -> None: + self.assertEqual(vc.suggest_bump(["refactor: tidy Z"]), "PATCH") + + def test_merge_commit_does_not_force_bump(self) -> None: + self.assertEqual(vc.suggest_bump(["Merge pull request #1"]), "PATCH") + + def test_feet_and_fix_prefers_minor(self) -> None: + self.assertEqual(vc.suggest_bump(["fix: a", "feat: b"]), "MINOR") + + def test_empty_messages_suggests_patch(self) -> None: + self.assertEqual(vc.suggest_bump([]), "PATCH") + + +class CliTests(unittest.TestCase): + def _run(self, *args: str, cwd: str | None = None, **kwargs) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "validate_commits.py"), *args], + cwd=cwd or str(ROOT), capture_output=True, text=True, **kwargs, + ) + + def test_message_flag_valid(self) -> None: + result = self._run("--message", "-", input="feat: cli entry") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_message_flag_invalid(self) -> None: + result = self._run("--message", "-", input="not conventional at all") + self.assertNotEqual(result.returncode, 0) + + def test_version_flag_valid(self) -> None: + result = self._run("--version", "1.2.3") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_version_flag_invalid(self) -> None: + result = self._run("--version", "1.2") + self.assertNotEqual(result.returncode, 0) + + def test_pr_title_flag_valid(self) -> None: + result = self._run("--pr-title", "feat: cli pr") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_pr_title_flag_invalid(self) -> None: + result = self._run("--pr-title", "Random PR title") + self.assertNotEqual(result.returncode, 0) + + def test_explicitly_empty_flags_fail_closed(self) -> None: + # An explicitly empty value must be validated (and fail), not fall + # through to the default range validation. + for flag in ("--version", "--pr-title"): + result = self._run(flag, "") + self.assertNotEqual(result.returncode, 0, f"{flag} '' must fail") + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle: + handle.write("") + path = handle.name + try: + result = self._run("--message", path) + self.assertNotEqual(result.returncode, 0) + finally: + os.unlink(path) + + @staticmethod + def _git_env() -> dict[str, str]: + return { + **os.environ, + "GIT_AUTHOR_NAME": "ci-fleet", "GIT_AUTHOR_EMAIL": "ci-fleet@example.invalid", + "GIT_COMMITTER_NAME": "ci-fleet", "GIT_COMMITTER_EMAIL": "ci-fleet@example.invalid", + } + + def _init_repo(self, directory: str) -> None: + subprocess.run( + ["git", "init", "-b", "main", directory], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + def _commit(self, directory: str, message: str, allow_merge_failure: bool = False) -> str: + result = subprocess.run( + ["git", "-C", directory, "commit", "--allow-empty", "-m", message], + capture_output=True, text=True, env=self._git_env(), + ) + if result.returncode != 0 and allow_merge_failure: + # A single-parent commit cannot be created with merge semantics; + # callers use this to fake a merge-shaped subject. + raise AssertionError(f"unexpected commit failure: {result.stderr}") + return subprocess.run( + ["git", "-C", directory, "rev-parse", "HEAD"], check=True, + stdout=subprocess.PIPE, text=True, + ).stdout.strip() + + def _range_result(self, directory: str, base_sha: str, head_sha: str) -> subprocess.CompletedProcess: + return self._run("--base", base_sha, "--head", head_sha, cwd=directory) + + def test_range_validates_new_commits_only(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + base_sha = self._commit(directory, "Old non-conventional commit") + head_sha = self._commit(directory, "feat: new feature") + result = self._range_result(directory, base_sha, head_sha) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_range_fails_on_bad_new_commit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + base_sha = self._commit(directory, "Old non-conventional commit") + self._commit(directory, "Bad new commit") + head_sha = self._commit(directory, "feat: new feature") + result = self._range_result(directory, base_sha, head_sha) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Bad new commit", result.stderr) + + def test_true_merge_commit_is_exempt_by_parent_count(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + self._commit(directory, "feat: base commit") + subprocess.run( + ["git", "-C", directory, "checkout", "-b", "feature"], + check=True, stdout=subprocess.DEVNULL, + ) + self._commit(directory, "feat: feature work") + subprocess.run( + ["git", "-C", directory, "checkout", "main"], + check=True, stdout=subprocess.DEVNULL, + ) + self._commit(directory, "fix: mainline work") + result = subprocess.run( + ["git", "-C", directory, "merge", "--no-ff", "feature", + "-m", "Merge branch 'feature'"], + capture_output=True, text=True, env=self._git_env(), + ) + self.assertEqual(result.returncode, 0, result.stderr) + merge_sha = subprocess.run( + ["git", "-C", directory, "rev-parse", "HEAD"], check=True, + stdout=subprocess.PIPE, text=True, + ).stdout.strip() + self.assertTrue(vc.is_true_merge_commit(merge_sha, directory)) + self.assertEqual( + vc.validate_message("Merge branch 'feature'", sha=merge_sha, workspace=directory), + [], + ) + + def test_fake_merge_subject_with_single_parent_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + self._commit(directory, "feat: base commit") + fake_merge_sha = self._commit(directory, "Merge definitely not conventional") + self.assertFalse(vc.is_true_merge_commit(fake_merge_sha, directory)) + errors = vc.validate_message("Merge definitely not conventional", sha=fake_merge_sha) + self.assertTrue(errors, "single-parent 'Merge ...' commit must not be exempt") + + def test_fabricated_revert_reference_in_range_is_rejected(self) -> None: + # In range validation (sha + workspace available), the referenced + # commit must exist; a fabricated or all-zero reference is not proof. + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + self._commit(directory, "feat: base commit") + fabricated = ( + 'Revert "feat: never happened"\n' + "\n" + "This reverts commit ffffffffffffffffffffffffffffffffffffffff.\n" + ) + sha = self._commit(directory, fabricated) + result = self._range_result(directory, "HEAD", sha) + self.assertNotEqual( + result.returncode, 0, + "a revert referencing a nonexistent commit must be rejected", + ) + + def test_revert_of_unrelated_existing_commit_in_range_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + self._commit(directory, "chore: bootstrap") + referenced = self._commit(directory, "feat: referenced change") + forged = self._commit( + directory, + 'Revert "feat: referenced change"\n\nThis reverts commit ' + + referenced + ".", + ) + result = self._range_result(directory, referenced, forged) + self.assertNotEqual( + result.returncode, 0, + "a generated-looking message must actually reverse the reference", + ) + + def test_actual_git_revert_in_range_is_exempt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + path = Path(directory) / "value.txt" + path.write_text("before\n", encoding="utf-8") + subprocess.run(["git", "-C", directory, "add", "value.txt"], check=True) + self._commit(directory, "chore: bootstrap") + path.write_text("after\n", encoding="utf-8") + subprocess.run(["git", "-C", directory, "add", "value.txt"], check=True) + referenced = self._commit(directory, "feat: change value") + reverted = subprocess.run( + ["git", "-C", directory, "revert", "--no-edit", referenced], + check=True, capture_output=True, text=True, env=self._git_env(), + ) + self.assertEqual(reverted.returncode, 0) + revert_sha = subprocess.run( + ["git", "-C", directory, "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + result = self._range_result(directory, referenced, revert_sha) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_actual_git_revert_of_root_commit_in_range_is_exempt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + root_path = Path(directory) / "root.txt" + root_path.write_text("root\n", encoding="utf-8") + subprocess.run(["git", "-C", directory, "add", "root.txt"], check=True) + root_sha = self._commit(directory, "feat: root change") + base_sha = self._commit(directory, "chore: retain history") + subprocess.run( + ["git", "-C", directory, "revert", "--no-edit", root_sha], + check=True, capture_output=True, text=True, env=self._git_env(), + ) + revert_sha = subprocess.run( + ["git", "-C", directory, "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + result = self._range_result(directory, base_sha, revert_sha) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_empty_base_validates_head_commit_only(self) -> None: + # workflow_dispatch path: empty base must not enumerate all history. + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + self._commit(directory, "Old non-conventional commit") + head_sha = self._commit(directory, "feat: new feature") + result = self._run("--base", "", "--head", head_sha, cwd=directory) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_stable_tag_must_reach_main(self) -> None: + # A stable (non-prerelease) tag must point into the main line. + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + main_sha = self._commit(directory, "feat: base commit") + subprocess.run( + ["git", "-C", directory, "branch", "origin/main"], + check=True, stdout=subprocess.DEVNULL, + ) + self.assertTrue(vc.is_ancestor(main_sha, "origin/main", directory)) + head_sha = self._commit(directory, "feat: feature work") + # HEAD is not on origin/main: stable fails, prerelease passes. + result = self._run("--version", "v1.2.3", "--tag-commit", head_sha, cwd=directory) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not reachable", result.stderr) + result = self._run("--version", "v1.2.3-rc.1", "--tag-commit", head_sha, cwd=directory) + self.assertEqual(result.returncode, 0, result.stderr) + result = self._run("--version", "v1.2.3+build.7", "--tag-commit", head_sha, cwd=directory) + self.assertNotEqual(result.returncode, 0) + # And a stable tag pointing into main passes. + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + main_sha = self._commit(directory, "feat: base commit") + subprocess.run( + ["git", "-C", directory, "branch", "origin/main"], + check=True, stdout=subprocess.DEVNULL, + ) + result = self._run("--version", "v0.2.0", "--tag-commit", main_sha, cwd=directory) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_prerelease_tag_on_main_is_rejected(self) -> None: + # docs/CONTRIBUTING.md: prereleases are not used for `main`-sourced + # tags. A prerelease tag pointing into the main line must fail even + # though its ancestry branch is skipped for branch-local tags. + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + main_sha = self._commit(directory, "feat: base commit") + subprocess.run( + ["git", "-C", directory, "branch", "origin/main"], + check=True, stdout=subprocess.DEVNULL, + ) + result = self._run("--version", "v0.2.0-rc.1", "--tag-commit", main_sha, cwd=directory) + self.assertNotEqual(result.returncode, 0) + self.assertIn("prerelease", result.stderr) + + def test_tag_ancestry_error_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + head_sha = self._commit(directory, "feat: branch release") + result = self._run( + "--version", "v0.2.0-rc.1", "--tag-commit", head_sha, + "--main-ref", "refs/heads/missing", cwd=directory, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unable to evaluate ancestry", result.stderr) + + def test_required_bump_enforced_for_tag(self) -> None: + # Once a prior release exists, a new tag must implement at least the + # bump its commit range requires (feat! => MAJOR; patch tag fails). + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + base_sha = self._commit(directory, "chore: bootstrap") + subprocess.run( + ["git", "-C", directory, "tag", "v0.1.0"], + check=True, + ) + self._commit(directory, "feat!: break contract") + head_sha = self._commit(directory, "fix: follow-up") + subprocess.run( + ["git", "-C", directory, "branch", "origin/main"], + check=True, + ) + subprocess.run( + ["git", "-C", directory, "branch", "-f", "origin/main", head_sha], + check=True, + ) + result = self._run( + "--version", "v0.1.1", + "--tag-commit", head_sha, + "--base", base_sha, + "--head", head_sha, + cwd=directory, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("MAJOR", result.stderr) + + def test_required_bump_uses_release_base_for_prior_tag(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + base_sha = self._commit(directory, "chore: bootstrap") + subprocess.run( + ["git", "-C", directory, "tag", "v0.1.0"], check=True, + ) + head_sha = self._commit(directory, "fix: patch release") + subprocess.run( + ["git", "-C", directory, "branch", "origin/main", head_sha], + check=True, + ) + subprocess.run( + ["git", "-C", directory, "tag", "v0.1.1", head_sha], check=True, + ) + result = self._run( + "--version", "v0.1.1", + "--tag-commit", head_sha, + "--base", base_sha, + "--head", head_sha, + cwd=directory, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_release_base_ignores_intervening_nonrelease_tags(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + self._commit(directory, "chore: bootstrap") + subprocess.run(["git", "-C", directory, "tag", "v0.1.0"], check=True) + self._commit(directory, "feat!: break contract") + subprocess.run(["git", "-C", directory, "tag", "v0.2.0-rc.1"], check=True) + subprocess.run(["git", "-C", directory, "tag", "not-a-release"], check=True) + head_sha = self._commit(directory, "fix: follow-up") + subprocess.run( + ["git", "-C", directory, "branch", "origin/main", head_sha], check=True, + ) + subprocess.run(["git", "-C", directory, "tag", "v0.1.1", head_sha], check=True) + base = self._run( + "--release-base-for", "HEAD", "--exclude-tag", "v0.1.1", cwd=directory, + ) + self.assertEqual(base.returncode, 0, base.stderr) + self.assertEqual(base.stdout.strip(), "v0.1.0") + result = self._run( + "--version", "v0.1.1", "--tag-commit", head_sha, + "--base", base.stdout.strip(), "--head", head_sha, + cwd=directory, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("MAJOR", result.stderr) + + def test_required_bump_resets_lower_components(self) -> None: + for message, version in ( + ("feat: add capability", "v1.3.9"), + ("feat!: replace contract", "v2.7.9"), + ): + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + self._init_repo(directory) + base_sha = self._commit(directory, "chore: bootstrap") + subprocess.run( + ["git", "-C", directory, "tag", "v1.2.7"], check=True, + ) + head_sha = self._commit(directory, message) + result = vc.check_required_bump(version, base_sha, head_sha, directory) + self.assertTrue(result, f"{version} must reset lower components") + + +class LowercaseDescriptionTests(unittest.TestCase): + """docs/CONTRIBUTING.md requires descriptions to begin with a lowercase letter.""" + + def test_uppercase_description_is_rejected(self) -> None: + self.assertTrue(vc.validate_message("feat: Add endpoint")) + self.assertTrue(vc.validate_title("feat: Add endpoint")) + + def test_double_space_after_colon_is_rejected(self) -> None: + self.assertTrue(vc.validate_message("feat: add endpoint")) + self.assertTrue(vc.validate_title("feat: add endpoint")) + + def test_capitals_after_first_word_are_allowed(self) -> None: + self.assertEqual(vc.validate_message("feat: retain the GitHub App token"), []) + + def test_uppercase_scope_is_rejected(self) -> None: + # docs/CONTRIBUTING.md: lowercase ASCII scope. + self.assertTrue(vc.validate_message("feat(Runner): add x")) + self.assertTrue(vc.validate_title("feat(Runner): add x")) + + def test_spaces_in_scope_are_rejected(self) -> None: + self.assertTrue(vc.validate_message("feat(a b): add x")) + self.assertTrue(vc.validate_title("feat(a b): add x")) + + def test_empty_scope_is_rejected(self) -> None: + self.assertTrue(vc.validate_message("feat( ): add x")) + self.assertTrue(vc.validate_message("feat(): add x")) + + def test_lowercase_scopes_still_pass(self) -> None: + for subject in ( + "feat(runner): add shard expansion", + "fix(ci-runner): close leak", + "ci(gha2): bump pin", + "feat(runner/shard): expand matrix", + ): + self.assertEqual(vc.validate_message(subject), [], subject) + self.assertEqual(vc.validate_title(subject), [], subject) + + +class BreakingChangeSynonymTests(unittest.TestCase): + """CC 1.0.0 defines BREAKING-CHANGE as synonymous with BREAKING CHANGE.""" + + def test_hyphenated_footer_is_valid_and_breaking(self) -> None: + message = "feat: replace api surface\n\nBREAKING-CHANGE: incompatible now\n" + self.assertEqual(vc.validate_message(message), []) + self.assertEqual(vc.bump_kind(message), "MAJOR") + + def test_plain_footer_is_still_valid_and_breaking(self) -> None: + message = "feat: replace api surface\n\nBREAKING CHANGE: incompatible now\n" + self.assertEqual(vc.validate_message(message), []) + self.assertEqual(vc.bump_kind(message), "MAJOR") + + +class BumpMarkerTests(unittest.TestCase): + """Only the grammar's breaking marker (!) may classify MAJOR, not any '!'.""" + + def test_exclamation_in_description_is_not_major(self) -> None: + self.assertEqual(vc.bump_kind("fix: preserve the ! operator"), "PATCH") + self.assertEqual(vc.bump_kind("feat: keep the ! operator"), "MINOR") + + def test_marker_after_scope_is_major(self) -> None: + self.assertEqual(vc.bump_kind("fix(controller)!: break contract"), "MAJOR") + + +class Python37CompatibilityTests(unittest.TestCase): + """The validator promises Python 3.7+; walrus syntax breaks it at parse time.""" + + def test_no_walrus_operator_in_validator(self) -> None: + source = vc.__file__ + with open(source, encoding="utf-8") as handle: + content = handle.read() + self.assertNotIn(":=", content) + + +class ExistingHistoryComplianceTests(unittest.TestCase): + """Every post-migration commit on this branch conforms. The pre-migration + history (commits up to and including 'Fix installer drift check lint', + c600165) predates the convention and is exempt; the migration boundary is + identified by date so the test does not depend on commit ordering.""" + + PRE_MIGRATION_CUTOFF = "2026-07-18 17:22:14 -0500" + # Pre-convention subjects that landed after the cutoff via squash merges + # (they were authored before the convention existed and are grandfathered). + GRANDFATHERED_SUBJECTS = { + "Fix controller access to root-owned app key", + "Prevent manager validation bytecode drift", + "Add fleet-wide host health monitoring (#42)", + } + + def test_head_commits_on_branch_conform(self) -> None: + result = subprocess.run( + ["git", "-C", str(ROOT), "rev-list", "--reverse", + "--after=" + self.PRE_MIGRATION_CUTOFF, "HEAD"], + capture_output=True, text=True, check=True, + ) + commits = [sha for sha in result.stdout.splitlines() if sha] + failures = [] + for sha in commits: + message = subprocess.run( + ["git", "-C", str(ROOT), "log", "-1", "--format=%B", sha], + capture_output=True, text=True, check=True, + ).stdout.rstrip("\n") + # Merge commits are exempt by policy (verified by parent count); + # a handful of pre-convention subjects (e.g. "Fix controller + # access to root-owned app key", "Prevent manager validation + # bytecode drift") landed after the cutoff via squash merges and + # are grandfathered here. + header = message.splitlines()[0] + if vc.MERGE_RE.match(header) or header in self.GRANDFATHERED_SUBJECTS: + continue + errors = vc.validate_message(message) + if errors: + failures.append(f"{sha[:8]} {message.splitlines()[0]}: {errors}") + self.assertEqual( + failures, [], + "post-migration HEAD commits must conform to Conventional Commits 1.0.0", + ) + + def test_post_migration_boundary_is_conventional(self) -> None: + # The first post-cutoff commit (the convention migration itself) must + # already conform, proving the cutoff sits at the right place. + result = subprocess.run( + ["git", "-C", str(ROOT), "rev-list", "--reverse", "--after=" + + self.PRE_MIGRATION_CUTOFF, "HEAD"], + capture_output=True, text=True, check=True, + ) + first_new = next(sha for sha in result.stdout.splitlines() if sha) + message = subprocess.run( + ["git", "-C", str(ROOT), "log", "-1", "--format=%B", first_new], + capture_output=True, text=True, check=True, + ).stdout.rstrip("\n") + self.assertEqual(vc.validate_message(message), []) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test_workflow_tag_validation.py b/scripts/test_workflow_tag_validation.py new file mode 100644 index 00000000..ca48d894 --- /dev/null +++ b/scripts/test_workflow_tag_validation.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Regression tests for the tag-validation step in .github/workflows/validate.yml. + +Finding 3861004945: the release-tag step must run the validator extracted from +a trusted revision (the merge base with origin/main), never the tagged-tree +copy, so a branch-local commit cannot weaken its own tag policy. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "validate.yml" + + +class TrustedTagValidatorTests(unittest.TestCase): + def setUp(self) -> None: + self.text = WORKFLOW.read_text(encoding="utf-8") + + def _tag_step(self) -> str: + match = re.search( + r"- name: Validate release tags are SemVer 2\.0\.0\n(.*?)(?=\n [a-z-]+:|\Z)", + self.text, + re.DOTALL, + ) + self.assertIsNotNone(match, "release-tag validation step not found") + return match.group(0) + + def test_tag_step_does_not_run_the_tagged_tree_validator(self) -> None: + step = self._tag_step() + self.assertNotIn( + "python3 scripts/validate_commits.py", + step, + "the tag step must not execute the tagged-tree copy of the validator", + ) + self.assertNotIn( + "cp scripts/validate_commits.py", + step, + "a tag push must fail when the trusted revision lacks the validator", + ) + + def test_tag_step_uses_trusted_base_extraction(self) -> None: + step = self._tag_step() + self.assertIn("trusted-validator.py", step) + self.assertIn("merge-base", step) + self.assertIn("git show", step) + + def test_push_commit_validation_uses_the_trusted_base(self) -> None: + step = self.text.split("- name: Validate proposed commit messages", 1)[1] + step = step.split("- name: Validate release tags", 1)[0] + self.assertNotIn('[[ "$EVENT_NAME" == pull_request ]] && git cat-file', step) + self.assertIn('git show "$BASE_SHA:scripts/validate_commits.py"', step) + self.assertIn('elif [[ "$EVENT_NAME" != push ]]; then', step) + self.assertIn("trusted commit validator unavailable", step) + + def test_tag_step_passes_the_release_range(self) -> None: + step = self._tag_step() + self.assertIn( + 'RELEASE_BASE="$(python3 "$validator" --release-base-for "$TAG_COMMIT" ' + '--exclude-tag "$TAG_NAME")"', + step, + ) + self.assertIn('--base "$RELEASE_BASE" --head "$TAG_COMMIT"', step) + + def test_new_tag_secret_scan_uses_a_finite_range(self) -> None: + scanner = self.text.split("- name: Scan every proposed commit for secrets", 1)[1] + self.assertIn('BASE_SHA="$(git merge-base "$HEAD_SHA" origin/main)"', scanner) + + def test_tag_secret_scan_uses_the_trusted_scanner(self) -> None: + scanner = self.text.split("- name: Scan every proposed commit for secrets", 1)[1] + self.assertNotIn('[[ "$EVENT_NAME" == pull_request ]] && git cat-file', scanner) + self.assertIn('git show "$BASE_SHA:$scanner"', scanner) + + def test_validation_runs_after_failure_but_stops_on_cancellation(self) -> None: + validate_job = self.text.split("\n validate:\n", 1)[1] + condition = validate_job.split(" steps:", 1)[0] + self.assertIn("!cancelled()", condition) + self.assertNotIn("always()", condition) + + def test_tag_deletion_skips_both_validation_jobs(self) -> None: + guard = "github.event_name != 'push' || github.event.deleted == false" + convention_job = self.text.split("\n commit-convention:\n", 1)[1] + convention_job = convention_job.split(" steps:", 1)[0] + validate_job = self.text.split("\n validate:\n", 1)[1].split(" steps:", 1)[0] + self.assertIn(guard, convention_job) + self.assertIn(guard, validate_job) + + def test_repository_validation_runs_this_suite(self) -> None: + validation = (ROOT / "scripts" / "validate.sh").read_text(encoding="utf-8") + self.assertIn("python3 scripts/test_workflow_tag_validation.py", validation) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/validate.sh b/scripts/validate.sh index 9660aaa2..fa1fb157 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -22,6 +22,8 @@ python3 scripts/test_desired_state.py python3 scripts/test_health.py python3 scripts/test_status_receiver.py python3 scripts/test_quickstart.py +python3 scripts/test_validate_commits.py +python3 scripts/test_workflow_tag_validation.py python3 -m json.tool schemas/status-report-v1.json >/dev/null python3 scripts/desired_state.py validate-engine-capabilities --manifest engine-capabilities.json --require-status-reporting-config --require-status-reporting >/dev/null python3 .github/actions/plan/plan.py --plan examples/project/scripts/ci/plan.json --group fast >/dev/null diff --git a/scripts/validate_commits.py b/scripts/validate_commits.py new file mode 100644 index 00000000..4fef974e --- /dev/null +++ b/scripts/validate_commits.py @@ -0,0 +1,686 @@ +#!/usr/bin/env python3 +"""Conventional Commits 1.0.0 + Semantic Versioning 2.0.0 enforcement for ci-fleet. + +This validator covers the contributor-facing commit/PR-title contract. It is +intentionally dependency-free (stdlib only) and deterministic so it can run in +any environment that has Python 3.7+. + +Responsibilities: + * validate one or more commit messages against Conventional Commits 1.0.0 + * validate a PR title as a single conventional commit subject + * validate SemVer 2.0.0 version strings (with optional leading "v") + * suggest the next SemVer bump from a commit range (release gate helper) + +It does NOT write or publish versions. The project is pre-1.0 (0.y.z); see +docs/CONTRIBUTING.md for the release gate and the meaning of 0.y.z. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from typing import Iterable + +# --------------------------------------------------------------------------- +# Conventional Commits 1.0.0 grammar +# +# [optional scope][!]: +# +# body? (blank line separator) +# footer* (token: value, or "BREAKING CHANGE: ...") +# +# The project-approved type set (lowercase, as used across the existing history): +ALLOWED_TYPES = frozenset({ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", +}) + +# A scope is optional and nested in parentheses. docs/CONTRIBUTING.md requires +# lowercase ASCII scope components; allow hyphens, underscores, and digits +# inside a component but never uppercase letters or spaces. +SCOPE = r"[a-z0-9_-]+(?:/[a-z0-9_-]+)*" +SUBJECT = r"^(.{1,100})$" +FOOTER_TOKEN = r"[A-Z][A-Z0-9_]+" +FOOTER_VALUE = r"[^\n]+" +# "BREAKING CHANGE:" and "BREAKING-CHANGE:" must be exactly uppercase (CC 1.0.0). +BREAKING_HEADER = "BREAKING CHANGE:" +BREAKING_HEADER_ALT = "BREAKING-CHANGE:" +# Trailers use the "Token: value" form. +FOOTER_LINE = re.compile(rf"^{FOOTER_TOKEN}: {FOOTER_VALUE}$") + +# Full conventional-commit header regex (not multiline; applied per message). +# Requires exactly one space after the colon and a description beginning with +# a lowercase letter, per CC 1.0.0 and docs/CONTRIBUTING.md. +CONVENTIONAL_HEADER = re.compile( + r"^(" + "|".join(sorted(ALLOWED_TYPES)) + r")(?:\(" + SCOPE + r"\))?" + r"(!)?: [a-z].*$", + re.UNICODE, +) + +# Git trailers (e.g. "Reviewed-by: ...", "Signed-off-by: ...") and the +# BREAKING CHANGE / BREAKING-CHANGE trailer. Trailers are optional. +# +# Trailer tokens follow git's own grammar (trailing-attrs): a token of three +# or more alphanumerics with an inner hyphen permitted, followed by ": ". +# This accepts conventional trailers like "Reviewed-by" and "Co-authored-by" +# that the uppercase-only FOOTER_TOKEN class would reject, so a footer block +# beginning with them is still recognized as footers by has_breaking_change(). +TRAILER_TOKEN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]") +TRAILER_RE = re.compile( + r"^(?:" + TRAILER_TOKEN_RE.pattern + r"(?:: | #)" + FOOTER_VALUE + r"|" + + re.escape(BREAKING_HEADER) + r" " + FOOTER_VALUE + r"|" + + re.escape(BREAKING_HEADER_ALT) + r" " + FOOTER_VALUE + r")$" +) + +# Git-generated plumbing commits are exempt: they are produced by git itself, +# not authored per the contract, and existing base-branch commits are never +# re-checked here. +# +# A real merge is verified by parent count (see is_true_merge_commit); the +# subject prefix alone only exempts git's own generated revert form +# `Revert ""`, where is a full 40-hex commit id. Any other +# "Revert ..." shape (including `Revert ""` with arbitrary text) is +# ordinary authored content and must use the approved `revert:` type. +MERGE_RE = re.compile(r"^Merge ", re.IGNORECASE) +# Git's own generated revert subject is `Revert ""` with a +# body containing `This reverts commit <40-hex sha>.` (see git-revert(1) and +# the revert instruction in docs/CONTRIBUTING.md). Exempt only that exact +# subject/body pair: the body line carries the proof, so an authored +# `Revert "..."` subject without it still goes through normal validation and +# must use the approved `revert:` type. +REVERT_SUBJECT_RE = re.compile(r'^Revert ".+"$') +# The generated proof line ends with a period for ordinary reverts; for +# merge reverts (`git revert -m 1`) it ends with ", reversing" and is +# followed by a "changes made to ." continuation line. +REVERT_BODY_PROOF_RE = re.compile( + r"^This reverts commit ([0-9a-fA-F]{40})(?:\.$|, reversing$)" +) + +def is_true_merge_commit(sha: str, workspace: str = ".") -> bool: + """Return True if the commit is a true merge commit (has 2+ parents).""" + result = subprocess.run( + ["git", "-C", workspace, "rev-list", "--parents", "-n", "1", sha], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if result.returncode != 0: + return False + parts = result.stdout.strip().split() + # First part is the commit SHA, rest are parents + return len(parts) >= 3 # commit SHA + at least 2 parents + +# --------------------------------------------------------------------------- +# Semantic Versioning 2.0.0 +# +# ..[-][+] +# major, minor, patch are non-negative integers without leading zeroes. +# prerelease: dot-separated identifiers of [0-9A-Za-z-]. +# build: dot-separated identifiers of [0-9A-Za-z-]. +# +# An optional leading "v" is permitted as a tag decoration; the version payload +# itself must be valid SemVer. +SEMVER_RE = re.compile( + r"^v?" + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) + + +def is_semver(value: str) -> bool: + """Return True when `value` is a valid SemVer 2.0.0 string.""" + return SEMVER_RE.fullmatch(value) is not None + + +def parse_version(value: str) -> tuple[int, int, int] | None: + """Return (major, minor, patch) for a valid SemVer string, else None.""" + match = SEMVER_RE.fullmatch(value) + if match is None: + return None + major, minor, patch = match.group(1), match.group(2), match.group(3) + return int(major), int(minor), int(patch) + + +def is_zero_major(version: str) -> bool: + """0.y.z means initial development with an unstable API.""" + parsed = parse_version(version) + if parsed is None: + return False + return parsed[0] == 0 + + +def referenced_revert_commit(message: str) -> str | None: + """Return the 40-hex sha from git's generated revert proof line, if any. + + Matches both the ordinary form (`....`) and the merge-revert form + (`..., reversing`). + """ + match = REVERT_BODY_PROOF_RE.match + for line in message.splitlines()[1:]: + found = match(line) + if found: + return found.group(1) + return None + + +def referenced_commit_exists(sha: str, workspace: str = ".") -> bool: + """Return True when `sha` resolves to a commit object in `workspace`.""" + result = subprocess.run( + ["git", "-C", workspace, "cat-file", "-e", sha + "^{commit}"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + return result.returncode == 0 + + +def reverses_commit(revert_sha: str, referenced_sha: str, workspace: str = ".") -> bool: + """Return True when `revert_sha` applies the inverse of `referenced_sha`.""" + def parents(sha: str) -> list[str]: + result = subprocess.run( + ["git", "-C", workspace, "rev-list", "--parents", "-n", "1", sha], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + return result.stdout.strip().split()[1:] if result.returncode == 0 else [] + + revert_parents = parents(revert_sha) + referenced_parents = parents(referenced_sha) + if len(revert_parents) != 1: + return False + if not referenced_parents: + empty_tree = subprocess.run( + ["git", "-C", workspace, "hash-object", "-t", "tree", "--stdin"], + input=b"", stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + if empty_tree.returncode != 0: + return False + referenced_parents = [empty_tree.stdout.decode().strip()] + + target = subprocess.run( + ["git", "-C", workspace, "rev-parse", revert_sha + "^{tree}"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ).stdout.strip() + for referenced_parent in referenced_parents: + patch = subprocess.run( + ["git", "-C", workspace, "diff", "--binary", referenced_parent, referenced_sha], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + with tempfile.NamedTemporaryFile() as index: + env = {**os.environ, "GIT_INDEX_FILE": index.name} + read_tree = subprocess.run( + ["git", "-C", workspace, "read-tree", revert_parents[0]], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, + ) + applied = subprocess.run( + ["git", "-C", workspace, "apply", "--cached", "--reverse"], + input=patch.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, + ) + if read_tree.returncode != 0 or applied.returncode != 0: + continue + tree = subprocess.run( + ["git", "-C", workspace, "write-tree"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, + ) + if tree.returncode == 0 and tree.stdout.strip() == target: + return True + return False + + +def is_git_generated_revert( + message: str, *, sha: str | None = None, workspace: str = ".", + verify_reference: bool = False, +) -> bool: + """Return True only for git's own generated revert form. + + Requires the `Revert ""` subject AND the + `This reverts commit .` proof line in the body, matching what + `git revert` produces (docs/CONTRIBUTING.md instructs its use). + + With verify_reference=True the referenced commit must actually exist in + the repository; this closes the fabricated-reference bypass in range + validation where a workspace is always available. + """ + lines = message.splitlines() + if not lines or not REVERT_SUBJECT_RE.match(lines[0]): + return False + if not any(REVERT_BODY_PROOF_RE.match(line) for line in lines[1:]): + return False + if verify_reference: + referenced = referenced_revert_commit(message) + # An all-zero reference is never a real commit object. + if ( + referenced is None + or set(referenced) == {"0"} + or not referenced_commit_exists(referenced, workspace) + or sha is None + or not reverses_commit(sha, referenced, workspace) + ): + return False + return True + + +def bump_kind(message: str) -> str | None: + """Classify a single conventional commit for SemVer bump selection. + + Returns "MAJOR", "MINOR", or "PATCH", or None when the commit is not a + conventional change (e.g. a merge or refactor-only commit carries no bump). + """ + header = message.splitlines()[0] if message else "" + if is_git_generated_revert(message): + return None + if not is_conventional_header(header): + return None + # Check for explicit breaking marker "!" after type/scope (e.g., "feat!: ...") + match = CONVENTIONAL_HEADER.match(header) + if match and match.group(2): # group(2) is the breaking marker "!" + return "MAJOR" + if has_breaking_change(message): + return "MAJOR" + if header.split(":", 1)[0].split("(")[0] == "feat": + return "MINOR" + return "PATCH" + + +def suggest_bump(messages: Iterable[str]) -> str: + """Recommend a SemVer bump from a list of conventional commit messages.""" + kinds = [] + for message in messages: + k = bump_kind(message) + if k: + kinds.append(k) + if not kinds: + return "PATCH" + if "MAJOR" in kinds: + return "MAJOR" + if "MINOR" in kinds: + return "MINOR" + return "PATCH" + + +def is_conventional_header(header: str) -> bool: + """Validate a single commit subject line against the conventional grammar.""" + if not header or len(header) > 100: + return False + return bool(CONVENTIONAL_HEADER.match(header)) + + +def has_breaking_change(message: str) -> bool: + """Detect a BREAKING CHANGE or BREAKING-CHANGE footer (case-sensitive per spec).""" + if not message: + return False + lines = message.splitlines() + # The footer block is separated from the subject/body by a blank line and + # begins with a trailer ("Token: value"); footer values may span + # continuation lines until the next trailer. A "BREAKING CHANGE:"-shaped + # line glued to body text without that separator is body prose, not a + # footer. + try: + last_blank = len(lines) - 1 - lines[::-1].index("") + except ValueError: + return False + footer = lines[last_blank + 1:] + if not footer or not TRAILER_RE.match(footer[0]): + return False + return any( + line.startswith(BREAKING_HEADER) or line.startswith(BREAKING_HEADER_ALT) + for line in footer + if TRAILER_RE.match(line) + ) + + +def validate_message(message: str, *, skip_merge: bool = True, sha: str | None = None, workspace: str = ".") -> list[str]: + """Validate one commit message against Conventional Commits 1.0.0. + + Returns a list of human-readable error strings (empty == valid). + """ + errors: list[str] = [] + if not message or not message.strip(): + errors.append("message is empty") + return errors + + lines = message.splitlines() + header = lines[0] + + if skip_merge: + if is_git_generated_revert( + message, sha=sha, workspace=workspace, verify_reference=bool(sha), + ): + # Only git's own generated revert form (subject + body proof line) + # is exempt; anything else must be conventional. When a sha is + # available (range validation), the referenced commit must exist, + # so an authored commit cannot forge the proof with a fabricated + # reference. + return errors + if MERGE_RE.match(header): + # "Merge " prefix alone is not proof: a single-parent commit can be + # named anything. Only true merges (2+ parents, verified via sha) + # are exempt. Without a sha we cannot verify, so do not exempt. + if sha and is_true_merge_commit(sha, workspace): + return errors + + if not header or not is_conventional_header(header): + errors.append( + f"header is not conventional: '{header}'. " + f"Expected '[scope][!]: ' from {sorted(ALLOWED_TYPES)}" + ) + return errors + + # Body, if present, must follow the header after exactly one blank line. + if len(lines) > 1 and lines[1].strip() != "": + errors.append("header must be followed by a blank line before the body") + elif len(lines) > 2 and lines[2].strip() == "": + # lines[1] is blank and so is lines[2]: more than one separator line. + errors.append("exactly one blank line must separate the header from the body") + + return errors + + +def validate_title(title: str) -> list[str]: + """Validate a PR title (treated as a single conventional subject).""" + if not title: + return ["PR title is empty"] + if REVERT_SUBJECT_RE.match(title) or MERGE_RE.match(title): + return ["PR title must be a conventional commit subject, not a merge/plumbing title"] + if not is_conventional_header(title): + return [f"PR title is not conventional: '{title}'"] + return [] + + +def is_ancestor(commit: str, ancestor: str, workspace: str = ".") -> bool: + """Return True when `commit` is reachable from `ancestor` (or equal to it).""" + result = subprocess.run( + ["git", "-C", workspace, "merge-base", "--is-ancestor", commit, ancestor], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if result.returncode not in (0, 1): + raise RuntimeError(f"unable to evaluate ancestry: {result.stderr.strip()}") + return result.returncode == 0 + + +def validate_version(value: str) -> list[str]: + """Validate a SemVer 2.0.0 version string (optional leading 'v').""" + if is_semver(value): + return [] + return [f"not a valid SemVer 2.0.0 version: '{value}'"] + + +def check_required_bump( + version: str, base: str, head: str, workspace: str = ".", +) -> list[str]: + """Reject `version` when it does not implement the required SemVer bump. + + The required bump is computed from the conventional classification of the + release range base..head (docs/CONTRIBUTING.md release gate: "the SemVer + bump matches the Conventional Commits classification"). With no prior + release tag, any valid SemVer satisfies the pre-1.0 gate. + ponytail: compares only major.minor.patch; prerelease/build metadata of + the candidate is ignored, upgrade if tag-vs-range metadata ever matters. + """ + parsed = parse_version(version) + if parsed is None: + return [] + prior = latest_release_tag(workspace, base) + if prior is None: + return [] + required = suggest_bump(msg for _, msg in commit_messages(base, head, workspace=workspace)) + candidate = parse_version(version) + assert candidate is not None and prior is not None + old_major, old_minor, old_patch = prior + new_major, new_minor, new_patch = candidate + + def _bumped(level: str) -> bool: + """True when the candidate implements exactly `level` over prior.""" + if level == "MAJOR": + return new_major > old_major and (new_minor, new_patch) == (0, 0) + if level == "MINOR": + # MINOR keeps major and increases minor (0.y.z included). + return new_major == old_major and new_minor > old_minor and new_patch == 0 + # PATCH: same major.minor, higher patch. + return (new_major, new_minor) == (old_major, old_minor) and new_patch > old_patch + + satisfied = _bumped(required) + if not satisfied: + return [ + f"version '{version}' does not implement the required {required} " + f"bump over released '{prior[0]}.{prior[1]}.{prior[2]}' for range " + f"{base}..{head}" + ] + return [] + + +def git_revision_list(base: str | None, head: str, *, workspace: str = ".") -> list[str]: + """Return commit SHAs in the range base..head (shallow-clone safe). + + Falls back to single-commit resolution when git range semantics are not + available (shallow clones, single-commit histories). + For workflow_dispatch (empty base), validate only the HEAD commit. + """ + if base and base.strip(): + spec = f"{base}..{head}" + else: + # Empty base (workflow_dispatch): validate only HEAD + spec = f"{head}^..{head}" + result = subprocess.run( + ["git", "-C", workspace, "rev-list", "--reverse", spec], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + commits = [line for line in result.stdout.splitlines() if line.strip()] + if not commits: + # Shallow clone or range resolved to nothing: resolve the head alone. + resolved = subprocess.run( + ["git", "-C", workspace, "rev-parse", head], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + sha = resolved.stdout.strip() + if sha and re.fullmatch(r"[0-9a-f]{40}", sha): + commits = [sha] + return commits + + +def commit_message(workspace: str, sha: str) -> str: + """Return the raw commit message for `sha` (subject on line 0).""" + result = subprocess.run( + ["git", "-C", workspace, "log", "-1", "--format=%B", sha], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + # git log --format=%B ends with a newline; strip the trailing newline. + return result.stdout.rstrip("\n") + + +def commit_messages( + base: str | None, + head: str, + *, + workspace: str = ".", +) -> list[tuple[str, str]]: + """Return the full commit messages for every commit in base..head. + + Returns a list of (sha, message) tuples. + + Falls back to resolving the head SHA alone when the range is empty + (shallow clones, single-commit histories). + """ + commits = git_revision_list(base, head, workspace=workspace) + return [(sha, commit_message(workspace, sha)) for sha in commits] + + +def latest_release( + workspace: str = ".", main_ref: str = "origin/main", exclude_tag: str | None = None, +) -> tuple[tuple[int, int, int], str] | None: + """Return the highest stable SemVer and tag reachable from main_ref. + + Stable means no prerelease component (prereleases are branch-local and + never releases per docs/CONTRIBUTING.md). Returns None when no release + tag exists yet. + """ + result = subprocess.run( + ["git", "-C", workspace, "tag", "--list", "--merged", main_ref], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if result.returncode != 0: + return None + best: tuple[tuple[int, int, int], str] | None = None + for tag in result.stdout.split(): + if tag == exclude_tag: + continue + match = SEMVER_RE.match(tag) + if match is not None and match.group(4) is None: + parsed = parse_version(tag) + assert parsed is not None + key = (parsed, tag) + if best is None or key[0] > best[0]: + best = (parsed, tag) + return best + + +def latest_release_tag(workspace: str = ".", main_ref: str = "origin/main") -> tuple[int, int, int] | None: + """Return the highest released stable SemVer reachable from main_ref.""" + release = latest_release(workspace, main_ref) + return release[0] if release else None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base", default=os.environ.get("BASE_SHA"), + help="base SHA or ref (the commits already on the target branch). " + "Commits in base..head are validated; base commits are not re-checked.", + ) + parser.add_argument( + "--head", default=os.environ.get("HEAD_SHA", "HEAD"), + help="head SHA or ref to validate (default: HEAD).", + ) + parser.add_argument( + "--pr-title", default=os.environ.get("PR_TITLE"), + help="validate a PR title as a single conventional commit subject", + ) + parser.add_argument( + "--message", default=None, + help="validate a single explicit commit message (read from file when '-' is given)", + ) + parser.add_argument( + "--version", default=None, + help="validate a SemVer 2.0.0 version string (optional leading v)", + ) + parser.add_argument( + "--tag-commit", default=os.environ.get("TAG_COMMIT"), + help="commit the version tag points at; with --version, a stable " + "(non-prerelease) tag must point into the main line (MAIN_REF, " + "default 'origin/main')", + ) + parser.add_argument( + "--main-ref", default=os.environ.get("MAIN_REF", "origin/main"), + help="ref representing the main line for stable-tag reachability", + ) + parser.add_argument( + "--release-base-for", default=None, + help="print the highest stable SemVer tag reachable from this ref", + ) + parser.add_argument( + "--exclude-tag", default=None, + help="exclude this tag when selecting --release-base-for", + ) + parser.add_argument( + "--suggest-bump", action="store_true", + help="print the recommended SemVer bump from the base..head range", + ) + args = parser.parse_args() + + failures: list[str] = [] + + if args.release_base_for is not None: + release = latest_release(".", args.release_base_for, args.exclude_tag) + if release: + print(release[1]) + return 0 + + if args.version is not None: + failures.extend(validate_version(args.version)) + if not failures and args.tag_commit is not None: + match = SEMVER_RE.match(args.version) + assert match is not None + on_main = is_ancestor(args.tag_commit, args.main_ref) + if match.group(4) is not None: + # docs/CONTRIBUTING.md reserves prerelease identifiers for + # branch-local tags; they are never main-sourced. + if on_main: + failures.append( + f"prerelease tag '{args.version}' points at a commit " + f"reachable from '{args.main_ref}'; prereleases are " + "branch-local only (docs/CONTRIBUTING.md)" + ) + elif not on_main: + # Stable tags must point into the main line. + failures.append( + f"stable tag '{args.version}' points at a commit that is " + f"not reachable from '{args.main_ref}'; stable versions " + "are tagged on main (prereleases may stay branch-local)" + ) + if ( + not failures + and args.tag_commit is not None + and args.base is not None + and args.head + ): + failures.extend(check_required_bump(args.version, args.base, args.head)) + if failures: + for failure in failures: + print(f"version: {failure}", file=sys.stderr) + return 1 + print(f"OK: '{args.version}' is a valid SemVer 2.0.0 version") + return 0 + + if args.message is not None: + message = sys.stdin.read() if args.message == "-" else open(args.message, encoding="utf-8").read() + failures.extend(validate_message(message)) + if failures: + for failure in failures: + print(f"commit: {failure}", file=sys.stderr) + return 1 + print("OK: conventional commit message") + return 0 + + if args.pr_title is not None: + failures.extend(validate_title(args.pr_title)) + if failures: + for failure in failures: + print(f"pr title: {failure}", file=sys.stderr) + return 1 + print("OK: conventional PR title") + return 0 + + if args.suggest_bump: + messages = [msg for _, msg in commit_messages(args.base, args.head)] + print(f"bump: {suggest_bump(messages)}") + return 0 + + # Default: validate every commit in base..head. + commits = commit_messages(args.base, args.head) + if not commits: + print("no commits found to validate; pass --base/--head or --message", file=sys.stderr) + return 1 + + for sha, message in commits: + errors = validate_message(message, sha=sha, workspace=".") + for error in errors: + print(f"{sha[:8]}: {error}", file=sys.stderr) + failures.append(error) + + if failures: + return 1 + print(f"OK: {len(commits)} commits conform to Conventional Commits 1.0.0") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())