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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions repository-branch-protection-drift-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
161 changes: 161 additions & 0 deletions repository-branch-protection-drift-guard/branchProtectionGuard.js
Original file line number Diff line number Diff line change
@@ -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 [
`<svg xmlns="http://www.w3.org/2000/svg" width="760" height="220" viewBox="0 0 760 220" role="img" aria-label="Branch protection drift guard summary">`,
`<rect width="760" height="220" fill="#f8fafc"/>`,
`<rect x="30" y="28" width="700" height="164" rx="8" fill="#ffffff" stroke="#d0d7de"/>`,
`<text x="54" y="72" font-family="Arial, sans-serif" font-size="24" font-weight="700" fill="#111827">Branch Protection Drift Guard</text>`,
`<text x="54" y="112" font-family="Arial, sans-serif" font-size="18" fill="${color}">Decision: ${result.decision}</text>`,
`<text x="54" y="146" font-family="Arial, sans-serif" font-size="16" fill="#374151">Blockers: ${result.summary.blockers} | Holds: ${result.summary.holds} | Findings: ${result.summary.findings}</text>`,
`<text x="54" y="174" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Repository ${result.repositoryId}</text>`,
`</svg>`
].join("\n");
}

module.exports = {
evaluateBranchProtection,
toMarkdown,
toSvg
};
16 changes: 16 additions & 0 deletions repository-branch-protection-drift-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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}`);
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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. |
Binary file not shown.
8 changes: 8 additions & 0 deletions repository-branch-protection-drift-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions repository-branch-protection-drift-guard/sampleRepository.js
Original file line number Diff line number Diff line change
@@ -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" }
]
};
35 changes: 35 additions & 0 deletions repository-branch-protection-drift-guard/test.js
Original file line number Diff line number Diff line change
@@ -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");