diff --git a/repository-version-tag-governor/README.md b/repository-version-tag-governor/README.md new file mode 100644 index 00000000..05fcfeed --- /dev/null +++ b/repository-version-tag-governor/README.md @@ -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. diff --git a/repository-version-tag-governor/acceptance-notes.md b/repository-version-tag-governor/acceptance-notes.md new file mode 100644 index 00000000..e2af8f39 --- /dev/null +++ b/repository-version-tag-governor/acceptance-notes.md @@ -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 +``` diff --git a/repository-version-tag-governor/demo.js b/repository-version-tag-governor/demo.js new file mode 100644 index 00000000..247ac9c0 --- /dev/null +++ b/repository-version-tag-governor/demo.js @@ -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 ` + + + + Repository version tag governor + Semantic release and citation-version integrity review + ${bar(56, 154, "Blocked", mixed.blocked, "#b91c1c")} + ${bar(56, 214, "Manual review", mixed.manualReview, "#b7791f")} + ${bar(56, 274, "Approved", mixed.approved, "#15803d")} + Digest ${packet.mixed.auditDigest.slice(0, 16)} + +`; +} + +function bar(x, y, label, count, color) { + const width = Math.max(24, count * 120); + return [ + ` ${label}`, + ` `, + ` `, + ` ${count} proposal(s)` + ].join("\n"); +} diff --git a/repository-version-tag-governor/index.js b/repository-version-tag-governor/index.js new file mode 100644 index 00000000..059b94b8 --- /dev/null +++ b/repository-version-tag-governor/index.js @@ -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 +}; diff --git a/repository-version-tag-governor/make-demo-video.js b/repository-version-tag-governor/make-demo-video.js new file mode 100644 index 00000000..fcc97c3b --- /dev/null +++ b/repository-version-tag-governor/make-demo-video.js @@ -0,0 +1,164 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const width = 320; +const height = 180; +const fps = 2; +const frameCount = 16; +const rowSize = Math.ceil((width * 3) / 4) * 4; +const frameSize = rowSize * height; +const reportsDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportsDir, "demo.avi"); + +fs.mkdirSync(reportsDir, { recursive: true }); + +const frames = []; +for (let i = 0; i < frameCount; i += 1) { + frames.push(makeFrame(i)); +} + +const chunks = []; +const indexes = []; +let offset = 4; +for (const frame of frames) { + const chunk = riffChunk("00db", frame); + chunks.push(chunk); + indexes.push(indexEntry("00db", 0x10, offset, frame.length)); + offset += chunk.length; +} + +const hdrl = listChunk("hdrl", [ + riffChunk("avih", aviHeader()), + listChunk("strl", [ + riffChunk("strh", streamHeader()), + riffChunk("strf", bitmapInfoHeader()) + ]) +]); +const movi = listChunk("movi", chunks); +const idx1 = riffChunk("idx1", Buffer.concat(indexes)); +const riff = riffChunk("RIFF", Buffer.concat([Buffer.from("AVI "), hdrl, movi, idx1])); + +fs.writeFileSync(outputPath, riff); +console.log(`Wrote ${path.relative(process.cwd(), outputPath)} (${riff.length} bytes)`); + +function makeFrame(frameIndex) { + const frame = Buffer.alloc(frameSize, 0xff); + const progress = (frameIndex + 1) / frameCount; + fillRect(frame, 0, 0, width, height, [248, 250, 252]); + fillRect(frame, 18, 18, 284, 144, [255, 255, 255]); + strokeRect(frame, 18, 18, 284, 144, [210, 218, 228]); + fillRect(frame, 38, 50, 240, 18, [232, 237, 244]); + fillRect(frame, 38, 50, Math.round(240 * progress), 18, [185, 28, 28]); + fillRect(frame, 38, 90, 240, 18, [232, 237, 244]); + fillRect(frame, 38, 90, Math.round(120 * progress), 18, [183, 121, 31]); + fillRect(frame, 38, 130, 240, 18, [232, 237, 244]); + fillRect(frame, 38, 130, Math.round(120 * progress), 18, [21, 128, 61]); + drawTagMarks(frame, frameIndex); + return frame; +} + +function drawTagMarks(frame, frameIndex) { + const count = Math.min(4, Math.floor(frameIndex / 4) + 1); + for (let i = 0; i < count; i += 1) { + fillRect(frame, 48 + i * 28, 78, 18, 8, [36, 78, 96]); + fillRect(frame, 48 + i * 28, 88, 18, 4, [94, 129, 143]); + } +} + +function fillRect(frame, x, y, w, h, rgb) { + for (let py = y; py < y + h; py += 1) { + if (py < 0 || py >= height) continue; + for (let px = x; px < x + w; px += 1) { + if (px < 0 || px >= width) continue; + setPixel(frame, px, py, rgb); + } + } +} + +function strokeRect(frame, x, y, w, h, rgb) { + fillRect(frame, x, y, w, 1, rgb); + fillRect(frame, x, y + h - 1, w, 1, rgb); + fillRect(frame, x, y, 1, h, rgb); + fillRect(frame, x + w - 1, y, 1, h, rgb); +} + +function setPixel(frame, x, y, rgb) { + const bottomUpY = height - y - 1; + const index = bottomUpY * rowSize + x * 3; + frame[index] = rgb[2]; + frame[index + 1] = rgb[1]; + frame[index + 2] = rgb[0]; +} + +function aviHeader() { + const buffer = Buffer.alloc(56); + buffer.writeUInt32LE(Math.round(1000000 / fps), 0); + buffer.writeUInt32LE(frameSize * fps, 4); + buffer.writeUInt32LE(0, 8); + buffer.writeUInt32LE(0x10, 12); + buffer.writeUInt32LE(frameCount, 16); + buffer.writeUInt32LE(0, 20); + buffer.writeUInt32LE(1, 24); + buffer.writeUInt32LE(frameSize, 28); + buffer.writeUInt32LE(width, 32); + buffer.writeUInt32LE(height, 36); + return buffer; +} + +function streamHeader() { + const buffer = Buffer.alloc(56); + buffer.write("vids", 0, 4, "ascii"); + buffer.write("DIB ", 4, 4, "ascii"); + buffer.writeUInt32LE(0, 8); + buffer.writeUInt32LE(0, 12); + buffer.writeUInt32LE(0, 16); + buffer.writeUInt32LE(1, 20); + buffer.writeUInt32LE(fps, 24); + buffer.writeUInt32LE(0, 28); + buffer.writeUInt32LE(frameCount, 32); + buffer.writeUInt32LE(frameSize, 36); + buffer.writeInt32LE(-1, 40); + buffer.writeUInt32LE(0, 44); + buffer.writeInt16LE(0, 48); + buffer.writeInt16LE(0, 50); + buffer.writeInt16LE(width, 52); + buffer.writeInt16LE(height, 54); + return buffer; +} + +function bitmapInfoHeader() { + const buffer = Buffer.alloc(40); + buffer.writeUInt32LE(40, 0); + buffer.writeInt32LE(width, 4); + buffer.writeInt32LE(height, 8); + buffer.writeUInt16LE(1, 12); + buffer.writeUInt16LE(24, 14); + buffer.writeUInt32LE(0, 16); + buffer.writeUInt32LE(frameSize, 20); + return buffer; +} + +function indexEntry(id, flags, chunkOffset, size) { + const buffer = Buffer.alloc(16); + buffer.write(id, 0, 4, "ascii"); + buffer.writeUInt32LE(flags, 4); + buffer.writeUInt32LE(chunkOffset, 8); + buffer.writeUInt32LE(size, 12); + return buffer; +} + +function riffChunk(id, payload) { + const size = payload.length; + const pad = size % 2 === 1 ? 1 : 0; + const buffer = Buffer.alloc(8 + size + pad); + buffer.write(id, 0, 4, "ascii"); + buffer.writeUInt32LE(size, 4); + payload.copy(buffer, 8); + return buffer; +} + +function listChunk(type, chunks) { + return riffChunk("LIST", Buffer.concat([Buffer.from(type, "ascii"), ...chunks])); +} diff --git a/repository-version-tag-governor/package.json b/repository-version-tag-governor/package.json new file mode 100644 index 00000000..beef4371 --- /dev/null +++ b/repository-version-tag-governor/package.json @@ -0,0 +1,13 @@ +{ + "name": "repository-version-tag-governor", + "version": "1.0.0", + "description": "Synthetic semantic version-tag governance for SCIBASE issue 10.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node make-demo-video.js" + }, + "license": "MIT" +} diff --git a/repository-version-tag-governor/reports/demo.avi b/repository-version-tag-governor/reports/demo.avi new file mode 100644 index 00000000..e3c2ecd6 Binary files /dev/null and b/repository-version-tag-governor/reports/demo.avi differ diff --git a/repository-version-tag-governor/reports/repository-version-tag-packet.json b/repository-version-tag-governor/reports/repository-version-tag-packet.json new file mode 100644 index 00000000..4541f944 --- /dev/null +++ b/repository-version-tag-governor/reports/repository-version-tag-packet.json @@ -0,0 +1,137 @@ +{ + "mixed": { + "generatedAt": "2026-05-28T08:10:00.000Z", + "repositoryId": "synthetic-repository-versioning", + "status": "hold_tag_publication", + "summary": { + "status": "hold_tag_publication", + "proposalCount": 3, + "blocked": 1, + "manualReview": 1, + "approved": 1, + "blockerCount": 6, + "warningCount": 6 + }, + "proposals": [ + { + "proposalId": "reused-preprint-tag", + "tag": "preprint-v2.0", + "releaseType": "preprint", + "decision": "block_release_tag", + "blockers": [ + { + "code": "version_tag_collision", + "message": "preprint-v2.0 already points to a different commit or DOI." + }, + { + "code": "non_monotonic_version_tag", + "message": "preprint-v2.0 does not advance beyond the latest preprint version." + }, + { + "code": "metadata_version_drift", + "message": "metadata.json version preprint-v1.9 does not match tag preprint-v2.0." + }, + { + "code": "doi_metadata_mismatch", + "message": "Repository DOI and metadata DOI differ for the proposed version." + }, + { + "code": "artifact_hashes_missing", + "message": "Missing hash locks for data." + }, + { + "code": "fork_attribution_missing", + "message": "Derived repository release lacks parent project attribution." + } + ], + "warnings": [ + { + "code": "citation_version_stale", + "message": "Citation text references preprint-v1.9 instead of preprint-v2.0." + }, + { + "code": "results_hash_missing", + "message": "Results output hash is absent for a reproducibility-bearing tag." + }, + { + "code": "changelog_entry_missing", + "message": "No changelog entry is attached to the proposed tag." + } + ], + "actions": [ + "Create a new monotonic tag instead of reusing a published repository version.", + "Advance the semantic version or preprint version before publication.", + "Update metadata.json before DOI or citation release.", + "Reconcile DOI fields before publishing the tag.", + "Regenerate citation text and Cite this project metadata for the tag.", + "Lock manuscript, data, code, and metadata hashes before release.", + "Add result/figure hash evidence or mark the release as metadata-only.", + "Attach parent repository DOI and fork lineage before publishing the tag.", + "Add a concise changelog entry that explains version-scoped changes." + ] + }, + { + "proposalId": "minor-release-needs-review", + "tag": "v1.3.0", + "releaseType": "release", + "decision": "manual_review_tag", + "blockers": [], + "warnings": [ + { + "code": "citation_version_stale", + "message": "Citation text references v1.2.0 instead of v1.3.0." + }, + { + "code": "results_hash_missing", + "message": "Results output hash is absent for a reproducibility-bearing tag." + }, + { + "code": "changelog_entry_missing", + "message": "No changelog entry is attached to the proposed tag." + } + ], + "actions": [ + "Regenerate citation text and Cite this project metadata for the tag.", + "Add result/figure hash evidence or mark the release as metadata-only.", + "Add a concise changelog entry that explains version-scoped changes." + ] + }, + { + "proposalId": "major-release-ready", + "tag": "v2.0.0", + "releaseType": "release", + "decision": "approve_tag", + "blockers": [], + "warnings": [], + "actions": [] + } + ], + "auditDigest": "9dc7b2c8ec29eb7ea6d6c580bf662d2030955576c69344176e6d2caf58bd38f9" + }, + "ready": { + "generatedAt": "2026-05-28T08:10:00.000Z", + "repositoryId": "synthetic-ready-repository-versioning", + "status": "ready", + "summary": { + "status": "ready", + "proposalCount": 1, + "blocked": 0, + "manualReview": 0, + "approved": 1, + "blockerCount": 0, + "warningCount": 0 + }, + "proposals": [ + { + "proposalId": "major-release-ready", + "tag": "v2.0.0", + "releaseType": "release", + "decision": "approve_tag", + "blockers": [], + "warnings": [], + "actions": [] + } + ], + "auditDigest": "1c36ac737eb497dbfb0259fc5d5066df268d68105657f60afd1419ef4484f00e" + } +} diff --git a/repository-version-tag-governor/reports/repository-version-tag-report.md b/repository-version-tag-governor/reports/repository-version-tag-report.md new file mode 100644 index 00000000..ef0684e7 --- /dev/null +++ b/repository-version-tag-governor/reports/repository-version-tag-report.md @@ -0,0 +1,34 @@ +# Repository Version Tag Governor Report + +Synthetic semantic tag governance packet for SCIBASE issue #10. + +## Summary + +| Packet | Status | Blocked | Manual Review | Approved | Digest | +| --- | --- | ---: | ---: | ---: | --- | +| mixed | hold_tag_publication | 1 | 1 | 1 | 9dc7b2c8ec29 | +| ready | ready | 0 | 0 | 1 | 1c36ac737eb4 | + +## Proposal Decisions + +### reused-preprint-tag: block_release_tag +- blocker version_tag_collision: preprint-v2.0 already points to a different commit or DOI. +- blocker non_monotonic_version_tag: preprint-v2.0 does not advance beyond the latest preprint version. +- blocker metadata_version_drift: metadata.json version preprint-v1.9 does not match tag preprint-v2.0. +- blocker doi_metadata_mismatch: Repository DOI and metadata DOI differ for the proposed version. +- blocker artifact_hashes_missing: Missing hash locks for data. +- blocker fork_attribution_missing: Derived repository release lacks parent project attribution. +- warning citation_version_stale: Citation text references preprint-v1.9 instead of preprint-v2.0. +- warning results_hash_missing: Results output hash is absent for a reproducibility-bearing tag. +- warning changelog_entry_missing: No changelog entry is attached to the proposed tag. + +### minor-release-needs-review: manual_review_tag +- warning citation_version_stale: Citation text references v1.2.0 instead of v1.3.0. +- warning results_hash_missing: Results output hash is absent for a reproducibility-bearing tag. +- warning changelog_entry_missing: No changelog entry is attached to the proposed tag. + +### major-release-ready: approve_tag + +## Non-Overlap Notes + +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. diff --git a/repository-version-tag-governor/reports/summary.svg b/repository-version-tag-governor/reports/summary.svg new file mode 100644 index 00000000..0a7f63de --- /dev/null +++ b/repository-version-tag-governor/reports/summary.svg @@ -0,0 +1,20 @@ + + + + + Repository version tag governor + Semantic release and citation-version integrity review + Blocked + + + 1 proposal(s) + Manual review + + + 1 proposal(s) + Approved + + + 1 proposal(s) + Digest 9dc7b2c8ec29eb7e + diff --git a/repository-version-tag-governor/requirements-map.md b/repository-version-tag-governor/requirements-map.md new file mode 100644 index 00000000..b8c2b81b --- /dev/null +++ b/repository-version-tag-governor/requirements-map.md @@ -0,0 +1,14 @@ +# Requirements Map + +| Issue #10 requirement | Coverage | +| --- | --- | +| Semantic versioning | Validates `vX.Y.Z` and `preprint-vX.Y` formats plus monotonic version progression. | +| Hash-based integrity | Requires manuscript, data, code, metadata, and result hash evidence where relevant. | +| DOI assignment per tagged version | Blocks DOI and metadata DOI mismatch before publication. | +| Auto-generated citations | Flags stale citation versions that do not match the proposed tag. | +| Forking with attribution | Blocks derived releases that omit parent repository lineage. | +| Version history, rollback, tagging | Detects tag reuse and collision against prior tag history. | + +## Non-Overlap + +This is not a repository foundation module, broad integrity ledger, release readiness gate, DOI tombstone gate, restore rehearsal guard, access review, environment drift check, merge queue governance workflow, retention legal hold, sensitive artifact guard, license compatibility checker, branch hypothesis lineage gate, component-owner approval guard, or API export contract. It focuses narrowly on semantic tag governance and citation-version integrity. diff --git a/repository-version-tag-governor/sample-data.js b/repository-version-tag-governor/sample-data.js new file mode 100644 index 00000000..96d1fbfb --- /dev/null +++ b/repository-version-tag-governor/sample-data.js @@ -0,0 +1,103 @@ +"use strict"; + +const sharedHistory = [ + { + tag: "preprint-v1.9", + commitHash: "a100", + doi: "10.5555/scibase.preprint.v1.9" + }, + { + tag: "preprint-v2.0", + commitHash: "b200", + doi: "10.5555/scibase.preprint.v2.0" + }, + { + tag: "v1.2.0", + commitHash: "c300", + doi: "10.5555/scibase.release.v1.2.0" + } +]; + +const mixedRepositoryTagPacket = { + generatedAt: "2026-05-28T08:10:00Z", + repositoryId: "synthetic-repository-versioning", + proposals: [ + { + id: "reused-preprint-tag", + releaseType: "preprint", + tag: "preprint-v2.0", + commitHash: "d400", + doi: "10.5555/scibase.preprint.v2.0-reused", + metadataVersion: "preprint-v1.9", + metadataDoi: "10.5555/scibase.preprint.v2.0", + citationVersion: "preprint-v1.9", + changelogEntry: false, + artifactHashes: { + manuscript: "sha256:manuscript-v20", + code: "sha256:code-v20", + metadata: "sha256:metadata-v20" + }, + forkAttribution: { + required: true, + present: false + }, + history: sharedHistory + }, + { + id: "minor-release-needs-review", + releaseType: "release", + tag: "v1.3.0", + commitHash: "e500", + doi: "10.5555/scibase.release.v1.3.0", + metadataVersion: "v1.3.0", + metadataDoi: "10.5555/scibase.release.v1.3.0", + citationVersion: "v1.2.0", + changelogEntry: false, + artifactHashes: { + manuscript: "sha256:manuscript-v130", + data: "sha256:data-v130", + code: "sha256:code-v130", + metadata: "sha256:metadata-v130" + }, + forkAttribution: { + required: false, + present: false + }, + history: sharedHistory + }, + { + id: "major-release-ready", + releaseType: "release", + tag: "v2.0.0", + commitHash: "f600", + doi: "10.5555/scibase.release.v2.0.0", + metadataVersion: "v2.0.0", + metadataDoi: "10.5555/scibase.release.v2.0.0", + citationVersion: "v2.0.0", + changelogEntry: true, + artifactHashes: { + manuscript: "sha256:manuscript-v200", + data: "sha256:data-v200", + code: "sha256:code-v200", + metadata: "sha256:metadata-v200", + results: "sha256:results-v200" + }, + forkAttribution: { + required: true, + present: true + }, + history: sharedHistory + } + ] +}; + +const readyRepositoryTagPacket = { + generatedAt: "2026-05-28T08:10:00Z", + repositoryId: "synthetic-ready-repository-versioning", + proposals: [mixedRepositoryTagPacket.proposals[2]] +}; + +module.exports = { + mixedRepositoryTagPacket, + readyRepositoryTagPacket +}; diff --git a/repository-version-tag-governor/test.js b/repository-version-tag-governor/test.js new file mode 100644 index 00000000..529a9f6b --- /dev/null +++ b/repository-version-tag-governor/test.js @@ -0,0 +1,61 @@ +"use strict"; + +const assert = require("assert"); +const { assessRepositoryVersionTags, parseTag } = require("./index"); +const { mixedRepositoryTagPacket, readyRepositoryTagPacket } = require("./sample-data"); + +function testRiskyTagIsBlocked() { + const result = assessRepositoryVersionTags(mixedRepositoryTagPacket); + const risky = result.proposals.find((proposal) => proposal.proposalId === "reused-preprint-tag"); + const codes = risky.blockers.map((blocker) => blocker.code); + + assert.strictEqual(result.status, "hold_tag_publication"); + assert.strictEqual(risky.decision, "block_release_tag"); + assert(codes.includes("version_tag_collision")); + assert(codes.includes("metadata_version_drift")); + assert(codes.includes("doi_metadata_mismatch")); + assert(codes.includes("artifact_hashes_missing")); + assert(codes.includes("fork_attribution_missing")); +} + +function testManualReviewTagWarnsOnly() { + const result = assessRepositoryVersionTags(mixedRepositoryTagPacket); + const review = result.proposals.find((proposal) => proposal.proposalId === "minor-release-needs-review"); + const codes = review.warnings.map((warning) => warning.code); + + assert.strictEqual(review.decision, "manual_review_tag"); + assert.strictEqual(review.blockers.length, 0); + assert(codes.includes("citation_version_stale")); + assert(codes.includes("results_hash_missing")); + assert(codes.includes("changelog_entry_missing")); +} + +function testReadyTagIsApproved() { + const result = assessRepositoryVersionTags(readyRepositoryTagPacket); + const ready = result.proposals[0]; + + assert.strictEqual(result.status, "ready"); + assert.strictEqual(ready.decision, "approve_tag"); + assert.strictEqual(ready.blockers.length, 0); + assert.strictEqual(ready.warnings.length, 0); +} + +function testTagParsingAndDigestAreDeterministic() { + const first = assessRepositoryVersionTags(mixedRepositoryTagPacket); + const second = assessRepositoryVersionTags(mixedRepositoryTagPacket); + + assert.deepStrictEqual(parseTag("v1.2.3").parts, [1, 2, 3]); + assert.deepStrictEqual(parseTag("preprint-v2.1").parts, [2, 1, 0]); + assert.match(first.auditDigest, /^[a-f0-9]{64}$/); + assert.strictEqual(first.auditDigest, second.auditDigest); +} + +function run() { + testRiskyTagIsBlocked(); + testManualReviewTagWarnsOnly(); + testReadyTagIsApproved(); + testTagParsingAndDigestAreDeterministic(); + console.log("4 tests passed"); +} + +run();