mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-06 15:39:07 +00:00
docs(git): publish a versioned Git transport support matrix (#1883)
* fix(git): make gitSourceStatus exhaustive over GitSourceErrorCode GIT_ERROR was the only code falling through the implicit default branch. Give it an explicit case and add the same never-guard webhookPullStatus already uses, so a future code with no mapping is a compile error instead of a silent 400. * fix(git): fail loudly under CI when git or sshd is missing Every real-git and real-sshd integration suite carried its own local gitAvailable()/sshdAvailable() probe and skipped silently when the dependency was absent, in CI as well as locally. A cell in the upcoming support matrix could then advertise automated proof while the test that proves it never ran. Consolidate into shared requireGitBinary()/requireSshd() helpers (one for backend vitest, one for Playwright, since backend's rootDir pin blocks a cross-directory import) that take an injectable probe. Locally a missing dependency still skips; under CI it throws with an actionable message naming what's missing. * feat(docs): publish a versioned Git transport support matrix Adds docs/git-transport-support.yaml as the canonical claim set for every transport/ref/auth/host/CA combination Git Sources supports, each claim naming its own reproducible evidence rather than generalizing from a related test. A claim is supported only when a real end-to-end test (or a dated live attestation) proves that exact combination; everything else is marked unverified, never assumed. The published page (docs/features/git-transport-support.mdx) is generated from the YAML by backend/scripts/git-support-matrix, so it cannot silently drift from what the tests actually prove. A new backend test (git-support-matrix.test.ts) enforces this: schema validity, evidence semantics (supported needs success evidence, unsupported needs a reproducible rejection, unverified forbids evidence entirely), byte-identical page generation, and that every referenced test title resolves via the TypeScript AST rather than a string search that a skipped or commented-out test would pass. The error-model section is cross-checked against the real GitSourceErrorCode and TransportFacingCode unions and against gitSourceStatus's actual HTTP mapping, so the matrix and the runtime behavior cannot diverge either. Named Git hosts (GitHub, GitLab, Gitea, Forgejo, Bitbucket) and the direct-proxy/Pilot execution paths are seeded as unverified pending a live attestation pass; only the generic local-fixture combinations already proven by the real-git integration suites are marked supported today. * docs(git): scope GitHub claims to what this pass can actually attest Splits the GitHub row into a public no-auth claim (attestable with a real public repository) and separate PAT/SSH deploy-key claims marked unverified with an explicit reason: this pass holds no real GitHub credential to exercise them with, and none is assumed or fabricated. * feat(docs): attest the Git transport matrix live against real hosts Runs the QA fleet's live Sencho instance through the transport combinations that automated fixtures cannot exercise, then records each result in docs/git-transport-attestations.yaml so it can be re-run and compared later. GitHub, GitLab, and Bitbucket are attested over public HTTPS against real, stable, publicly-owned demo repositories (branch and pinned SHA; GitLab additionally has a tagged fixture). Gitea and Forgejo get full coverage (branch, tag, and SHA, over both HTTPS with a per-source CA and SSH with a deploy key) against disposable self-hosted instances stood up for this pass, including a private repository so the authentication and host-key failure classifiers were exercised against a real wrong credential and a real wrong host key, not just the mocked corpus. The direct-proxy and Pilot execution paths are each confirmed once against a real public host, proving the distributed dispatch itself rather than assuming it from the local-path evidence. Left honestly unverified: GitHub PAT and SSH deploy-key auth (this pass holds no real GitHub credential), a GitHub tag combination (no small stable tagged fixture found), and a Bitbucket tag combination (the fixture repository carries none). Every claim's evidence records its exact transport, ref, auth, host, CA, and node path so nothing here is extrapolated from a neighboring result. All infrastructure created for this pass (two throwaway Git server containers, one probe stack) was torn down afterward and the fleet's container list was confirmed to match its state before the pass. * style(git): replace em dashes and fix a stale .mjs reference Directive 18 applies to code comments and build markers too, not just prose. Also corrects the claim set's header comment, which still named render.mjs after the renderer was moved to render.js to match the house convention for backend scripts.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// Generates the tables in docs/features/git-transport-support.mdx from
|
||||
// docs/git-transport-support.yaml.
|
||||
//
|
||||
// Everything between the GENERATED markers is produced here; the rest of the
|
||||
// MDX file (intro prose, the "How these claims are verified" section) is
|
||||
// hand-written and left untouched. The validator (git-support-matrix.test.ts)
|
||||
// imports renderFullMdx and asserts the committed file is byte-identical to
|
||||
// what it produces, so the page can never silently drift from the YAML.
|
||||
const fs = require('fs');
|
||||
const { loadClaimSet, MDX_PATH } = require('./loadClaimSet');
|
||||
|
||||
const MARKER_BEGIN = '<!-- GENERATED:BEGIN (run `npm run matrix:render` in backend/ to regenerate, do not edit by hand) -->';
|
||||
const MARKER_END = '<!-- GENERATED:END -->';
|
||||
|
||||
const TRANSPORT_LABELS = { https: 'HTTPS', ssh: 'SSH' };
|
||||
const TRANSPORT_NOTES = {
|
||||
https: 'Personal Access Token for private repositories, or no credential at all for public ones. TLS verification uses the system trust store by default, or a per-source custom CA when configured.',
|
||||
ssh: 'A read-only deploy key with strict host-key verification. Standard (22) and nonstandard ports are both supported.',
|
||||
};
|
||||
|
||||
const REF_LABELS = { branch: 'Branch', tag: 'Tag', sha: 'Commit SHA' };
|
||||
const REF_NOTES = {
|
||||
branch: 'Tracks the head of a branch; each pull resolves and pins the exact commit.',
|
||||
tag: 'Both annotated and lightweight tags resolve to their target commit.',
|
||||
sha: 'A full commit SHA is pinned directly; the Git host must advertise the commit on some branch or tag.',
|
||||
};
|
||||
|
||||
const AUTH_LABELS = { none: 'Public (no auth)', pat: 'Personal Access Token', 'deploy-key': 'SSH deploy key' };
|
||||
const AUTH_NOTES = {
|
||||
none: 'For public repositories.',
|
||||
pat: 'Stored encrypted at rest, never returned after save.',
|
||||
'deploy-key': 'Stored encrypted at rest; the server host key is verified on every fetch.',
|
||||
};
|
||||
|
||||
const CA_LABELS = { system: 'System trust (default)', 'per-source': 'Per-source custom CA', 'not-applicable': 'Not applicable' };
|
||||
const CA_NOTES = {
|
||||
system: 'The host running the fetch trusts its system certificate store.',
|
||||
'per-source': "Combined with the system trust anchors, so public hosts keep validating normally. Redirects are re-resolved and only followed when they stay on the source's own host.",
|
||||
'not-applicable': 'SSH uses host-key verification instead of TLS certificate trust.',
|
||||
};
|
||||
|
||||
const HOST_LABELS = {
|
||||
generic: 'Generic (self-hosted or any Git server)',
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
forgejo: 'Forgejo',
|
||||
bitbucket: 'Bitbucket',
|
||||
};
|
||||
const HOST_ORDER = ['generic', 'github', 'gitlab', 'gitea', 'forgejo', 'bitbucket'];
|
||||
|
||||
const STATUS_LABELS = { supported: 'Supported', unsupported: 'Not supported', unverified: 'Not yet verified' };
|
||||
|
||||
function aggregateStatus(claims) {
|
||||
if (claims.length === 0) return 'unverified';
|
||||
if (claims.some((c) => c.support === 'unsupported')) return 'unsupported';
|
||||
if (claims.some((c) => c.support === 'supported')) return 'supported';
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
function evidenceSummary(claims, attestationsById) {
|
||||
const kinds = new Set();
|
||||
let latestDate = null;
|
||||
for (const c of claims) {
|
||||
if (c.support !== 'supported' || !c.evidence) continue;
|
||||
if (c.evidence.kind === 'automated') kinds.add('automated');
|
||||
if (c.evidence.kind === 'live') {
|
||||
kinds.add('live');
|
||||
const att = attestationsById.get(c.evidence.attestation);
|
||||
if (att && (!latestDate || att.date > latestDate)) latestDate = att.date;
|
||||
}
|
||||
}
|
||||
if (kinds.size === 0) return 'Pending';
|
||||
if (kinds.has('automated') && kinds.has('live')) return `Automated, every change; live as of ${latestDate}`;
|
||||
if (kinds.has('automated')) return 'Automated, every change';
|
||||
return `Live, ${latestDate}`;
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
const sep = headers.map(() => '---');
|
||||
return [
|
||||
`| ${headers.join(' | ')} |`,
|
||||
`| ${sep.join(' | ')} |`,
|
||||
...rows.map((r) => `| ${r.join(' | ')} |`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderTransports(claims) {
|
||||
const rows = Object.keys(TRANSPORT_LABELS).map((t) => {
|
||||
const group = claims.filter((c) => c.transport === t);
|
||||
return [TRANSPORT_LABELS[t], STATUS_LABELS[aggregateStatus(group)], TRANSPORT_NOTES[t]];
|
||||
});
|
||||
return ['## Transports', '', table(['Transport', 'Status', 'Notes'], rows)].join('\n');
|
||||
}
|
||||
|
||||
function renderRefs(claims) {
|
||||
const rows = Object.keys(REF_LABELS).map((r) => {
|
||||
const group = claims.filter((c) => c.ref === r);
|
||||
return [REF_LABELS[r], STATUS_LABELS[aggregateStatus(group)], REF_NOTES[r]];
|
||||
});
|
||||
return ['## Reference types', '', table(['Reference type', 'Status', 'Notes'], rows)].join('\n');
|
||||
}
|
||||
|
||||
function renderAuth(claims) {
|
||||
const rows = Object.keys(AUTH_LABELS).map((a) => {
|
||||
const group = claims.filter((c) => c.auth === a);
|
||||
return [AUTH_LABELS[a], STATUS_LABELS[aggregateStatus(group)], AUTH_NOTES[a]];
|
||||
});
|
||||
return ['## Authentication', '', table(['Method', 'Status', 'Notes'], rows)].join('\n');
|
||||
}
|
||||
|
||||
const CA_TABLE_MODES = ['system', 'per-source'];
|
||||
|
||||
function renderCa(claims) {
|
||||
const rows = CA_TABLE_MODES.map((c) => {
|
||||
const group = claims.filter((claim) => claim.ca === c);
|
||||
return [CA_LABELS[c], STATUS_LABELS[aggregateStatus(group)], CA_NOTES[c]];
|
||||
});
|
||||
return ['## TLS and certificate authorities', '', table(['Mode', 'Status', 'Notes'], rows)].join('\n');
|
||||
}
|
||||
|
||||
function renderHosts(claims, attestationsById) {
|
||||
const rows = HOST_ORDER.map((host) => {
|
||||
const group = claims.filter((c) => c.host === host);
|
||||
const httpsGroup = group.filter((c) => c.transport === 'https');
|
||||
const sshGroup = group.filter((c) => c.transport === 'ssh');
|
||||
const branchGroup = group.filter((c) => c.ref === 'branch');
|
||||
const tagGroup = group.filter((c) => c.ref === 'tag');
|
||||
const shaGroup = group.filter((c) => c.ref === 'sha');
|
||||
return [
|
||||
HOST_LABELS[host],
|
||||
STATUS_LABELS[aggregateStatus(httpsGroup)],
|
||||
STATUS_LABELS[aggregateStatus(sshGroup)],
|
||||
STATUS_LABELS[aggregateStatus(branchGroup)],
|
||||
STATUS_LABELS[aggregateStatus(tagGroup)],
|
||||
STATUS_LABELS[aggregateStatus(shaGroup)],
|
||||
evidenceSummary(group, attestationsById),
|
||||
];
|
||||
});
|
||||
return [
|
||||
'## Git hosts',
|
||||
'',
|
||||
table(['Host', 'HTTPS', 'SSH', 'Branch', 'Tag', 'Commit SHA', 'Evidence'], rows),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderLimitations(limitations) {
|
||||
const bullets = limitations.map((l) => `- **${l.title}.** ${l.statement}`);
|
||||
return ['## Not supported', '', ...bullets].join('\n');
|
||||
}
|
||||
|
||||
function renderGeneratedBlock(data) {
|
||||
const { support, attestations } = data;
|
||||
const attestationsById = new Map((attestations.attestations || []).map((a) => [a.id, a]));
|
||||
const claims = support.claims;
|
||||
return [
|
||||
renderTransports(claims),
|
||||
'',
|
||||
renderRefs(claims),
|
||||
'',
|
||||
renderAuth(claims),
|
||||
'',
|
||||
renderHosts(claims, attestationsById),
|
||||
'',
|
||||
renderCa(claims),
|
||||
'',
|
||||
renderLimitations(support.limitations),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderFullMdx() {
|
||||
const data = loadClaimSet();
|
||||
const generated = renderGeneratedBlock(data);
|
||||
const current = fs.readFileSync(MDX_PATH, 'utf8');
|
||||
|
||||
const beginIdx = current.indexOf(MARKER_BEGIN);
|
||||
const endIdx = current.indexOf(MARKER_END);
|
||||
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
||||
throw new Error(`${MDX_PATH} is missing the GENERATED markers, or they are out of order.`);
|
||||
}
|
||||
|
||||
const before = current.slice(0, beginIdx + MARKER_BEGIN.length);
|
||||
const after = current.slice(endIdx);
|
||||
return `${before}\n\n${generated}\n\n${after}`;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const rendered = renderFullMdx();
|
||||
fs.writeFileSync(MDX_PATH, rendered, 'utf8');
|
||||
console.log(`[matrix:render] Wrote ${MDX_PATH}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MARKER_BEGIN,
|
||||
MARKER_END,
|
||||
renderGeneratedBlock,
|
||||
renderFullMdx,
|
||||
aggregateStatus,
|
||||
};
|
||||
Reference in New Issue
Block a user