From ed6a02a1e6ca4f925f0e67a0bada05a9e7c7bd7d Mon Sep 17 00:00:00 2001 From: davidrsdiaz Date: Thu, 28 May 2026 16:20:47 -0500 Subject: [PATCH] Add repository release signature guard --- repository-release-signature-guard/README.md | 28 +++ repository-release-signature-guard/demo.js | 18 ++ repository-release-signature-guard/index.js | 238 ++++++++++++++++++ .../make-demo-video.js | 67 +++++ .../reports/demo.mp4 | Bin 0 -> 4809 bytes .../reports/release-signature-review.json | 171 +++++++++++++ .../reports/release-signature-review.md | 62 +++++ .../reports/release-signature-summary.svg | 45 ++++ .../sample-data.js | 131 ++++++++++ repository-release-signature-guard/test.js | 39 +++ 10 files changed, 799 insertions(+) create mode 100644 repository-release-signature-guard/README.md create mode 100644 repository-release-signature-guard/demo.js create mode 100644 repository-release-signature-guard/index.js create mode 100644 repository-release-signature-guard/make-demo-video.js create mode 100644 repository-release-signature-guard/reports/demo.mp4 create mode 100644 repository-release-signature-guard/reports/release-signature-review.json create mode 100644 repository-release-signature-guard/reports/release-signature-review.md create mode 100644 repository-release-signature-guard/reports/release-signature-summary.svg create mode 100644 repository-release-signature-guard/sample-data.js create mode 100644 repository-release-signature-guard/test.js diff --git a/repository-release-signature-guard/README.md b/repository-release-signature-guard/README.md new file mode 100644 index 00000000..8a26fb8b --- /dev/null +++ b/repository-release-signature-guard/README.md @@ -0,0 +1,28 @@ +# Repository Release Signature Guard + +Focused slice for SCIBASE issue #10, Project Repository and Version Control. + +This module validates signed commit, tag, and release attestation evidence before a scientific repository version is published, cited, or exported. It is narrower than broad repository ledgers, fork provenance, semantic tag governance, component-owner approval, merge queue governance, sensitive-artifact scanning, external reference pinning, compute sandbox policy, restore rehearsal, and DOI tombstone guards. + +## What it checks + +- Release tags cannot be published when tag or target commit signatures are missing, expired, or untrusted. +- DOI/citation metadata must reference the same immutable tag and commit digest as the export bundle. +- Attestation subjects must match the release tag, commit, and export manifest hash. +- Component hashes for manuscript, data, code, notebooks, protocols, results, and metadata must be covered by the signed release packet. +- Fork-derived releases must preserve upstream attribution in the signed provenance packet. +- Reviewer packets receive deterministic publish, hold, or re-sign decisions. + +## Local verification + +```bash +node repository-release-signature-guard/test.js +node repository-release-signature-guard/demo.js +node repository-release-signature-guard/make-demo-video.js +``` + +Generated reviewer artifacts are written to `repository-release-signature-guard/reports/`. + +## Safety + +Fixtures are synthetic. The module does not call GitHub, GPG, Sigstore, DataCite, Crossref, package registries, wallets, credential stores, or external services. diff --git a/repository-release-signature-guard/demo.js b/repository-release-signature-guard/demo.js new file mode 100644 index 00000000..d73872e0 --- /dev/null +++ b/repository-release-signature-guard/demo.js @@ -0,0 +1,18 @@ +const fs = require("fs"); +const path = require("path"); +const { releases } = require("./sample-data"); +const { reviewReleases, renderMarkdownReport, renderSvgReport } = require("./index"); + +function main() { + const report = reviewReleases(releases); + const reportDir = path.join(__dirname, "reports"); + fs.mkdirSync(reportDir, { recursive: true }); + fs.writeFileSync(path.join(reportDir, "release-signature-review.json"), `${JSON.stringify(report, null, 2)}\n`); + fs.writeFileSync(path.join(reportDir, "release-signature-review.md"), renderMarkdownReport(report)); + fs.writeFileSync(path.join(reportDir, "release-signature-summary.svg"), renderSvgReport(report)); + console.log("repository release signature demo generated"); + console.log(`decision summary: ${JSON.stringify(report.summary)}`); + console.log(`reports: ${reportDir}`); +} + +main(); diff --git a/repository-release-signature-guard/index.js b/repository-release-signature-guard/index.js new file mode 100644 index 00000000..0ba82701 --- /dev/null +++ b/repository-release-signature-guard/index.js @@ -0,0 +1,238 @@ +const REQUIRED_COMPONENTS = ["manuscript", "data", "code", "notebooks", "protocols", "results", "metadata"]; +const REVIEW_NOW = Date.parse("2026-05-28T00:00:00Z"); + +function addIssue(issues, severity, code, message, context) { + issues.push({ severity, code, message, context }); +} + +function signatureIsExpired(signature) { + if (!signature || !signature.expiresAt) return false; + return Date.parse(signature.expiresAt) <= REVIEW_NOW; +} + +function checkSignature(release, kind, signature, issues, actions) { + if (!signature || !signature.present) { + addIssue(issues, "critical", `missing-${kind}-signature`, `${kind} signature is missing.`, release.id); + actions.push(`Create a trusted ${kind} signature before publishing ${release.tag}.`); + return; + } + + if (!signature.trustedSigner) { + addIssue(issues, "critical", `untrusted-${kind}-signer`, `${kind} signature signer is not trusted for this repository.`, signature.signer); + actions.push(`Rotate ${kind} signing identity to an approved project signer.`); + } + + if (signatureIsExpired(signature)) { + addIssue(issues, "high", `expired-${kind}-signature`, `${kind} signature expired before release review.`, signature.signer); + actions.push(`Re-sign ${release.tag} with a current trusted key.`); + } +} + +function checkCitationAndBundle(release, issues, actions) { + if (release.exportBundle.tag !== release.tag || release.exportBundle.commit !== release.commit) { + addIssue(issues, "critical", "export-target-drift", "Export bundle tag or commit does not match the release target.", release.exportBundle.manifestHash); + actions.push("Regenerate the export bundle from the signed release target."); + } + + if (release.citation.tag !== release.tag || release.citation.commit !== release.commit) { + addIssue(issues, "high", "citation-target-drift", "Citation metadata references a different tag or commit.", release.citation.doi); + actions.push("Update citation metadata before DOI publication."); + } +} + +function checkAttestation(release, issues, actions) { + const attestation = release.attestation || {}; + if (!attestation.present) { + addIssue(issues, "critical", "missing-release-attestation", "Signed release attestation is missing.", release.id); + actions.push("Generate a release attestation that covers the tag, commit, bundle, and component hashes."); + return; + } + + if (attestation.subjectTag !== release.tag || attestation.subjectCommit !== release.commit) { + addIssue(issues, "critical", "attestation-target-drift", "Attestation subject does not match the release tag and commit.", release.id); + actions.push("Reissue the attestation for the exact signed release target."); + } + + if (attestation.manifestHash !== release.exportBundle.manifestHash) { + addIssue(issues, "critical", "attestation-manifest-drift", "Attestation manifest hash does not match the export bundle.", release.exportBundle.manifestHash); + actions.push("Rebuild or re-sign the export bundle attestation."); + } + + const presentComponents = Object.keys(release.exportBundle.componentHashes || {}); + for (const component of presentComponents) { + if (!(attestation.coveredComponents || []).includes(component)) { + addIssue(issues, "high", "component-not-attested", `Component ${component} is present in the bundle but absent from the attestation.`, component); + actions.push(`Add ${component} hash evidence to the release attestation.`); + } + } + + const missingCoreComponents = REQUIRED_COMPONENTS.filter((component) => !presentComponents.includes(component)); + for (const component of missingCoreComponents) { + addIssue(issues, "low", "optional-component-absent", `Core repository component ${component} is absent from this release bundle.`, component); + } + + if (release.forkedFrom && !(attestation.forkAttribution || []).includes(release.forkedFrom)) { + addIssue(issues, "high", "missing-fork-attribution", "Fork-derived release lacks signed upstream attribution.", release.forkedFrom); + actions.push("Add upstream fork attribution to the signed release provenance packet."); + } +} + +function scoreFromIssues(issues) { + const weights = { critical: 32, high: 14, medium: 7, low: 2 }; + const deduction = issues.reduce((sum, issue) => sum + (weights[issue.severity] || 4), 0); + return Math.max(0, 100 - deduction); +} + +function decisionFromIssues(issues) { + const critical = issues.filter((issue) => issue.severity === "critical").length; + const high = issues.filter((issue) => issue.severity === "high").length; + if (critical > 0) return "hold-release"; + if (high > 0) return "re-sign-before-publication"; + return "publish"; +} + +function reviewRelease(release) { + const issues = []; + const actions = []; + + checkSignature(release, "tag", release.tagSignature, issues, actions); + checkSignature(release, "commit", release.commitSignature, issues, actions); + checkCitationAndBundle(release, issues, actions); + checkAttestation(release, issues, actions); + + if (actions.length === 0) { + actions.push("Publish release, DOI metadata, citation badge, and export bundle from the signed target."); + } + + return { + releaseId: release.id, + repositoryId: release.repositoryId, + tag: release.tag, + commit: release.commit, + doi: release.doi, + decision: decisionFromIssues(issues), + signatureScore: scoreFromIssues(issues), + issueCounts: issues.reduce((counts, issue) => { + counts[issue.severity] = (counts[issue.severity] || 0) + 1; + return counts; + }, {}), + issues, + actions: Array.from(new Set(actions)), + }; +} + +function reviewReleases(releases) { + const results = releases.map(reviewRelease); + const summary = { + releaseCount: results.length, + publishable: results.filter((result) => result.decision === "publish").length, + resignBeforePublication: results.filter((result) => result.decision === "re-sign-before-publication").length, + held: results.filter((result) => result.decision === "hold-release").length, + averageSignatureScore: Math.round(results.reduce((sum, result) => sum + result.signatureScore, 0) / results.length), + }; + + return { + generatedAt: new Date("2026-05-28T00:00:00Z").toISOString(), + requirementMap: [ + "File and metadata versioning: verifies immutable signed tags and commit targets.", + "Repository identifiers and citation: blocks DOI/citation publication when tag or commit evidence drifts.", + "Programmatic access and export: binds export bundle manifests to signed release attestations.", + "Collaboration and forking: preserves upstream fork attribution inside the signed provenance packet.", + ], + summary, + results, + }; +} + +function escapeHtml(value) { + return String(value).replace(/&/g, "&").replace(//g, ">"); +} + +function renderMarkdownReport(report) { + const lines = [ + "# Repository Release Signature Review", + "", + `Generated: ${report.generatedAt}`, + "", + "## Summary", + "", + `- Releases reviewed: ${report.summary.releaseCount}`, + `- Publishable: ${report.summary.publishable}`, + `- Re-sign before publication: ${report.summary.resignBeforePublication}`, + `- Held: ${report.summary.held}`, + `- Average signature score: ${report.summary.averageSignatureScore}`, + "", + "## Requirement Map", + "", + ...report.requirementMap.map((item) => `- ${item}`), + "", + "## Release Decisions", + "", + ]; + + for (const result of report.results) { + lines.push(`### ${result.releaseId}`); + lines.push(""); + lines.push(`- Decision: ${result.decision}`); + lines.push(`- Signature score: ${result.signatureScore}`); + lines.push(`- Tag: ${result.tag}`); + lines.push(`- Commit: ${result.commit}`); + lines.push(`- Issues: ${result.issues.length}`); + for (const action of result.actions) { + lines.push(`- Action: ${action}`); + } + lines.push(""); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function renderSvgReport(report) { + const width = 1120; + const rowHeight = 88; + const height = 150 + report.results.length * rowHeight; + const rows = report.results + .map((result, index) => { + const y = 112 + index * rowHeight; + const color = + result.decision === "publish" + ? "#2f9e44" + : result.decision === "re-sign-before-publication" + ? "#f08c00" + : "#d6336c"; + const barWidth = Math.max(24, Math.round(result.signatureScore * 5.2)); + return ` + + ${escapeHtml(result.releaseId)} + Decision: ${escapeHtml(result.decision)} | Issues: ${result.issues.length} + + + ${result.signatureScore} + `; + }) + .join("\n"); + + return ` + + + Repository Release Signature Guard + Checks signed tags, commits, attestations, DOI metadata, and export bundle parity. +${rows} + +`; +} + +module.exports = { + reviewRelease, + reviewReleases, + renderMarkdownReport, + renderSvgReport, + signatureIsExpired, +}; diff --git a/repository-release-signature-guard/make-demo-video.js b/repository-release-signature-guard/make-demo-video.js new file mode 100644 index 00000000..6e3737a4 --- /dev/null +++ b/repository-release-signature-guard/make-demo-video.js @@ -0,0 +1,67 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const { releases } = require("./sample-data"); +const { reviewReleases } = require("./index"); + +function ffmpegCandidates() { + return ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"]; +} + +function makeVideo(output) { + const report = reviewReleases(releases); + const publishWidth = 120 + report.summary.publishable * 170; + const resignWidth = 120 + report.summary.resignBeforePublication * 170; + const holdWidth = 120 + report.summary.held * 170; + const filter = [ + "drawbox=x=0:y=0:w=1280:h=720:color=0x0b1020@1:t=fill", + "drawbox=x=80:y=110:w=1120:h=8:color=0x4dabf7@1:t=fill", + `drawbox=x=120:y=220:w=${publishWidth}:h=90:color=0x2f9e44@1:t=fill`, + `drawbox=x=120:y=340:w=${resignWidth}:h=90:color=0xf08c00@1:t=fill`, + `drawbox=x=120:y=460:w=${holdWidth}:h=90:color=0xd6336c@1:t=fill`, + "drawbox=x=120:y=590:w=1020:h=24:color=0x495057@1:t=fill", + ].join(","); + + const errors = []; + for (const ffmpeg of ffmpegCandidates()) { + const result = spawnSync( + ffmpeg, + [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=black:s=1280x720:r=12:d=4", + "-vf", + filter, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output, + ], + { encoding: "utf8" }, + ); + + if (result.status === 0 && fs.existsSync(output) && fs.statSync(output).size > 1000) { + return; + } + + const stderr = (result.stderr || result.error || "").toString(); + errors.push(`${ffmpeg}: ${stderr.split("\n").slice(-8).join("\n")}`); + } + + throw new Error(`ffmpeg failed to generate demo video:\n${errors.join("\n")}`); +} + +function main() { + const reportDir = path.join(__dirname, "reports"); + fs.mkdirSync(reportDir, { recursive: true }); + const output = path.join(reportDir, "demo.mp4"); + makeVideo(output); + console.log(`demo video generated: ${output}`); +} + +main(); diff --git a/repository-release-signature-guard/reports/demo.mp4 b/repository-release-signature-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..30d8fde7b976e8be9f78fe709f9162bbc207642c GIT binary patch literal 4809 zcmeHJdsGzH8Nb7_s1y_xQ^eO0QP8l{4sv1FMOxrao%%s0U+>F;;x^5D(GT_$o0;Nlb%mzdN%EqJ=#@Jw5%S z_spI9-S2gO_xtYsW)MQ8U9J?I67NDN1mO|7iK?KDE+N&3kXCl_d<8;?b5%GP7`rvi zHG~G$BLd;4t35CQc8_^Zm*%6W5QIX#vPhMJ@CCV4SyR(ZG2ji>?b&*)^Qd*TU=wm$ zF^p#!SpsS#%Sm#;6?njY`v)+GKIUSa6v#*y({t|zU^k-KM-@w*gW*Jlf>(u;Veip+ zoC9+{pK>!Ci*+=IT~4+VxwXz{l5fy5m*Vpsv_rmOo1?-7sbeC?j%7-i|^60>LZ;q$fPVoif|K(~t z{@wUMl5uHJ<2e#?Lt_TCKyGl}6#6X47AX?-1$^xxASXfBK~I7{4!RzCLV*5XpTk+C zdBBPr>{q)PMq4=}Q!vhRvC4>LaosBbR1J|51bj*FH=R>0Gvl_&Ja#yWp%h~rL1m{xVltUXTS{8GA&mk-z8(~k3yboe(xsA?Ik}+8urvsAd7+YH?J{XH7*cd5gDDM| z4p|nml9LxMT&TwtjOQq~o)=4!@hWapyU<5DH<@B(O z6?-&-g;k{7C9K()PSOsMcTrX_HIlN(a-35FD$|q6&@vEonagYd2o$r3ce7@bG0A8o z?UW>21*z02U=7tlnPBDZc8QhEIuj{7M6duynIy;arIZ6^)_cqpQsSI6fVsyokZw^~ zgm$_p83S>;Wme=UPy)V;*f11;|11jE#U=_s!Rb)OW8{BZ8oJD0;Cnb zD`umKlxWt?(&e%_)qr7&6ppFLN)9j=Y3u#%V!240Y0*x)pgOa7TQD)3QuGG03~$D4 z(5C|_V8_ZxdCcjPfFa4OU`{2S0%QqJ1SAoBQDtxda5_;nd=~n#%a)+Q{?5;?bcDOM zAHLljBh5K4Zn}1U>D-vto_zJ%Nv6tLgufy8pT9lkSnEy5{if=&!V67Anu#dXfxhQK zW1r{S1`R6?`Ei?n9=GDd!V5zdpIKL1@%+m66DOK3ZHU%ul4p(jI%`nIa_;6|+VJwI zP%`AO6vU3o!XU`rzqjo~sG9uRf zZTPC3@T3JXM1o~7GRYM=(J!CXCJe~E@k&46OO509CPe&h^UjX=bFzmxx#einsNuQ} z&*XQW99=c~hc%ZkK5^<){Pt14$c={d_d34O7q%WCr=2TZ^UT?V59SPP-cXDX`Efi# z>@QYp-pa^c)_=?0@khSDX<*IF6$hVces2DbOC5K$M_0Bl%_m`}nhlO$(#<)jK5=R_ z5?YFpBflOU`-GYD#`JM-Ivkrz-P7{d?oOySeR?e-wH;?{Xs|KG?@pU#Zy@v4y+w<+=q$2lfw+I;4B? zz`CZjx9LNzh%@ha>pIfp{WNw-U089*!JiCRehJ0YMP=q*)oy*fA@tWr<_}1y+uqc+ z#@@0tY5MAI%^^eo^wII;Nb9Jyy}vEF@H3pJiD8lAloxKSE}!hxyt7MNGOy{{&VsAs zk9W{pH+MF)wN$J;+&uHUYU0@2#@A;;_5Q)Fu<)gmhQu}f{qpe=-ib%P$#3I zReS1*UC%B7E#fOMoM7=z@zla!*aMD3o)|tMhBM4)Kk{5aQnMJ86A3RR=Vtd*-t8f_ zckZ5g3W5S^A<<=l7oP^AvHL7YV>^11P8CIS9ogld{Qv*Ji);WM6a$7 zc(>KS?CBL=AbRij3QxTn8x98G#rZq8&+a~Z#D@Q$G@|JUo@=Yiyw_&dE({}SE-{Lva~@kIs|J)ge! GWA#rU1cwm- literal 0 HcmV?d00001 diff --git a/repository-release-signature-guard/reports/release-signature-review.json b/repository-release-signature-guard/reports/release-signature-review.json new file mode 100644 index 00000000..d9e9089f --- /dev/null +++ b/repository-release-signature-guard/reports/release-signature-review.json @@ -0,0 +1,171 @@ +{ + "generatedAt": "2026-05-28T00:00:00.000Z", + "requirementMap": [ + "File and metadata versioning: verifies immutable signed tags and commit targets.", + "Repository identifiers and citation: blocks DOI/citation publication when tag or commit evidence drifts.", + "Programmatic access and export: binds export bundle manifests to signed release attestations.", + "Collaboration and forking: preserves upstream fork attribution inside the signed provenance packet." + ], + "summary": { + "releaseCount": 4, + "publishable": 1, + "resignBeforePublication": 0, + "held": 3, + "averageSignatureScore": 47 + }, + "results": [ + { + "releaseId": "rel-clean-preprint-v2", + "repositoryId": "neuro-imaging-atlas", + "tag": "preprint-v2.0.0", + "commit": "9f41c2a7", + "doi": "10.5555/scibase.neuro-atlas.v2", + "decision": "publish", + "signatureScore": 100, + "issueCounts": {}, + "issues": [], + "actions": [ + "Publish release, DOI metadata, citation badge, and export bundle from the signed target." + ] + }, + { + "releaseId": "rel-unsigned-tag", + "repositoryId": "catalyst-screening", + "tag": "v1.0.0", + "commit": "11aa22bb", + "doi": "10.5555/scibase.catalyst.v1", + "decision": "hold-release", + "signatureScore": 64, + "issueCounts": { + "critical": 1, + "low": 2 + }, + "issues": [ + { + "severity": "critical", + "code": "missing-tag-signature", + "message": "tag signature is missing.", + "context": "rel-unsigned-tag" + }, + { + "severity": "low", + "code": "optional-component-absent", + "message": "Core repository component protocols is absent from this release bundle.", + "context": "protocols" + }, + { + "severity": "low", + "code": "optional-component-absent", + "message": "Core repository component results is absent from this release bundle.", + "context": "results" + } + ], + "actions": [ + "Create a trusted tag signature before publishing v1.0.0." + ] + }, + { + "releaseId": "rel-attestation-drift", + "repositoryId": "rna-benchmark", + "tag": "v1.4.1", + "commit": "abc123ef", + "doi": "10.5555/scibase.rna-benchmark.v1", + "decision": "hold-release", + "signatureScore": 24, + "issueCounts": { + "high": 3, + "critical": 1, + "low": 1 + }, + "issues": [ + { + "severity": "high", + "code": "citation-target-drift", + "message": "Citation metadata references a different tag or commit.", + "context": "10.5555/scibase.rna-benchmark.v1" + }, + { + "severity": "critical", + "code": "attestation-manifest-drift", + "message": "Attestation manifest hash does not match the export bundle.", + "context": "sha256:bundle-rna-current" + }, + { + "severity": "high", + "code": "component-not-attested", + "message": "Component notebooks is present in the bundle but absent from the attestation.", + "context": "notebooks" + }, + { + "severity": "high", + "code": "component-not-attested", + "message": "Component results is present in the bundle but absent from the attestation.", + "context": "results" + }, + { + "severity": "low", + "code": "optional-component-absent", + "message": "Core repository component protocols is absent from this release bundle.", + "context": "protocols" + } + ], + "actions": [ + "Update citation metadata before DOI publication.", + "Rebuild or re-sign the export bundle attestation.", + "Add notebooks hash evidence to the release attestation.", + "Add results hash evidence to the release attestation." + ] + }, + { + "releaseId": "rel-expired-untrusted-signer", + "repositoryId": "materials-protocols", + "tag": "release-2026-06", + "commit": "ffee1122", + "doi": "10.5555/scibase.materials.release.2026", + "decision": "hold-release", + "signatureScore": 0, + "issueCounts": { + "critical": 3, + "high": 2 + }, + "issues": [ + { + "severity": "critical", + "code": "untrusted-tag-signer", + "message": "tag signature signer is not trusted for this repository.", + "context": "external:unknown" + }, + { + "severity": "high", + "code": "expired-tag-signature", + "message": "tag signature expired before release review.", + "context": "external:unknown" + }, + { + "severity": "critical", + "code": "untrusted-commit-signer", + "message": "commit signature signer is not trusted for this repository.", + "context": "external:unknown" + }, + { + "severity": "high", + "code": "expired-commit-signature", + "message": "commit signature expired before release review.", + "context": "external:unknown" + }, + { + "severity": "critical", + "code": "missing-release-attestation", + "message": "Signed release attestation is missing.", + "context": "rel-expired-untrusted-signer" + } + ], + "actions": [ + "Rotate tag signing identity to an approved project signer.", + "Re-sign release-2026-06 with a current trusted key.", + "Rotate commit signing identity to an approved project signer.", + "Generate a release attestation that covers the tag, commit, bundle, and component hashes." + ] + } + ] +} diff --git a/repository-release-signature-guard/reports/release-signature-review.md b/repository-release-signature-guard/reports/release-signature-review.md new file mode 100644 index 00000000..8d4735df --- /dev/null +++ b/repository-release-signature-guard/reports/release-signature-review.md @@ -0,0 +1,62 @@ +# Repository Release Signature Review + +Generated: 2026-05-28T00:00:00.000Z + +## Summary + +- Releases reviewed: 4 +- Publishable: 1 +- Re-sign before publication: 0 +- Held: 3 +- Average signature score: 47 + +## Requirement Map + +- File and metadata versioning: verifies immutable signed tags and commit targets. +- Repository identifiers and citation: blocks DOI/citation publication when tag or commit evidence drifts. +- Programmatic access and export: binds export bundle manifests to signed release attestations. +- Collaboration and forking: preserves upstream fork attribution inside the signed provenance packet. + +## Release Decisions + +### rel-clean-preprint-v2 + +- Decision: publish +- Signature score: 100 +- Tag: preprint-v2.0.0 +- Commit: 9f41c2a7 +- Issues: 0 +- Action: Publish release, DOI metadata, citation badge, and export bundle from the signed target. + +### rel-unsigned-tag + +- Decision: hold-release +- Signature score: 64 +- Tag: v1.0.0 +- Commit: 11aa22bb +- Issues: 3 +- Action: Create a trusted tag signature before publishing v1.0.0. + +### rel-attestation-drift + +- Decision: hold-release +- Signature score: 24 +- Tag: v1.4.1 +- Commit: abc123ef +- Issues: 5 +- Action: Update citation metadata before DOI publication. +- Action: Rebuild or re-sign the export bundle attestation. +- Action: Add notebooks hash evidence to the release attestation. +- Action: Add results hash evidence to the release attestation. + +### rel-expired-untrusted-signer + +- Decision: hold-release +- Signature score: 0 +- Tag: release-2026-06 +- Commit: ffee1122 +- Issues: 5 +- Action: Rotate tag signing identity to an approved project signer. +- Action: Re-sign release-2026-06 with a current trusted key. +- Action: Rotate commit signing identity to an approved project signer. +- Action: Generate a release attestation that covers the tag, commit, bundle, and component hashes. diff --git a/repository-release-signature-guard/reports/release-signature-summary.svg b/repository-release-signature-guard/reports/release-signature-summary.svg new file mode 100644 index 00000000..c5461392 --- /dev/null +++ b/repository-release-signature-guard/reports/release-signature-summary.svg @@ -0,0 +1,45 @@ + + + + Repository Release Signature Guard + Checks signed tags, commits, attestations, DOI metadata, and export bundle parity. + + + rel-clean-preprint-v2 + Decision: publish | Issues: 0 + + + 100 + + + + rel-unsigned-tag + Decision: hold-release | Issues: 3 + + + 64 + + + + rel-attestation-drift + Decision: hold-release | Issues: 5 + + + 24 + + + + rel-expired-untrusted-signer + Decision: hold-release | Issues: 5 + + + 0 + + diff --git a/repository-release-signature-guard/sample-data.js b/repository-release-signature-guard/sample-data.js new file mode 100644 index 00000000..2ea492b0 --- /dev/null +++ b/repository-release-signature-guard/sample-data.js @@ -0,0 +1,131 @@ +const releases = [ + { + id: "rel-clean-preprint-v2", + repositoryId: "neuro-imaging-atlas", + tag: "preprint-v2.0.0", + commit: "9f41c2a7", + doi: "10.5555/scibase.neuro-atlas.v2", + tagSignature: { present: true, trustedSigner: true, signer: "orcid:0000-0002-0000-0001", expiresAt: "2027-01-01T00:00:00Z" }, + commitSignature: { present: true, trustedSigner: true, signer: "orcid:0000-0002-0000-0001", expiresAt: "2027-01-01T00:00:00Z" }, + exportBundle: { + manifestHash: "sha256:bundle-clean", + tag: "preprint-v2.0.0", + commit: "9f41c2a7", + componentHashes: { + manuscript: "sha256:manuscript-v2", + data: "sha256:data-v2", + code: "sha256:code-v2", + notebooks: "sha256:notebooks-v2", + protocols: "sha256:protocols-v2", + results: "sha256:results-v2", + metadata: "sha256:metadata-v2", + }, + }, + citation: { tag: "preprint-v2.0.0", commit: "9f41c2a7", doi: "10.5555/scibase.neuro-atlas.v2" }, + attestation: { + present: true, + subjectTag: "preprint-v2.0.0", + subjectCommit: "9f41c2a7", + manifestHash: "sha256:bundle-clean", + coveredComponents: ["manuscript", "data", "code", "notebooks", "protocols", "results", "metadata"], + forkAttribution: ["upstream-lab/neuro-atlas"], + }, + forkedFrom: "upstream-lab/neuro-atlas", + }, + { + id: "rel-unsigned-tag", + repositoryId: "catalyst-screening", + tag: "v1.0.0", + commit: "11aa22bb", + doi: "10.5555/scibase.catalyst.v1", + tagSignature: { present: false, trustedSigner: false, signer: null, expiresAt: null }, + commitSignature: { present: true, trustedSigner: true, signer: "orcid:0000-0002-0000-0002", expiresAt: "2027-01-01T00:00:00Z" }, + exportBundle: { + manifestHash: "sha256:bundle-catalyst", + tag: "v1.0.0", + commit: "11aa22bb", + componentHashes: { + manuscript: "sha256:cat-ms", + data: "sha256:cat-data", + code: "sha256:cat-code", + notebooks: "sha256:cat-notebooks", + metadata: "sha256:cat-meta", + }, + }, + citation: { tag: "v1.0.0", commit: "11aa22bb", doi: "10.5555/scibase.catalyst.v1" }, + attestation: { + present: true, + subjectTag: "v1.0.0", + subjectCommit: "11aa22bb", + manifestHash: "sha256:bundle-catalyst", + coveredComponents: ["manuscript", "data", "code", "notebooks", "metadata"], + forkAttribution: [], + }, + forkedFrom: null, + }, + { + id: "rel-attestation-drift", + repositoryId: "rna-benchmark", + tag: "v1.4.1", + commit: "abc123ef", + doi: "10.5555/scibase.rna-benchmark.v1", + tagSignature: { present: true, trustedSigner: true, signer: "orcid:0000-0002-0000-0003", expiresAt: "2027-01-01T00:00:00Z" }, + commitSignature: { present: true, trustedSigner: true, signer: "orcid:0000-0002-0000-0003", expiresAt: "2027-01-01T00:00:00Z" }, + exportBundle: { + manifestHash: "sha256:bundle-rna-current", + tag: "v1.4.1", + commit: "abc123ef", + componentHashes: { + manuscript: "sha256:rna-ms", + data: "sha256:rna-data", + code: "sha256:rna-code", + notebooks: "sha256:rna-notebooks", + results: "sha256:rna-results", + metadata: "sha256:rna-meta", + }, + }, + citation: { tag: "v1.4.0", commit: "abc123ef", doi: "10.5555/scibase.rna-benchmark.v1" }, + attestation: { + present: true, + subjectTag: "v1.4.1", + subjectCommit: "abc123ef", + manifestHash: "sha256:bundle-rna-old", + coveredComponents: ["manuscript", "data", "code", "metadata"], + forkAttribution: [], + }, + forkedFrom: null, + }, + { + id: "rel-expired-untrusted-signer", + repositoryId: "materials-protocols", + tag: "release-2026-06", + commit: "ffee1122", + doi: "10.5555/scibase.materials.release.2026", + tagSignature: { present: true, trustedSigner: false, signer: "external:unknown", expiresAt: "2026-01-01T00:00:00Z" }, + commitSignature: { present: true, trustedSigner: false, signer: "external:unknown", expiresAt: "2026-01-01T00:00:00Z" }, + exportBundle: { + manifestHash: "sha256:bundle-materials", + tag: "release-2026-06", + commit: "ffee1122", + componentHashes: { + manuscript: "sha256:mat-ms", + data: "sha256:mat-data", + code: "sha256:mat-code", + protocols: "sha256:mat-protocols", + metadata: "sha256:mat-meta", + }, + }, + citation: { tag: "release-2026-06", commit: "ffee1122", doi: "10.5555/scibase.materials.release.2026" }, + attestation: { + present: false, + subjectTag: null, + subjectCommit: null, + manifestHash: null, + coveredComponents: [], + forkAttribution: [], + }, + forkedFrom: null, + }, +]; + +module.exports = { releases }; diff --git a/repository-release-signature-guard/test.js b/repository-release-signature-guard/test.js new file mode 100644 index 00000000..243270bf --- /dev/null +++ b/repository-release-signature-guard/test.js @@ -0,0 +1,39 @@ +const assert = require("assert"); +const { releases } = require("./sample-data"); +const { reviewReleases, signatureIsExpired } = require("./index"); + +function byId(report, id) { + return report.results.find((result) => result.releaseId === id); +} + +function runTests() { + assert.strictEqual(signatureIsExpired({ expiresAt: "2027-01-01T00:00:00Z" }), false); + assert.strictEqual(signatureIsExpired({ expiresAt: "2026-01-01T00:00:00Z" }), true); + + const report = reviewReleases(releases); + assert.strictEqual(report.summary.releaseCount, 4); + + const clean = byId(report, "rel-clean-preprint-v2"); + assert.strictEqual(clean.decision, "publish"); + assert.strictEqual(clean.signatureScore, 100); + + const unsigned = byId(report, "rel-unsigned-tag"); + assert.strictEqual(unsigned.decision, "hold-release"); + assert.ok(unsigned.issues.some((issue) => issue.code === "missing-tag-signature")); + + const drift = byId(report, "rel-attestation-drift"); + assert.strictEqual(drift.decision, "hold-release"); + assert.ok(drift.issues.some((issue) => issue.code === "citation-target-drift")); + assert.ok(drift.issues.some((issue) => issue.code === "attestation-manifest-drift")); + assert.ok(drift.issues.some((issue) => issue.code === "component-not-attested")); + + const expired = byId(report, "rel-expired-untrusted-signer"); + assert.strictEqual(expired.decision, "hold-release"); + assert.ok(expired.issues.some((issue) => issue.code === "untrusted-tag-signer")); + assert.ok(expired.issues.some((issue) => issue.code === "expired-tag-signature")); + assert.ok(expired.issues.some((issue) => issue.code === "missing-release-attestation")); + + console.log("repository release signature guard tests passed"); +} + +runTests();