diff --git a/repository-branch-protection-drift-guard/README.md b/repository-branch-protection-drift-guard/README.md new file mode 100644 index 00000000..58b61461 --- /dev/null +++ b/repository-branch-protection-drift-guard/README.md @@ -0,0 +1,35 @@ +# Repository Branch Protection Drift Guard + +This module is a focused Project Repository & Version Control slice for issue +#10. It audits whether protected scientific repository branches still enforce +the release controls needed before DOI, citation, or export-bundle actions. + +It uses synthetic data only and has no external service dependencies. + +## Checks + +- Missing required status checks for reproducibility, citation metadata, and + export manifest validation. +- Reduced review-count requirements on protected release branches. +- Signed-commit enforcement drift. +- Force-push or deletion settings enabled on protected branches. +- Admin bypass enabled without an active exception ticket. +- Export bundles targeting branches with unresolved protection drift. + +## Local Verification + +```bash +node repository-branch-protection-drift-guard/test.js +node repository-branch-protection-drift-guard/demo.js +``` + +Demo artifacts are written to +`repository-branch-protection-drift-guard/reports/`. + +## Issue #10 Mapping + +This complements repository version control, merge requests, release tagging, +reproducibility checks, citations, and export bundles without overlapping the +broader repository ledger, merge queue, component-owner approval, semantic tag, +external-reference pinning, notebook diff, fork provenance, release signature, +restore rehearsal, compute sandbox, retention/legal-hold, or embargo guards. diff --git a/repository-branch-protection-drift-guard/branchProtectionGuard.js b/repository-branch-protection-drift-guard/branchProtectionGuard.js new file mode 100644 index 00000000..ae6f5cfe --- /dev/null +++ b/repository-branch-protection-drift-guard/branchProtectionGuard.js @@ -0,0 +1,161 @@ +const DEFAULT_REQUIRED_CHECKS = [ + "reproducibility-pipeline", + "citation-metadata", + "export-manifest" +]; + +function arrayDifference(required, actual) { + const actualSet = new Set(actual || []); + return required.filter((item) => !actualSet.has(item)); +} + +function addFinding(findings, severity, id, message, action, evidence = {}) { + findings.push({ severity, id, message, action, evidence }); +} + +function evaluateBranchProtection(repository, policy = {}) { + const requiredChecks = policy.requiredChecks || DEFAULT_REQUIRED_CHECKS; + const minReviews = policy.minApprovingReviews || 2; + const findings = []; + + for (const branch of repository.branches || []) { + if (!branch.releaseCandidate) { + continue; + } + + const missingChecks = arrayDifference(requiredChecks, branch.requiredStatusChecks); + if (missingChecks.length > 0) { + addFinding( + findings, + "block", + `missing-status-checks:${branch.name}`, + `Release branch ${branch.name} is missing required status checks.`, + "Restore the required release status checks before citation or export actions.", + { branch: branch.name, missingChecks } + ); + } + + if ((branch.requiredApprovingReviews || 0) < minReviews) { + addFinding( + findings, + "hold", + `review-count-drift:${branch.name}`, + `Release branch ${branch.name} requires fewer than ${minReviews} approving reviews.`, + "Reset branch protection to the project release-review baseline.", + { branch: branch.name, requiredApprovingReviews: branch.requiredApprovingReviews, minReviews } + ); + } + + if (!branch.requireSignedCommits) { + addFinding( + findings, + "hold", + `signed-commit-drift:${branch.name}`, + `Release branch ${branch.name} does not require signed commits.`, + "Require signed commits or attach a release-manager exception.", + { branch: branch.name } + ); + } + + if (branch.allowForcePushes || branch.allowDeletions) { + addFinding( + findings, + "block", + `destructive-branch-setting:${branch.name}`, + `Release branch ${branch.name} allows force pushes or deletion.`, + "Disable destructive branch settings before release or export.", + { branch: branch.name, allowForcePushes: branch.allowForcePushes, allowDeletions: branch.allowDeletions } + ); + } + + if (branch.allowAdminBypass && !branch.adminBypassExceptionId) { + addFinding( + findings, + "hold", + `admin-bypass:${branch.name}`, + `Release branch ${branch.name} allows admin bypass without an exception record.`, + "Attach the exception record or disable admin bypass.", + { branch: branch.name } + ); + } + } + + for (const exportBundle of repository.exportBundles || []) { + const branch = (repository.branches || []).find((candidate) => candidate.name === exportBundle.branch); + if (!branch) { + addFinding( + findings, + "block", + `unknown-export-branch:${exportBundle.id}`, + `Export bundle ${exportBundle.id} targets an unknown branch.`, + "Point the export bundle at a known release candidate branch.", + exportBundle + ); + continue; + } + + const branchFindingPrefix = `:${branch.name}`; + const branchHasDrift = findings.some((finding) => finding.id.endsWith(branchFindingPrefix)); + if (branchHasDrift && exportBundle.status === "ready") { + addFinding( + findings, + "block", + `export-on-drifted-branch:${exportBundle.id}`, + `Export bundle ${exportBundle.id} is marked ready while branch ${branch.name} has protection drift.`, + "Hold export until branch protection drift is resolved.", + exportBundle + ); + } + } + + const blockers = findings.filter((finding) => finding.severity === "block").length; + const holds = findings.filter((finding) => finding.severity === "hold").length; + + return { + repositoryId: repository.id, + decision: blockers > 0 ? "block-release-export" : holds > 0 ? "hold-for-release-manager" : "ready-for-release-export", + summary: { + branches: (repository.branches || []).length, + exportBundles: (repository.exportBundles || []).length, + blockers, + holds, + findings: findings.length + }, + findings + }; +} + +function toMarkdown(result) { + const rows = result.findings.map((finding) => `| ${finding.severity} | ${finding.message} | ${finding.action} |`); + return [ + "# Branch Protection Drift Guard Report", + "", + `Repository: ${result.repositoryId}`, + `Decision: ${result.decision}`, + "", + "| Severity | Finding | Action |", + "| --- | --- | --- |", + ...(rows.length ? rows : ["| ok | No branch-protection drift found. | Release/export may continue. |"]), + "" + ].join("\n"); +} + +function toSvg(result) { + const color = result.decision === "ready-for-release-export" ? "#0f7b45" : result.decision === "hold-for-release-manager" ? "#9a6700" : "#b42318"; + return [ + ``, + ``, + ``, + `Branch Protection Drift Guard`, + `Decision: ${result.decision}`, + `Blockers: ${result.summary.blockers} | Holds: ${result.summary.holds} | Findings: ${result.summary.findings}`, + `Repository ${result.repositoryId}`, + `` + ].join("\n"); +} + +module.exports = { + evaluateBranchProtection, + toMarkdown, + toSvg +}; diff --git a/repository-branch-protection-drift-guard/demo.js b/repository-branch-protection-drift-guard/demo.js new file mode 100644 index 00000000..1d3686a2 --- /dev/null +++ b/repository-branch-protection-drift-guard/demo.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); +const { evaluateBranchProtection, toMarkdown, toSvg } = require("./branchProtectionGuard"); +const sampleRepository = require("./sampleRepository"); + +const result = evaluateBranchProtection(sampleRepository); +const reportDir = path.join(__dirname, "reports"); + +fs.mkdirSync(reportDir, { recursive: true }); +fs.writeFileSync(path.join(reportDir, "branch-protection-packet.json"), `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(path.join(reportDir, "branch-protection-report.md"), toMarkdown(result)); +fs.writeFileSync(path.join(reportDir, "summary.svg"), toSvg(result)); + +console.log(`decision=${result.decision}`); +console.log(`findings=${result.findings.length}`); +console.log(`reports=${reportDir}`); diff --git a/repository-branch-protection-drift-guard/reports/branch-protection-packet.json b/repository-branch-protection-drift-guard/reports/branch-protection-packet.json new file mode 100644 index 00000000..5c4c7e5d --- /dev/null +++ b/repository-branch-protection-drift-guard/reports/branch-protection-packet.json @@ -0,0 +1,76 @@ +{ + "repositoryId": "project-repo-neuro-77", + "decision": "block-release-export", + "summary": { + "branches": 3, + "exportBundles": 2, + "blockers": 3, + "holds": 3, + "findings": 6 + }, + "findings": [ + { + "severity": "block", + "id": "missing-status-checks:release/v1.2-dataset", + "message": "Release branch release/v1.2-dataset is missing required status checks.", + "action": "Restore the required release status checks before citation or export actions.", + "evidence": { + "branch": "release/v1.2-dataset", + "missingChecks": [ + "citation-metadata" + ] + } + }, + { + "severity": "hold", + "id": "review-count-drift:release/v1.2-dataset", + "message": "Release branch release/v1.2-dataset requires fewer than 2 approving reviews.", + "action": "Reset branch protection to the project release-review baseline.", + "evidence": { + "branch": "release/v1.2-dataset", + "requiredApprovingReviews": 1, + "minReviews": 2 + } + }, + { + "severity": "hold", + "id": "signed-commit-drift:release/v1.2-dataset", + "message": "Release branch release/v1.2-dataset does not require signed commits.", + "action": "Require signed commits or attach a release-manager exception.", + "evidence": { + "branch": "release/v1.2-dataset" + } + }, + { + "severity": "block", + "id": "destructive-branch-setting:release/v1.2-dataset", + "message": "Release branch release/v1.2-dataset allows force pushes or deletion.", + "action": "Disable destructive branch settings before release or export.", + "evidence": { + "branch": "release/v1.2-dataset", + "allowForcePushes": false, + "allowDeletions": true + } + }, + { + "severity": "hold", + "id": "admin-bypass:release/v1.2-dataset", + "message": "Release branch release/v1.2-dataset allows admin bypass without an exception record.", + "action": "Attach the exception record or disable admin bypass.", + "evidence": { + "branch": "release/v1.2-dataset" + } + }, + { + "severity": "block", + "id": "export-on-drifted-branch:export-v1.2", + "message": "Export bundle export-v1.2 is marked ready while branch release/v1.2-dataset has protection drift.", + "action": "Hold export until branch protection drift is resolved.", + "evidence": { + "id": "export-v1.2", + "branch": "release/v1.2-dataset", + "status": "ready" + } + } + ] +} diff --git a/repository-branch-protection-drift-guard/reports/branch-protection-report.md b/repository-branch-protection-drift-guard/reports/branch-protection-report.md new file mode 100644 index 00000000..4f40dc99 --- /dev/null +++ b/repository-branch-protection-drift-guard/reports/branch-protection-report.md @@ -0,0 +1,13 @@ +# Branch Protection Drift Guard Report + +Repository: project-repo-neuro-77 +Decision: block-release-export + +| Severity | Finding | Action | +| --- | --- | --- | +| block | Release branch release/v1.2-dataset is missing required status checks. | Restore the required release status checks before citation or export actions. | +| hold | Release branch release/v1.2-dataset requires fewer than 2 approving reviews. | Reset branch protection to the project release-review baseline. | +| hold | Release branch release/v1.2-dataset does not require signed commits. | Require signed commits or attach a release-manager exception. | +| block | Release branch release/v1.2-dataset allows force pushes or deletion. | Disable destructive branch settings before release or export. | +| hold | Release branch release/v1.2-dataset allows admin bypass without an exception record. | Attach the exception record or disable admin bypass. | +| block | Export bundle export-v1.2 is marked ready while branch release/v1.2-dataset has protection drift. | Hold export until branch protection drift is resolved. | diff --git a/repository-branch-protection-drift-guard/reports/demo.webm b/repository-branch-protection-drift-guard/reports/demo.webm new file mode 100644 index 00000000..1200c417 Binary files /dev/null and b/repository-branch-protection-drift-guard/reports/demo.webm differ diff --git a/repository-branch-protection-drift-guard/reports/summary.svg b/repository-branch-protection-drift-guard/reports/summary.svg new file mode 100644 index 00000000..bd18c787 --- /dev/null +++ b/repository-branch-protection-drift-guard/reports/summary.svg @@ -0,0 +1,8 @@ + + + +Branch Protection Drift Guard +Decision: block-release-export +Blockers: 3 | Holds: 3 | Findings: 6 +Repository project-repo-neuro-77 + \ No newline at end of file diff --git a/repository-branch-protection-drift-guard/sampleRepository.js b/repository-branch-protection-drift-guard/sampleRepository.js new file mode 100644 index 00000000..d5cd6bc4 --- /dev/null +++ b/repository-branch-protection-drift-guard/sampleRepository.js @@ -0,0 +1,40 @@ +module.exports = { + id: "project-repo-neuro-77", + branches: [ + { + name: "release/v1.2-dataset", + releaseCandidate: true, + requiredStatusChecks: ["reproducibility-pipeline", "export-manifest"], + requiredApprovingReviews: 1, + requireSignedCommits: false, + allowForcePushes: false, + allowDeletions: true, + allowAdminBypass: true, + adminBypassExceptionId: "" + }, + { + name: "preprint/v1.1", + releaseCandidate: true, + requiredStatusChecks: ["reproducibility-pipeline", "citation-metadata", "export-manifest"], + requiredApprovingReviews: 2, + requireSignedCommits: true, + allowForcePushes: false, + allowDeletions: false, + allowAdminBypass: false + }, + { + name: "experiment/new-hypothesis", + releaseCandidate: false, + requiredStatusChecks: [], + requiredApprovingReviews: 0, + requireSignedCommits: false, + allowForcePushes: true, + allowDeletions: true, + allowAdminBypass: true + } + ], + exportBundles: [ + { id: "export-v1.2", branch: "release/v1.2-dataset", status: "ready" }, + { id: "export-v1.1", branch: "preprint/v1.1", status: "ready" } + ] +}; diff --git a/repository-branch-protection-drift-guard/test.js b/repository-branch-protection-drift-guard/test.js new file mode 100644 index 00000000..a05335ad --- /dev/null +++ b/repository-branch-protection-drift-guard/test.js @@ -0,0 +1,35 @@ +const assert = require("assert"); +const { evaluateBranchProtection } = require("./branchProtectionGuard"); +const sampleRepository = require("./sampleRepository"); + +const result = evaluateBranchProtection(sampleRepository); + +assert.equal(result.decision, "block-release-export"); +assert.ok(result.findings.some((finding) => finding.id === "missing-status-checks:release/v1.2-dataset")); +assert.ok(result.findings.some((finding) => finding.id === "review-count-drift:release/v1.2-dataset")); +assert.ok(result.findings.some((finding) => finding.id === "signed-commit-drift:release/v1.2-dataset")); +assert.ok(result.findings.some((finding) => finding.id === "destructive-branch-setting:release/v1.2-dataset")); +assert.ok(result.findings.some((finding) => finding.id === "admin-bypass:release/v1.2-dataset")); +assert.ok(result.findings.some((finding) => finding.id === "export-on-drifted-branch:export-v1.2")); + +const clean = evaluateBranchProtection({ + id: "clean-repo", + branches: [ + { + name: "release/v1.0", + releaseCandidate: true, + requiredStatusChecks: ["reproducibility-pipeline", "citation-metadata", "export-manifest"], + requiredApprovingReviews: 2, + requireSignedCommits: true, + allowForcePushes: false, + allowDeletions: false, + allowAdminBypass: false + } + ], + exportBundles: [{ id: "export-clean", branch: "release/v1.0", status: "ready" }] +}); + +assert.equal(clean.decision, "ready-for-release-export"); +assert.equal(clean.findings.length, 0); + +console.log("branch protection drift guard tests passed");