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
38 changes: 38 additions & 0 deletions repository-version-tag-governor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Repository Version Tag Governor

Synthetic, dependency-free semantic tag governance module for SCIBASE issue #10, Project Repository & Version Control.

This module evaluates repository release/preprint tag proposals before DOI, citation, or public release publication. It focuses on semantic version integrity, metadata alignment, artifact hash locks, and fork attribution.

## What It Checks

- supported `vX.Y.Z` and `preprint-vX.Y` tag formats
- version tag reuse or collision against existing history
- non-monotonic release and preprint versions
- `metadata.json` version drift
- DOI mismatch between release proposal and metadata
- stale generated citation version
- missing manuscript/data/code/metadata hash locks
- missing result hashes for reproducibility-bearing releases
- missing fork attribution for derived repository versions
- missing changelog evidence

## Run

```bash
npm run check
npm test
npm run demo
npm run demo:video
```

The demo writes reviewer artifacts under `reports/`:

- `repository-version-tag-packet.json`
- `repository-version-tag-report.md`
- `summary.svg`
- `demo.avi`

## Safety

All records are synthetic. The module does not call external services, mutate Git state, inspect real repositories, or publish tags.
20 changes: 20 additions & 0 deletions repository-version-tag-governor/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Acceptance Notes

Reviewer acceptance checklist:

- A reused preprint tag that points to a different commit or DOI is blocked.
- Metadata version and DOI drift block publication.
- Missing required artifact hashes block release.
- Stale citation text, missing result hashes, and missing changelog entries require manual review.
- A fully aligned major release is approved.
- Audit digests are deterministic across repeated runs.
- Demo artifacts are generated locally from synthetic data only.

Validation commands:

```bash
npm run check
npm test
npm run demo
npm run demo:video
```
84 changes: 84 additions & 0 deletions repository-version-tag-governor/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use strict";

const fs = require("fs");
const path = require("path");
const { assessRepositoryVersionTags } = require("./index");
const { mixedRepositoryTagPacket, readyRepositoryTagPacket } = require("./sample-data");

const reportsDir = path.join(__dirname, "reports");
fs.mkdirSync(reportsDir, { recursive: true });

const packet = {
mixed: assessRepositoryVersionTags(mixedRepositoryTagPacket),
ready: assessRepositoryVersionTags(readyRepositoryTagPacket)
};

fs.writeFileSync(path.join(reportsDir, "repository-version-tag-packet.json"), `${JSON.stringify(packet, null, 2)}\n`);
fs.writeFileSync(path.join(reportsDir, "repository-version-tag-report.md"), renderMarkdown(packet));
fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvg(packet));

console.log(`Wrote ${path.relative(process.cwd(), reportsDir)}`);
console.log(`Mixed packet status: ${packet.mixed.status}`);
console.log(`Audit digest: ${packet.mixed.auditDigest}`);

function renderMarkdown(packet) {
const lines = [
"# Repository Version Tag Governor Report",
"",
"Synthetic semantic tag governance packet for SCIBASE issue #10.",
"",
"## Summary",
"",
"| Packet | Status | Blocked | Manual Review | Approved | Digest |",
"| --- | --- | ---: | ---: | ---: | --- |",
`| mixed | ${packet.mixed.status} | ${packet.mixed.summary.blocked} | ${packet.mixed.summary.manualReview} | ${packet.mixed.summary.approved} | ${packet.mixed.auditDigest.slice(0, 12)} |`,
`| ready | ${packet.ready.status} | ${packet.ready.summary.blocked} | ${packet.ready.summary.manualReview} | ${packet.ready.summary.approved} | ${packet.ready.auditDigest.slice(0, 12)} |`,
"",
"## Proposal Decisions",
""
];

for (const proposal of packet.mixed.proposals) {
lines.push(`### ${proposal.proposalId}: ${proposal.decision}`);
for (const blocker of proposal.blockers) {
lines.push(`- blocker ${blocker.code}: ${blocker.message}`);
}
for (const warning of proposal.warnings) {
lines.push(`- warning ${warning.code}: ${warning.message}`);
}
lines.push("");
}

lines.push("## Non-Overlap Notes", "");
lines.push(
"This module focuses on semantic version tag governance and citation-version integrity before publication. It does not implement repository foundations, a broad integrity ledger, release readiness, DOI tombstones, restore rehearsal, access review, environment drift, merge queue governance, retention legal holds, sensitive artifact scanning, license compatibility, branch lineage, component-owner approval, or export contracts."
);

return `${lines.join("\n")}\n`;
}

function renderSvg(packet) {
const mixed = packet.mixed.summary;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="340" viewBox="0 0 760 340" role="img" aria-label="Repository version tag governance summary">
<rect width="760" height="340" fill="#f8fafc"/>
<rect x="32" y="32" width="696" height="276" rx="8" fill="#ffffff" stroke="#d7dee8"/>
<text x="56" y="76" font-family="Arial, sans-serif" font-size="26" font-weight="700" fill="#172033">Repository version tag governor</text>
<text x="56" y="106" font-family="Arial, sans-serif" font-size="14" fill="#526173">Semantic release and citation-version integrity review</text>
${bar(56, 154, "Blocked", mixed.blocked, "#b91c1c")}
${bar(56, 214, "Manual review", mixed.manualReview, "#b7791f")}
${bar(56, 274, "Approved", mixed.approved, "#15803d")}
<text x="548" y="294" font-family="Arial, sans-serif" font-size="12" fill="#526173">Digest ${packet.mixed.auditDigest.slice(0, 16)}</text>
</svg>
`;
}

function bar(x, y, label, count, color) {
const width = Math.max(24, count * 120);
return [
` <text x="${x}" y="${y - 14}" font-family="Arial, sans-serif" font-size="15" font-weight="700" fill="#172033">${label}</text>`,
` <rect x="${x}" y="${y}" width="360" height="24" rx="4" fill="#e8edf4"/>`,
` <rect x="${x}" y="${y}" width="${width}" height="24" rx="4" fill="${color}"/>`,
` <text x="${x + 382}" y="${y + 17}" font-family="Arial, sans-serif" font-size="13" fill="#172033">${count} proposal(s)</text>`
].join("\n");
}
236 changes: 236 additions & 0 deletions repository-version-tag-governor/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
"use strict";

const crypto = require("crypto");

const REQUIRED_ARTIFACTS = ["manuscript", "data", "code", "metadata"];

function assessRepositoryVersionTags(packet) {
const generatedAt = new Date(packet.generatedAt || "2026-05-28T00:00:00Z");
const proposals = (packet.proposals || []).map((proposal) => evaluateProposal(proposal));
const summary = summarize(proposals);
const payload = {
generatedAt: generatedAt.toISOString(),
repositoryId: packet.repositoryId || "synthetic-project-repository",
status: summary.status,
summary,
proposals
};

return {
...payload,
auditDigest: digest(payload)
};
}

function evaluateProposal(proposal) {
const blockers = [];
const warnings = [];
const actions = [];
const parsedTag = parseTag(proposal.tag);

checkTagFormat(proposal, parsedTag, blockers, actions);
checkCollision(proposal, blockers, actions);
checkMonotonicity(proposal, parsedTag, blockers, actions);
checkMetadataDrift(proposal, blockers, warnings, actions);
checkArtifactHashes(proposal, blockers, warnings, actions);
checkForkAttribution(proposal, blockers, actions);
checkChangelog(proposal, warnings, actions);

const decision = blockers.length > 0
? "block_release_tag"
: warnings.length > 0
? "manual_review_tag"
: "approve_tag";

return {
proposalId: proposal.id,
tag: proposal.tag,
releaseType: proposal.releaseType,
decision,
blockers,
warnings,
actions: [...new Set(actions)]
};
}

function checkTagFormat(proposal, parsedTag, blockers, actions) {
if (!parsedTag.valid) {
blockers.push({
code: "unsupported_version_tag",
message: `${proposal.tag} is not a supported vX.Y.Z or preprint-vX.Y tag.`
});
actions.push("Rename the release tag to a supported semantic repository version.");
}
}

function checkCollision(proposal, blockers, actions) {
const collisions = proposal.history.filter((entry) => entry.tag === proposal.tag);
for (const collision of collisions) {
if (collision.commitHash !== proposal.commitHash || collision.doi !== proposal.doi) {
blockers.push({
code: "version_tag_collision",
message: `${proposal.tag} already points to a different commit or DOI.`
});
actions.push("Create a new monotonic tag instead of reusing a published repository version.");
return;
}
}
}

function checkMonotonicity(proposal, parsedTag, blockers, actions) {
if (!parsedTag.valid) return;

const comparable = proposal.history
.map((entry) => ({ ...entry, parsed: parseTag(entry.tag) }))
.filter((entry) => entry.parsed.valid && entry.parsed.kind === parsedTag.kind);
const latest = comparable.reduce((max, entry) => compareVersions(entry.parsed.parts, max.parts) > 0 ? entry.parsed : max, { parts: [-1, -1, -1] });

if (compareVersions(parsedTag.parts, latest.parts) <= 0 && comparable.some((entry) => entry.tag !== proposal.tag)) {
blockers.push({
code: "non_monotonic_version_tag",
message: `${proposal.tag} does not advance beyond the latest ${parsedTag.kind} version.`
});
actions.push("Advance the semantic version or preprint version before publication.");
}
}

function checkMetadataDrift(proposal, blockers, warnings, actions) {
if (proposal.metadataVersion !== proposal.tag) {
blockers.push({
code: "metadata_version_drift",
message: `metadata.json version ${proposal.metadataVersion} does not match tag ${proposal.tag}.`
});
actions.push("Update metadata.json before DOI or citation release.");
}

if (proposal.metadataDoi !== proposal.doi) {
blockers.push({
code: "doi_metadata_mismatch",
message: "Repository DOI and metadata DOI differ for the proposed version."
});
actions.push("Reconcile DOI fields before publishing the tag.");
}

if (proposal.citationVersion !== proposal.tag) {
warnings.push({
code: "citation_version_stale",
message: `Citation text references ${proposal.citationVersion} instead of ${proposal.tag}.`
});
actions.push("Regenerate citation text and Cite this project metadata for the tag.");
}
}

function checkArtifactHashes(proposal, blockers, warnings, actions) {
const hashes = proposal.artifactHashes || {};
const missing = REQUIRED_ARTIFACTS.filter((name) => !hashes[name]);
if (missing.length > 0) {
blockers.push({
code: "artifact_hashes_missing",
message: `Missing hash locks for ${missing.join(", ")}.`
});
actions.push("Lock manuscript, data, code, and metadata hashes before release.");
}

if (!hashes.results && proposal.releaseType !== "metadata-only") {
warnings.push({
code: "results_hash_missing",
message: "Results output hash is absent for a reproducibility-bearing tag."
});
actions.push("Add result/figure hash evidence or mark the release as metadata-only.");
}
}

function checkForkAttribution(proposal, blockers, actions) {
if (proposal.forkAttribution.required && !proposal.forkAttribution.present) {
blockers.push({
code: "fork_attribution_missing",
message: "Derived repository release lacks parent project attribution."
});
actions.push("Attach parent repository DOI and fork lineage before publishing the tag.");
}
}

function checkChangelog(proposal, warnings, actions) {
if (!proposal.changelogEntry) {
warnings.push({
code: "changelog_entry_missing",
message: "No changelog entry is attached to the proposed tag."
});
actions.push("Add a concise changelog entry that explains version-scoped changes.");
}
}

function parseTag(tag) {
const semver = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
if (semver) {
return {
valid: true,
kind: "release",
parts: semver.slice(1).map(Number)
};
}

const preprint = /^preprint-v(\d+)\.(\d+)$/.exec(tag);
if (preprint) {
return {
valid: true,
kind: "preprint",
parts: [Number(preprint[1]), Number(preprint[2]), 0]
};
}

return {
valid: false,
kind: "unknown",
parts: [-1, -1, -1]
};
}

function compareVersions(left, right) {
for (let i = 0; i < 3; i += 1) {
if (left[i] > right[i]) return 1;
if (left[i] < right[i]) return -1;
}
return 0;
}

function summarize(proposals) {
const blocked = proposals.filter((proposal) => proposal.decision === "block_release_tag").length;
const manualReview = proposals.filter((proposal) => proposal.decision === "manual_review_tag").length;
const approved = proposals.filter((proposal) => proposal.decision === "approve_tag").length;
const blockerCount = proposals.reduce((sum, proposal) => sum + proposal.blockers.length, 0);
const warningCount = proposals.reduce((sum, proposal) => sum + proposal.warnings.length, 0);
const status = blocked > 0 ? "hold_tag_publication" : manualReview > 0 ? "review_before_tagging" : "ready";

return {
status,
proposalCount: proposals.length,
blocked,
manualReview,
approved,
blockerCount,
warningCount
};
}

function digest(value) {
return crypto.createHash("sha256").update(stableStringify(value)).digest("hex");
}

function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

module.exports = {
assessRepositoryVersionTags,
parseTag
};
Loading