diff --git a/.github/workflows/documentation-tool.yml b/.github/workflows/documentation-tool.yml new file mode 100644 index 0000000..e25ea5f --- /dev/null +++ b/.github/workflows/documentation-tool.yml @@ -0,0 +1,20 @@ +name: Documentation tool qualification +on: + pull_request: + paths: ['scripts/repository_docs.py', 'tests/test_repository_docs.py', '.github/workflows/documentation-tool.yml'] + push: + branches: [main] + paths: ['scripts/repository_docs.py', 'tests/test_repository_docs.py', '.github/workflows/documentation-tool.yml'] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: python3 -m pip install 'coverage>=7.6,<8' + - run: python3 -m coverage run --branch --source=scripts -m unittest discover -s tests -p test_repository_docs.py -v + - run: python3 -m coverage report --show-missing --fail-under=100 diff --git a/.github/workflows/repository-docs-shadow.yml b/.github/workflows/repository-docs-shadow.yml new file mode 100644 index 0000000..4ac1e82 --- /dev/null +++ b/.github/workflows/repository-docs-shadow.yml @@ -0,0 +1,22 @@ +name: Repository documentation shadow +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +jobs: + parity: + continue-on-error: true + runs-on: ubuntu-24.04 + timeout-minutes: 3 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: python3 -m pip install 'coverage>=7.6,<8' + - run: python3 -m coverage run --branch --include=scripts/repository_docs.py -m unittest discover -s tests -p test_repository_docs.py -v + - run: python3 -m coverage report --show-missing --fail-under=100 + - run: python3 scripts/repository_docs.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1e64c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.coverage diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..91656f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Shared CI repository + +Keep consumer builds and releases independent. Preserve existing required checks +and native merge queues. New fleet checks qualify in shadow before enforcement. +Use full commit pins and separate consumer upgrade/rollback PRs (JOV-6555). +Do not change credentials, branch protections, deployments or live controllers. + +For documentation tooling, read docs/DOCUMENTATION-PARITY.md and run its exact +coverage commands. Local consumer README/AGENTS and manifests remain repo-owned. +The older policy/POLICY.md and README distribution claims predate JOV-6555; +this task does not certify their planned workflows as implemented. + +## Repository documentation + +See [source parity](docs/DOCUMENTATION.md) and the generated +[source map](docs/REPOSITORY_SOURCES.md). Shared policy is pinned; local +manifest, build, test and release instructions remain owned by this repository. diff --git a/README.md b/README.md index 4057f6f..96a93e1 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,36 @@ # JovieInc/ci -Centralized, opinionated CI for all Jovie repositories — one set of reusable GitHub Actions workflows, shared composite actions, and a single strict policy for gates, distribution, and agent mediation. +Shared CI source for independently released company repositories. Consumers own +their build commands, tests, required checks, deployment and rollback. -**Principle:** update one workflow here → every repo benefits. Consumer repos keep only ~15-line caller stubs pinned to `@v1`. +## Implemented on this branch -## Layout -- `.github/workflows/` — reusable workflows (`on: workflow_call`) -- `actions/` — shared composite actions -- `policy/` — POLICY.md (branch rules, gate tiers, agent PR contract), risk-rules.yml, gitleaks.toml +- `.github/workflows/dependabot-auto-merge.yml`: callable dependency mediation. +- `actions/setup-node-pnpm`, `actions/setup-ios-build`, `actions/asc-api-key`: + composite actions; inspect their own inputs and prerequisites before use. +- `scripts/repository_docs.py`: offline manifest projection and pinned shared-file + parity. See [documentation parity](docs/DOCUMENTATION-PARITY.md) for tests, + scope, update procedure, shadow qualification and rollback. +- `policy/`: source policy, risk rules and secret-scanner configuration. -## Gate tiers -1. **Pre-PR (local/agent harness)** — typecheck, lint, affected unit tests. Never burn a runner on what the agent can run itself. -2. **PR (required)** — `rw-gate-fast` + risk classifier only. Deep gates run only when `rw-gate-risk` says risk ≥ standard. -3. **Merge queue** (`merge_group`) — build + E2E smoke on the queued batch. -4. **Post-merge** — deploy + canary + error gate, **fix-forward**: failures auto-file P1 + capped autofix; never retro-block the queue. -5. **Scheduled** — nightly full suite, security, CodeQL. +This list describes source, not consumer adoption or effective branch controls. +Previously described `rw-gate-fast`, `rw-gate-risk`, `rw-gate-deep` and +`rw-merge-queue` workflows are not present on this revision. JOV-2970/JOV-2975 +track shared CI; existing PRs retain ownership of their separate implementation. -## Versioning -Consumers pin `@v1` (moving major tag). Breaking changes bump major. Changes here run actionlint + smoke against consumer repos before tagging. +## Distribution -Tracked in Linear: JOV-2970 (epic), JOV-2975 (Phase 1). +JOV-6555 supersedes the old moving-`@v1` proposal: consumers use reviewed full +commit SHAs and separate upgrade PRs. A central commit does not automatically +change consumers. Qualify a candidate with relevant tests and a consumer canary, +then roll out independently; retain known-good pins for rollback. + +Existing consumer checks and release guarantees remain in force. New fleet +requirements qualify in shadow before enforcement. No sibling repo's healthy +build depends on every other consumer being green. + +## Repository documentation + +See [source parity](docs/DOCUMENTATION.md) and the generated +[source map](docs/REPOSITORY_SOURCES.md). Shared policy is pinned; local +manifest, build, test and release instructions remain owned by this repository. diff --git a/docs/DOCUMENTATION-PARITY.md b/docs/DOCUMENTATION-PARITY.md new file mode 100644 index 0000000..8eed3f8 --- /dev/null +++ b/docs/DOCUMENTATION-PARITY.md @@ -0,0 +1,64 @@ +# Repository documentation parity + +Decision (2026-09-24): COMPOSE Git-owned Markdown, existing repository freshness +checks, SHA-pinned file projections and repo-local manifests. Extend JOV-6555's +independent distribution boundary without replacing its shared-workflow work. + +The MIT-licensed `scripts/repository_docs.py` uses only Python 3.9+ stdlib. It +fetches nothing, executes no imports, needs no credentials and changes no service. +Consumers vendor the exact script and license using a full revision and SHA-256. +`repository-docs.json` names local entry points, manifest/workflow sources, output +and shared imports. Package facts are rendered directly; other sources have +fingerprints. A fingerprint is a review trigger, not proof of prose accuracy. + +## Update, verify and roll back + +1. Review the upstream commit and preserve the known-good consumer commit. +2. Update the selected import's full Git revision and SHA-256 in the registry. +3. From the reviewed tooling checkout run: + + ```sh + python3 scripts/repository_docs.py --root /path/to/consumer --write \ + --upstream JovieInc/ci=/path/to/ci \ + --upstream JovieInc/Jovie=/path/to/Jovie + ``` + +4. Review the diff and run the vendored `python3 scripts/repository_docs.py`. +5. Open an independently checked consumer PR. Revert that consumer commit to + roll back; sibling repos retain their pins and continue shipping. + +All source hashes are validated before writes. Only listed imports and the +projection are written. README and AGENTS stay locally owned. Without upstream +arguments the check proves offline parity to the declared pin, not freshness of +the pin or remote availability. Update-time Git readback proves source bytes. +After a local manifest/workflow edit, review its documentation and regenerate +with `python3 scripts/repository_docs.py --write` in the consumer. + +## Qualification + +Run `python3 -m coverage run --branch --source=scripts -m unittest discover -s tests -p test_repository_docs.py -v` +and `python3 -m coverage report --show-missing --fail-under=100`. +Tests cover source drift, absent entry points, path escape, bad pins, independent +upgrades and rollback. Consumer CI uses `continue-on-error` during shadow +qualification. Do not require it in branch protection. Promotion requires actual +ship samples, correctness/pass rate, p95 duration, throughput/cost and failure +isolation, an accountable owner and a tested rollback. No numeric promotion +threshold is asserted here. Existing gates remain enforced. + +## Adopt-first receipt + +- Preserve Jovie's existing `scripts/lib/doc-freshness.mjs` for local links and + computed markers, and JOV-6555 for pinned CI releases and consumer canaries. +- GitHub [reusable workflows and composite actions](https://docs.github.com/en/actions/concepts/workflows-and-actions/reusing-workflow-configurations) + are the maintained CI substrate; they do not render offline repo docs. +- [Copier template updates](https://copier.readthedocs.io/en/stable/generating/) + were evaluated but not adopted: whole-project templating is unnecessary for + a few shared files in independently maintained repositories. +- Custom code is limited to offline projection and exact-byte copy verification. + No new service catalog, platform, scheduler, updater bot or paid service. + Only reviewed non-secret policy and tool files cross repository boundaries. + Git and ordinary files preserve portability and independent rollback. + +Ship now: shadow checks in separate consumer PRs. Re-evaluate after two update +cycles with repeated drift or substantial manual work. Then extend the existing +updater under JOV-6555 rather than creating a second bot. diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md new file mode 100644 index 0000000..71163db --- /dev/null +++ b/docs/DOCUMENTATION.md @@ -0,0 +1,42 @@ +# Documentation ownership and parity + +The repository owns README/AGENTS, local manifests, runtime declarations and +release workflows. [The portable company contract](company/REPOSITORY_SOURCE_CONTRACT.md) +is shared source policy; it does not change the local stack or release gates. + +`repository-docs.json` selects source-owned entry points, local manifests and +workflows, generated output and immutable shared imports. The generated +[repository source map](REPOSITORY_SOURCES.md) renders package facts directly and +fingerprints other sources. Review related prose whenever a fingerprint changes. +This is source evidence, not proof of deployment, current credentials or runtime. + +From the repository root (Python 3.9+; no network or secrets): + +```sh +python3 scripts/repository_docs.py +python3 scripts/repository_docs.py --write +``` + +Use `--write` after reviewing local source changes. Shared-file edits belong in +the canonical repository named by each import, followed by a reviewed full Git +revision and SHA-256 update. From the reviewed JovieInc/ci tooling checkout: + +```sh +python3 scripts/repository_docs.py --root /path/to/this-repo --write \ + --upstream JovieInc/ci=/path/to/ci \ + --upstream JovieInc/Jovie=/path/to/Jovie +``` + +The default check verifies local copies against declared hashes offline. It does +not claim that upstream pins are latest. Update-time Git readback verifies the +exact upstream bytes. Only listed imports and generated output are written; +README/AGENTS remain local. Review changes in a separate PR per repository. +Revert the consumer commit to restore its previous pin; siblings remain usable. + +CI runs the same deterministic parity tests and coverage as shared tooling, then +checks the local projection in shadow. No new required gate or branch protection +is added. Existing required checks remain enforced. JOV-6607 tracks qualification; +JOV-6555 owns shared-CI release distribution and updater integration. Promote only +with representative ship samples, correctness/pass rate, p95, throughput/cost, +failure isolation, an accountable owner and tested rollback. No approved numeric +promotion threshold is implied. No scheduler, new bot or paid service is added. diff --git a/docs/REPOSITORY_SOURCES.md b/docs/REPOSITORY_SOURCES.md new file mode 100644 index 0000000..34561a0 --- /dev/null +++ b/docs/REPOSITORY_SOURCES.md @@ -0,0 +1,26 @@ +# Repository source map + +Generated by repository_docs.py; edit the source files and regenerate. +This is source evidence, not deployment or operational certification. + +Repository: `JovieInc/ci` + +## Source-owned entry points + +- `README.md` +- `AGENTS.md` +- `docs/DOCUMENTATION.md` + +## Manifest and workflow fingerprints + +A changed fingerprint requires review of its related documentation; it does not prove prose is accurate. + +- `.github/workflows/dependabot-auto-merge.yml` — Local manifest or workflow; review related instructions when changed (SHA-256 `432fca1d9fe7beb3dc78598971c321d5a7983d93d9fad2eb2f8ab6b5758139eb`) +- `.github/workflows/documentation-tool.yml` — Local manifest or workflow; review related instructions when changed (SHA-256 `e4212c1929eddfabcd48a6d0dfdebd00a7359640f77d447c764906d471f8350b`) +- `.github/workflows/repository-docs-shadow.yml` — Nonblocking parity qualification (SHA-256 `2b9ccbfcdef4df6d31e17ec1dbad5fffe82c01263af0c08b8b0481b31e7af48e`) + +## Pinned shared sources + +Updates are independent reviewable consumer changes. Local instructions remain source-owned. + +- `docs/company/REPOSITORY_SOURCE_CONTRACT.md` ← `JovieInc/Jovie@b193bc6a360b20df6933d45b4abefe0736c09493:docs/company/REPOSITORY_SOURCE_CONTRACT.md` (SHA-256 `f6bb8cdfdc317ed4ac3b99f8d2fc18a9647124c4fb807b1eef274892447b184f`) diff --git a/docs/company/REPOSITORY_SOURCE_CONTRACT.md b/docs/company/REPOSITORY_SOURCE_CONTRACT.md new file mode 100644 index 0000000..cfe3b47 --- /dev/null +++ b/docs/company/REPOSITORY_SOURCE_CONTRACT.md @@ -0,0 +1,38 @@ +# Company repository source contract + +Owner: company engineering / Tim White. Updated: 2026-09-24. +Scope: shared source/documentation boundaries, not product-specific configuration. + +Company canon is owned in Jovie's `canon/OPERATING_SYSTEM.md`, +`canon/ENGINEERING.md` and `canon/FLEET.md`. This portable projection summarizes +those boundaries for other repositories. Update this source first and distribute +reviewed immutable revisions; never overwrite a consumer's local instructions. + +- Each repository owns its README, AGENTS, manifests, workflows, dependencies, + tests, build, release, rollback and runtime declarations. Source code and real + manifests outrank stale prose. A common standard does not imply one stack. +- Company repositories ship independently under their current owners and gates. + A cross-repo hold requires fresh evidence of a specific shared bottleneck, + named owner, expiry and release condition. Concurrent CI alone is not evidence. +- Shared policy/tool copies record canonical repository, path, full Git revision + and content hash. Updates and rollback are independent consumer PRs. A pinned + copy is reproducible; it is not automatically the newest revision. +- Generated documentation is derived from local source. Review related prose + when source changes. Hash parity cannot establish semantic correctness. +- New or changed fleet delivery gates qualify in nonblocking shadow first. + Existing required checks remain enforced. Promotion needs representative actual + ships, correctness, pass rate, p95 duration, throughput/cost, failure isolation, + accountable ownership and tested rollback. Do not invent approved thresholds. +- Preserve meaningful tests and current coverage for executable changes. Clearly + distinguish source, local tests, hosted CI, review, native queue/merge, + deployment, observed runtime, persistence and customer outcome. +- Preserve active writers and the existing deployment/commissioning controller. + Documentation does not grant credentials, host changes, service activation, + external messaging, release-control changes or production mutation authority. +- Keep secrets, sessions, private memory, operational logs and host state out of + shared projections. Copy only reviewed non-secret source appropriate to the + destination's visibility. Retired instructions are history, never activation. + +Distribution mechanism: JovieInc/ci `docs/DOCUMENTATION-PARITY.md`, tracked by +JOV-6607 alongside JOV-6555. Existing repo-local freshness checks remain in place. +This source contract does not certify that a consumer implements any mechanism. diff --git a/policy/POLICY.md b/policy/POLICY.md index 0e1cd44..e1d3733 100644 --- a/policy/POLICY.md +++ b/policy/POLICY.md @@ -1,32 +1,31 @@ -# Jovie CI & Agent Delivery Policy (v1) +# Shared CI source policy -The single strict policy inherited by every Jovie repository. Mastered here; the -"Agent PR contract" section is synced verbatim into each repo's AGENTS.md. +Updated 2026-09-24. The old moving-major, universal gate-tier and automatic +AGENTS-sync claims were proposals, not implementation receipts. They are +superseded by the approved JOV-6555 independent pinned rollout contract. -## 1. Gate tiers -1. **Pre-PR (local/agent harness)** — typecheck, lint, affected unit tests via the repo's validate hook. Agents MUST pass these before opening a PR. Never burn a runner on what the agent can run itself. -2. **PR (required checks)** — `rw-gate-fast` (~5–8 min) + `rw-gate-risk` only. Deep gates run conditionally when risk ≥ standard. Everything else is advisory. -3. **Merge queue** — all merges to main go through the queue (`merge_group`); `rw-merge-queue` runs build + E2E smoke on the batch. Batch 5, timeout 30 min. -4. **Post-merge** — deploy + canary health + error gate with auto-rollback. **Fix-forward:** post-merge failures auto-file a P1 and dispatch capped autofix (max 3 attempts → `needs-human`); they never retro-block the queue. Revert is reserved for canary/error-gate failures. -5. **Scheduled** — nightly full suite, mutation tests, security, CodeQL. +- Full immutable commit-SHA pins; separate reviewed consumer upgrades and rollback. +- Preserve existing required-check names and real repo-specific test selectors. +- Validate the candidate against applicable representative fixtures and one actual + consumer canary. Unchanged pinned consumers remain independently usable. +- Each repo owns its runtime/toolchain, credentials, builds, releases and gates. + Do not copy another repo's Node/Python/Xcode requirements as company policy. +- Read-only untrusted PR validation is isolated from protected deployment/signing. + Use explicit least-privilege permissions and named secrets. +- Preserve native merge queue and existing gates. Applicable failed, missing or + unknown evidence cannot be presented as success. +- New delivery requirements qualify in nonblocking shadow before promotion, with + actual ship samples, correctness/pass rate, p95 duration, throughput/cost, + failure isolation, owner and tested rollback. No fixed new threshold is claimed. +- Publish a coherent draft early, then qualify the exact final head with required + tests, coverage and independent source review before promotion. +- Legacy `needs-human`, `human-review-required` and `no-auto` labels are not blanket + implementation/landing holds. Actual consequential-action authority still applies. -## 2. Risk filtering -`policy/risk-rules.yml` maps changed paths → low / standard / high. High-risk paths (auth, payments, migrations, token-vault, `.github/**`, public API) get the aggressive gates and a mandatory review label. Flaky or slow checks NEVER become required PR checks. +Shared source distribution must preserve local README/AGENTS instructions. The +[documentation parity procedure](../docs/DOCUMENTATION-PARITY.md) copies only +explicitly pinned files and generates a local source map. It neither rewrites a +consumer's whole AGENTS file nor establishes a fleet-wide enforced gate. -## 3. Branch & distribution rules -- Trunk-based: target `main` by default. -- `integration/` branches ONLY when: a feature spans >3 coordinated PRs, a churny shared area, or a mechanical agent sweep (codemods). The dispatching orchestrator owns the branch; it merges to main as one train via the queue. -- Concurrency: `cancel-in-progress: true` only for PR-scoped groups keyed by PR number. NEVER on main. NEVER on deploy/release workflows. - -## 4. Agent PR contract (synced into AGENTS.md) -1. Branch naming: `codex/*`, `claude/*`, `agent//*`, `*/jov--*`. -2. Target branch: `main` unless the orchestrator directs `integration/`. -3. Run the local validate hook pre-PR; never open a PR red on typecheck/lint/affected tests. -4. PR size ≤ 10 files / 400 lines unless labeled `sweep`. -5. Conventional commits; open as draft; the mediation pipeline flips to ready. -6. Never merge directly. Auto-merge only via the mediation pipeline. `needs-human` is a hard stop. -7. No force-push after entering the merge queue; rebase via the landing sweeper. -8. Labels: `automerge`, `needs-human`, `testing`, `sweep`. CI autofix is capped at 3 attempts per origin PR. - -## 5. Versioning -Consumers pin `@v1`. Breaking changes bump the major tag. Central-repo changes run actionlint + consumer smoke before tagging. +This policy does not mutate repository protections, rename required checks, +enable workflow consumers or authorize live runtime changes. diff --git a/repository-docs.json b/repository-docs.json new file mode 100644 index 0000000..1c1f58b --- /dev/null +++ b/repository-docs.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "repository": "JovieInc/ci", + "entrypoints": [ + "README.md", + "AGENTS.md", + "docs/DOCUMENTATION.md" + ], + "sources": [ + { + "path": ".github/workflows/dependabot-auto-merge.yml", + "purpose": "Local manifest or workflow; review related instructions when changed" + }, + { + "path": ".github/workflows/documentation-tool.yml", + "purpose": "Local manifest or workflow; review related instructions when changed" + }, + { + "path": ".github/workflows/repository-docs-shadow.yml", + "purpose": "Nonblocking parity qualification" + } + ], + "imports": [ + { + "repository": "JovieInc/Jovie", + "revision": "b193bc6a360b20df6933d45b4abefe0736c09493", + "path": "docs/company/REPOSITORY_SOURCE_CONTRACT.md", + "destination": "docs/company/REPOSITORY_SOURCE_CONTRACT.md", + "sha256": "f6bb8cdfdc317ed4ac3b99f8d2fc18a9647124c4fb807b1eef274892447b184f" + } + ], + "output": "docs/REPOSITORY_SOURCES.md" +} diff --git a/scripts/repository_docs.py b/scripts/repository_docs.py new file mode 100644 index 0000000..f390cd7 --- /dev/null +++ b/scripts/repository_docs.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Offline, repo-local documentation projection and pinned source parity (MIT).""" +import argparse +import hashlib +import json +import re +import subprocess +from pathlib import Path + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def local(root, name): + path = Path(name) + if path.is_absolute() or ".." in path.parts or not path.parts: + raise ValueError("unsafe repository path: " + name) + result = root / path + if root.resolve() not in (result.resolve(), *result.resolve().parents): + raise ValueError("path escapes repository: " + name) + return result.resolve() + + +def render(root, config, pending=None): + pending = pending or {} + lines = ["# Repository source map", "", "Generated by repository_docs.py; edit the source files and regenerate.", + "This is source evidence, not deployment or operational certification.", "", + "Repository: `" + config["repository"] + "`", "", "## Source-owned entry points", ""] + for entry in config["entrypoints"]: + path = local(root, entry) + if not path.is_file(): + raise ValueError("missing entry point: " + entry) + lines.append("- `" + entry + "`") + lines += ["", "## Manifest and workflow fingerprints", "", + "A changed fingerprint requires review of its related documentation; it does not prove prose is accurate.", ""] + for item in config["sources"]: + name = item["path"] + source = local(root, name) + data = pending[source] if source in pending else source.read_bytes() + lines += ["- `" + name + "` — " + item["purpose"] + " (SHA-256 `" + digest(data) + "`)"] + if name.endswith("package.json"): + manifest = json.loads(data) + for field in ("packageManager", "engines", "scripts", "dependencies", "devDependencies"): + if field in manifest: + lines += ["", "### `" + name + "`: " + field, "", "```json", json.dumps(manifest[field], indent=2, sort_keys=True), "```", ""] + lines += ["", "## Pinned shared sources", "", "Updates are independent reviewable consumer changes. Local instructions remain source-owned.", ""] + for item in config["imports"]: + lines.append("- `" + item["destination"] + "` ← `" + item["repository"] + "@" + item["revision"] + ":" + item["path"] + "` (SHA-256 `" + item["sha256"] + "`)") + return ("\n".join(lines) + "\n").encode() + + +def run(root, write=False, upstreams=None): + root = Path(root).resolve() + registry = local(root, "repository-docs.json") + config = json.loads(registry.read_text()) + if config["schemaVersion"] != 1 or not config["sources"] or not config["entrypoints"]: + raise ValueError("invalid documentation registry") + upstreams = upstreams or {} + pending, errors, destinations = [], [], set() + entrypoints = {local(root, entry) for entry in config["entrypoints"]} + output = local(root, config["output"]) + if output in entrypoints or output == registry or output in {local(root, i["path"]) for i in config["sources"]}: + raise ValueError("projection cannot overwrite a source or entry point") + # Validate every import before writing anything; never run upstream code. + for item in config["imports"]: + target = local(root, item["destination"]) + if target in destinations or target in entrypoints or target == output or target == registry: + raise ValueError("duplicate or reserved import destination") + destinations.add(target) + if not re.fullmatch(r"[0-9a-f]{40}", item["revision"]) or not re.fullmatch(r"[0-9a-f]{64}", item["sha256"]): + raise ValueError("imports require immutable revision and SHA-256") + # Validate the Git source path separately from the destination. + local(root, item["path"]) + source = upstreams.get(item["repository"]) + if source: + data = subprocess.run(["git", "-C", str(source), "show", item["revision"] + ":" + item["path"]], check=True, capture_output=True).stdout + if digest(data) != item["sha256"]: + raise ValueError("upstream content does not match pin: " + item["destination"]) + if write: + pending.append((target, data)) + elif not target.is_file() or target.read_bytes() != data: + errors.append("shared source drift: " + item["destination"]) + elif not target.is_file() or digest(target.read_bytes()) != item["sha256"]: + errors.append("pinned copy missing or changed: " + item["destination"]) + projection = render(root, config, dict(pending)) + if errors: + return errors + if write: + for target, data in pending + [(output, projection)]: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + elif not output.is_file() or output.read_bytes() != projection: + errors.append("source projection stale: " + config["output"]) + return errors + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--write", action="store_true") + parser.add_argument("--upstream", action="append", default=[], metavar="OWNER/REPO=CHECKOUT") + args = parser.parse_args() + try: + errors = run(args.root, args.write, dict(item.split("=", 1) for item in args.upstream)) + except (ValueError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as exc: + parser.exit(1, "repository-docs: " + str(exc) + "\n") + for error in errors: + print("repository-docs: " + error) + if errors: + return 1 + print("repository-docs: source parity verified (no runtime claim)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_repository_docs.py b/tests/test_repository_docs.py new file mode 100644 index 0000000..ba8fb61 --- /dev/null +++ b/tests/test_repository_docs.py @@ -0,0 +1,205 @@ +import importlib.util +import json +import subprocess +import tempfile +import runpy +from unittest.mock import patch +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location("repository_docs", Path(__file__).resolve().parents[1] / "scripts/repository_docs.py") +docs = importlib.util.module_from_spec(spec) +spec.loader.exec_module(docs) + + +class DocumentationParity(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / "README.md").write_text("Local owner instructions\n") + (self.root / "package.json").write_text(json.dumps({"packageManager": "pnpm@9.15.4", "engines": {"node": ">=24"}, "scripts": {"test": "vitest run"}, "dependencies": {"eve": "0.47.7"}})) + self.config = {"schemaVersion": 1, "repository": "example/app", "entrypoints": ["README.md"], "sources": [{"path": "package.json", "purpose": "Actual runtime"}], "imports": [], "output": "docs/SOURCES.md"} + self.save() + + def save(self): + (self.root / "repository-docs.json").write_text(json.dumps(self.config)) + + def test_source_change_detected_and_regeneration_preserves_local_instructions(self): + self.assertTrue(docs.run(self.root)) + self.assertEqual(docs.run(self.root, True), []) + self.assertEqual(docs.run(self.root), []) + before = (self.root / "README.md").read_bytes() + (self.root / "package.json").write_text('{"dependencies":{"eve":"1.0.0"}}') + self.assertTrue(docs.run(self.root)) + docs.run(self.root, True) + self.assertIn('"eve": "1.0.0"', (self.root / "docs/SOURCES.md").read_text()) + self.assertEqual((self.root / "README.md").read_bytes(), before) + + def test_missing_entrypoint_and_invalid_registry_fail(self): + (self.root / "README.md").unlink() + with self.assertRaises(ValueError): docs.run(self.root, True) + self.config["schemaVersion"] = 2 + self.save() + with self.assertRaises(ValueError): docs.run(self.root) + + def test_traversal_and_symlink_escape_rejected(self): + for name in ("../escape", "/tmp/escape", ""): + with self.assertRaises(ValueError): docs.local(self.root, name) + (self.root / "escape").symlink_to(self.root.parent, target_is_directory=True) + with self.assertRaises(ValueError): docs.local(self.root, "escape/out") + + def test_pinned_update_drift_and_independent_rollback(self): + upstream = self.root / "upstream" + upstream.mkdir() + def git(*args): + return subprocess.run(["git", "-C", str(upstream), *args], check=True, capture_output=True).stdout.decode().strip() + git("init") + git("config", "user.email", "test@example.invalid") + git("config", "user.name", "Test") + (upstream / "policy.md").write_text("first") + git("add", ".") + git("commit", "-m", "first") + first = git("rev-parse", "HEAD") + item = {"repository": "example/canon", "revision": first, "path": "policy.md", "destination": "company/policy.md", "sha256": docs.digest(b"first")} + self.config["imports"] = [item] + self.save() + self.assertTrue(docs.run(self.root)) + mapping = {"example/canon": upstream} + self.assertTrue(docs.run(self.root, upstreams=mapping)) + docs.run(self.root, True, mapping) + self.assertEqual(docs.run(self.root, upstreams=mapping), []) + (upstream / "policy.md").write_text("second") + git("commit", "-am", "second") + # An upstream change leaves the pinned consumer unchanged. + self.assertEqual(docs.run(self.root, upstreams=mapping), []) + item["revision"] = git("rev-parse", "HEAD") + self.save() + with self.assertRaises(ValueError): docs.run(self.root, True, mapping) + self.assertEqual((self.root / "company/policy.md").read_text(), "first") + item["sha256"] = docs.digest(b"second") + self.save() + docs.run(self.root, True, mapping) + self.assertEqual((self.root / "company/policy.md").read_text(), "second") + item.update(revision=first, sha256=docs.digest(b"first")) + self.save() + docs.run(self.root, True, mapping) + self.assertEqual((self.root / "company/policy.md").read_text(), "first") + item["revision"] = "main" + self.save() + with self.assertRaises(ValueError): docs.run(self.root) + + def test_duplicate_destinations_and_source_overwrite_rejected(self): + item = {"repository": "x/y", "revision": "a" * 40, "path": "p", "destination": "copy", "sha256": docs.digest(b"ok")} + (self.root / "copy").write_bytes(b"ok") + self.config["imports"] = [item, item] + self.save() + with self.assertRaises(ValueError): docs.run(self.root, True) + self.config["imports"] = [] + self.config["output"] = "package.json" + self.save() + with self.assertRaises(ValueError): docs.run(self.root, True) + + def test_entrypoints_and_aliases_cannot_be_overwritten(self): + original = (self.root / "README.md").read_bytes() + (self.root / "readme-alias").symlink_to("README.md") + for destination in ("README.md", "readme-alias"): + with self.subTest(destination=destination): + self.config["imports"] = [] + self.config["output"] = destination + self.save() + with self.assertRaises(ValueError): docs.run(self.root, True) + self.config["output"] = "docs/SOURCES.md" + self.config["imports"] = [{"repository": "x/y", "revision": "a" * 40, "path": "p", "destination": destination, "sha256": docs.digest(original)}] + self.save() + with self.assertRaises(ValueError): docs.run(self.root, True) + self.assertEqual((self.root / "README.md").read_bytes(), original) + self.assertFalse((self.root / "docs/SOURCES.md").exists()) + + def test_symlinked_registry_cannot_be_overwritten(self): + registry = self.root / "repository-docs.json" + registry.rename(self.root / "config.json") + registry.symlink_to("config.json") + (self.root / "config-alias").symlink_to("config.json") + for destination in ("repository-docs.json", "config.json", "config-alias"): + for writer in ("output", "import"): + with self.subTest(destination=destination, writer=writer): + self.config["output"] = destination if writer == "output" else "docs/SOURCES.md" + self.config["imports"] = [] if writer == "output" else [{"repository": "x/y", "revision": "a" * 40, "path": "p", "destination": destination, "sha256": docs.digest(b"replacement")}] + self.save() + before = registry.read_bytes() + with self.assertRaises(ValueError): docs.run(self.root, True) + self.assertEqual(registry.read_bytes(), before) + self.assertTrue(registry.is_symlink()) + self.assertFalse((self.root / "docs/SOURCES.md").exists()) + + def test_imported_sources_use_pending_bytes_before_any_mutation(self): + upstream = self.root / "upstream" + upstream.mkdir() + def git(*args): + return subprocess.run(["git", "-C", str(upstream), *args], check=True, capture_output=True).stdout.decode().strip() + git("init") + git("config", "user.email", "test@example.invalid") + git("config", "user.name", "Test") + old = (self.root / "package.json").read_bytes() + (upstream / "package.json").write_bytes(old) + git("add", ".") + git("commit", "-m", "old manifest") + first = git("rev-parse", "HEAD") + item = {"repository": "x/y", "revision": first, "path": "package.json", "destination": "package.json", "sha256": docs.digest(old)} + self.config["imports"] = [item] + self.save() + mapping = {"x/y": upstream} + docs.run(self.root, True, mapping) + original_projection = (self.root / "docs/SOURCES.md").read_bytes() + for content in (b'{"dependencies":{"eve":"2.0.0"}}', b'{'): + (upstream / "package.json").write_bytes(content) + git("commit", "-am", "update manifest") + item.update(revision=git("rev-parse", "HEAD"), sha256=docs.digest(content)) + self.save() + before = (self.root / "package.json").read_bytes() + projection = (self.root / "docs/SOURCES.md").read_bytes() + if content == b'{': + with self.assertRaises(ValueError): docs.run(self.root, True, mapping) + self.assertEqual((self.root / "package.json").read_bytes(), before) + self.assertEqual((self.root / "docs/SOURCES.md").read_bytes(), projection) + else: + self.assertEqual(docs.run(self.root, True, mapping), []) + self.assertEqual(docs.run(self.root), []) + self.assertIn('"eve": "2.0.0"', (self.root / "docs/SOURCES.md").read_text()) + item.update(revision=first, sha256=docs.digest(old)) + self.save() + self.assertEqual(docs.run(self.root, True, mapping), []) + self.assertEqual(docs.run(self.root), []) + self.assertEqual((self.root / "docs/SOURCES.md").read_bytes(), original_projection) + + def test_cli_entrypoint_and_non_json_source_are_measured(self): + self.config["sources"].append({"path": "README.md", "purpose": "Local guidance"}) + self.save() + argv = [str(Path(docs.__file__)), "--root", str(self.root)] + with patch("sys.argv", argv): + self.assertEqual(docs.main(), 1) + with patch("sys.argv", argv + ["--write"]): + self.assertEqual(docs.main(), 0) + with patch("sys.argv", argv): + with self.assertRaises(SystemExit) as result: + runpy.run_path(docs.__file__, run_name="__main__") + self.assertEqual(result.exception.code, 0) + with patch("sys.argv", argv + ["--upstream", "malformed"]): + with self.assertRaises(SystemExit) as result: + docs.main() + self.assertEqual(result.exception.code, 1) + + def test_cli_success_drift_and_malformed_config(self): + script = str(Path(docs.__file__)) + def cli(*args): + return subprocess.run(["python3", script, "--root", str(self.root), *args], capture_output=True) + self.assertEqual(cli().returncode, 1) + self.assertEqual(cli("--write").returncode, 0) + self.assertEqual(cli().returncode, 0) + (self.root / "repository-docs.json").write_text("{") + self.assertEqual(cli().returncode, 1) + + +if __name__ == "__main__": + unittest.main()