mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-05 23:26:06 +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:
@@ -13,6 +13,7 @@
|
||||
"test:docker-integration": "vitest run --config vitest.docker-integration.config.ts",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src",
|
||||
"matrix:render": "node scripts/git-support-matrix/render.js",
|
||||
"reset-mfa": "node dist/cli/resetMfa.js",
|
||||
"reset-password": "node dist/cli/resetPassword.js",
|
||||
"create-emergency-admin": "node dist/cli/createEmergencyAdmin.js",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared YAML loader for the Git transport support matrix.
|
||||
//
|
||||
// Used by both render.js (the CLI/build-time generator) and
|
||||
// git-support-matrix.test.ts (the validator), so the two never parse the
|
||||
// source files differently.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parse } = require('yaml');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const SUPPORT_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-support.yaml');
|
||||
const ATTESTATIONS_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-attestations.yaml');
|
||||
const MDX_PATH = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx');
|
||||
|
||||
function loadClaimSet() {
|
||||
const supportRaw = fs.readFileSync(SUPPORT_YAML_PATH, 'utf8');
|
||||
const attestationsRaw = fs.readFileSync(ATTESTATIONS_YAML_PATH, 'utf8');
|
||||
return {
|
||||
support: parse(supportRaw),
|
||||
attestations: parse(attestationsRaw),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REPO_ROOT,
|
||||
SUPPORT_YAML_PATH,
|
||||
ATTESTATIONS_YAML_PATH,
|
||||
MDX_PATH,
|
||||
loadClaimSet,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Shared availability probes for the real-git and real-sshd integration
|
||||
* suites.
|
||||
*
|
||||
* Every suite used to carry its own copy of `gitAvailable()`/`sshdAvailable()`
|
||||
* and pass the result straight to `describe.skipIf`, so a missing dependency
|
||||
* in CI silently skipped the suite instead of failing the build: proof of a
|
||||
* combination could stop running with nothing in the test output to say so.
|
||||
* These wrappers keep the same local-dev behavior (skip when the dependency
|
||||
* is absent) but throw under CI, where the dependency is expected to be
|
||||
* present and a skip would be a false claim of coverage.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
export type DependencyProbe = () => boolean;
|
||||
|
||||
export const defaultGitProbe: DependencyProbe = () =>
|
||||
spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
export const defaultSshdProbe: DependencyProbe = () =>
|
||||
spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
function requireDependency(name: string, hint: string, probe: DependencyProbe): boolean {
|
||||
const present = probe();
|
||||
if (!present && process.env.CI) {
|
||||
throw new Error(`${name} is required in CI but was not found. ${hint}`);
|
||||
}
|
||||
return present;
|
||||
}
|
||||
|
||||
/** True when the system `git` binary is available; throws under CI if not. */
|
||||
export function requireGitBinary(probe: DependencyProbe = defaultGitProbe): boolean {
|
||||
return requireDependency(
|
||||
'git',
|
||||
'Ensure the CI image installs the git CLI before running the backend suite.',
|
||||
probe,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a local `sshd` binary is available; throws under CI if not. */
|
||||
export function requireSshd(probe: DependencyProbe = defaultSshdProbe): boolean {
|
||||
return requireDependency(
|
||||
'sshd',
|
||||
'Ensure the CI image installs openssh-server and frees loopback port 22 (see .github/workflows/ci.yml).',
|
||||
probe,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Resolves a (file, exact test title) proof handle against real vitest
|
||||
* source, using the TypeScript compiler API rather than a string search.
|
||||
*
|
||||
* A string search accepts a commented-out test, a `.skip`-ed test, or a
|
||||
* duplicate title landing on the wrong declaration. This walks the actual
|
||||
* AST: it finds every `it`/`test` declaration with that literal title,
|
||||
* rejects any declaration carrying a skip-shaped modifier (`.skip`, `.todo`,
|
||||
* `.failing`, `.only`, `.each`, `.skipIf`, `.runIf`) directly, and rejects
|
||||
* any declaration nested under an enclosing `describe` that is unconditionally
|
||||
* skipped or conditionally skipped by anything other than
|
||||
* `describe.skipIf(...)` whose predicate calls one of the approved hardened
|
||||
* dependency probes (`requireGitBinary`, `requireSshd` from
|
||||
* `./externalDeps`). `describe.skip` and `describe.runIf` are never
|
||||
* approved, at any nesting depth.
|
||||
*/
|
||||
import ts from 'typescript';
|
||||
|
||||
export const APPROVED_GUARD_HELPERS = ['requireGitBinary', 'requireSshd'];
|
||||
|
||||
const SKIP_SHAPED_MODIFIERS = new Set(['skip', 'todo', 'failing', 'only', 'each', 'skipIf', 'runIf']);
|
||||
|
||||
export type HandleResolution =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'not-found' | 'duplicate' | 'skipped-directly' | 'unapproved-ancestor-skip' };
|
||||
|
||||
interface CallShape {
|
||||
kind: string;
|
||||
modifier: string | null;
|
||||
/** For a curried modifier (`X.skipIf(pred)(title, fn)`), the inner call's first argument. */
|
||||
predicateArg?: ts.Node;
|
||||
}
|
||||
|
||||
const CURRIED_MODIFIERS = new Set(['skipIf', 'runIf', 'each']);
|
||||
|
||||
function classifyCall(node: ts.CallExpression): CallShape | null {
|
||||
const expr = node.expression;
|
||||
|
||||
// Direct form: describe('x', fn) / describe.skip('x', fn) / it.todo('x').
|
||||
if (ts.isIdentifier(expr)) {
|
||||
return { kind: expr.text, modifier: null };
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
|
||||
return { kind: expr.expression.text, modifier: expr.name.text };
|
||||
}
|
||||
|
||||
// Curried form: X.skipIf(pred)(title, fn) / X.runIf(pred)(title, fn) /
|
||||
// X.each(cases)(title, fn): the outer call's expression is itself a
|
||||
// CallExpression whose own expression is the X.modifier access.
|
||||
if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression) && ts.isIdentifier(expr.expression.expression)) {
|
||||
const modifier = expr.expression.name.text;
|
||||
if (CURRIED_MODIFIERS.has(modifier)) {
|
||||
return { kind: expr.expression.expression.text, modifier, predicateArg: expr.arguments[0] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function predicateCallsApprovedHelper(argNode: ts.Node): boolean {
|
||||
let found = false;
|
||||
const visit = (n: ts.Node): void => {
|
||||
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && APPROVED_GUARD_HELPERS.includes(n.expression.text)) {
|
||||
found = true;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
};
|
||||
visit(argNode);
|
||||
return found;
|
||||
}
|
||||
|
||||
function stringLiteralText(node: ts.Node | undefined): string | null {
|
||||
if (node && ts.isStringLiteralLike(node)) return node.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
function functionBodyArgument(node: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | undefined {
|
||||
return node.arguments.find((a): a is ts.ArrowFunction | ts.FunctionExpression =>
|
||||
ts.isArrowFunction(a) || ts.isFunctionExpression(a));
|
||||
}
|
||||
|
||||
type AncestorState = 'none' | 'approved' | 'unapproved';
|
||||
|
||||
interface FoundDeclaration {
|
||||
title: string;
|
||||
ownModifier: string | null;
|
||||
ancestorState: AncestorState;
|
||||
}
|
||||
|
||||
function collectDeclarations(sourceFile: ts.SourceFile): FoundDeclaration[] {
|
||||
const found: FoundDeclaration[] = [];
|
||||
|
||||
function walk(node: ts.Node, ancestorState: AncestorState): void {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const classified = classifyCall(node);
|
||||
if (classified?.kind === 'describe') {
|
||||
let nextState: AncestorState = ancestorState;
|
||||
if (ancestorState !== 'unapproved') {
|
||||
if (classified.modifier === 'skip' || classified.modifier === 'runIf') {
|
||||
nextState = 'unapproved';
|
||||
} else if (classified.modifier === 'skipIf') {
|
||||
const predicate = classified.predicateArg;
|
||||
nextState = predicate && predicateCallsApprovedHelper(predicate) ? 'approved' : 'unapproved';
|
||||
} else if (classified.modifier === 'only' || classified.modifier === 'each' || classified.modifier === 'todo') {
|
||||
// Scoping/parameterization modifiers on describe don't skip
|
||||
// this suite's tests; leave ancestorState unchanged.
|
||||
nextState = ancestorState;
|
||||
}
|
||||
}
|
||||
const callback = functionBodyArgument(node);
|
||||
if (callback?.body) {
|
||||
ts.forEachChild(callback.body, (child) => walk(child, nextState));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (classified?.kind === 'it' || classified?.kind === 'test') {
|
||||
const title = stringLiteralText(node.arguments[0]);
|
||||
if (title !== null) {
|
||||
found.push({ title, ownModifier: classified.modifier, ancestorState });
|
||||
}
|
||||
// Do not descend further into an it/test call's own arguments;
|
||||
// its callback body is the test implementation, not more
|
||||
// declarations.
|
||||
return;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, (child) => walk(child, ancestorState));
|
||||
}
|
||||
|
||||
walk(sourceFile, 'none');
|
||||
return found;
|
||||
}
|
||||
|
||||
export function resolveTestHandle(filePath: string, sourceText: string, title: string): HandleResolution {
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
const declarations = collectDeclarations(sourceFile).filter((d) => d.title === title);
|
||||
|
||||
if (declarations.length === 0) return { ok: false, reason: 'not-found' };
|
||||
|
||||
const runnable = declarations.filter((d) => d.ownModifier === null && d.ancestorState !== 'unapproved');
|
||||
if (runnable.length > 1) return { ok: false, reason: 'duplicate' };
|
||||
if (runnable.length === 1) return { ok: true };
|
||||
|
||||
// Every matching declaration is skipped some way; report the most
|
||||
// specific reason from the first match.
|
||||
const first = declarations[0];
|
||||
if (first.ownModifier !== null && SKIP_SHAPED_MODIFIERS.has(first.ownModifier)) {
|
||||
return { ok: false, reason: 'skipped-directly' };
|
||||
}
|
||||
return { ok: false, reason: 'unapproved-ancestor-skip' };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { requireGitBinary, requireSshd } from './__helpers__/externalDeps';
|
||||
|
||||
describe('external dependency probes', () => {
|
||||
const originalCi = process.env.CI;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCi === undefined) delete process.env.CI;
|
||||
else process.env.CI = originalCi;
|
||||
});
|
||||
|
||||
describe('requireGitBinary', () => {
|
||||
it('returns true when git is present, locally or in CI', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireGitBinary(() => true)).toBe(true);
|
||||
process.env.CI = '1';
|
||||
expect(requireGitBinary(() => true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when git is absent locally', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireGitBinary(() => false)).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when git is absent under CI', () => {
|
||||
process.env.CI = '1';
|
||||
expect(() => requireGitBinary(() => false)).toThrow(/git is required in CI/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireSshd', () => {
|
||||
it('returns true when sshd is present, locally or in CI', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireSshd(() => true)).toBe(true);
|
||||
process.env.CI = '1';
|
||||
expect(requireSshd(() => true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when sshd is absent locally', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireSshd(() => false)).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when sshd is absent under CI', () => {
|
||||
process.env.CI = '1';
|
||||
expect(() => requireSshd(() => false)).toThrow(/sshd is required in CI/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Proves per-source CA bundles work without the process-wide NODE_EXTRA_CA_CERTS bridge.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs, readFileSync } from 'fs';
|
||||
import https from 'https';
|
||||
import os from 'os';
|
||||
@@ -9,10 +9,7 @@ import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { nativeGitTransport } from '../services/git/nativeGitTransport';
|
||||
import { buildBareRepo } from './__helpers__/gitFixture';
|
||||
|
||||
function gitAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
import { requireGitBinary } from './__helpers__/externalDeps';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
|
||||
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
|
||||
@@ -70,7 +67,7 @@ function serveRepo(bareDir: string): Promise<{ url: string; close: () => void }>
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!gitAvailable())('per-source private CA transport (real git)', () => {
|
||||
describe.skipIf(!requireGitBinary())('per-source private CA transport (real git)', () => {
|
||||
let repoUrl: string;
|
||||
let closeServer: () => void;
|
||||
let prevExtraCaCerts: string | undefined;
|
||||
|
||||
@@ -18,6 +18,7 @@ import path from 'path';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { nativeGitTransport } from '../services/git/nativeGitTransport';
|
||||
import { buildBareRepo } from './__helpers__/gitFixture';
|
||||
import { requireGitBinary } from './__helpers__/externalDeps';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
|
||||
const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8');
|
||||
@@ -27,10 +28,6 @@ const TLS_OPTS = {
|
||||
};
|
||||
const GOOD_TOKEN = 'correct-horse-battery-staple';
|
||||
|
||||
function gitAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
|
||||
interface Request { url: string; authorization: string | null }
|
||||
|
||||
interface Fixture {
|
||||
@@ -133,7 +130,7 @@ async function workspace(): Promise<string> {
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe.skipIf(!gitAvailable())('redirect destination revalidation (real git)', () => {
|
||||
describe.skipIf(!requireGitBinary())('redirect destination revalidation (real git)', () => {
|
||||
let bareDir: string;
|
||||
let headSha: string;
|
||||
let prevExtraCaCerts: string | undefined;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Response } from 'express';
|
||||
import { gitSourceStatus, sendGitSourceError, webhookPullStatus } from '../utils/gitSourceHttp';
|
||||
import { GitSourceError } from '../services/GitSourceService';
|
||||
import { GitSourceError, type GitSourceErrorCode } from '../services/GitSourceService';
|
||||
|
||||
describe('gitSourceStatus', () => {
|
||||
it('maps AUTH_FAILED to 400, never 401', () => {
|
||||
@@ -47,9 +47,25 @@ describe('gitSourceStatus', () => {
|
||||
expect(gitSourceStatus('PLAN_UNAVAILABLE')).toBe(409);
|
||||
});
|
||||
|
||||
it('maps unknown codes to 400', () => {
|
||||
it('maps GIT_ERROR to 400', () => {
|
||||
expect(gitSourceStatus('GIT_ERROR')).toBe(400);
|
||||
});
|
||||
|
||||
it('maps OPERATION_IN_FLIGHT to 409', () => {
|
||||
expect(gitSourceStatus('OPERATION_IN_FLIGHT')).toBe(409);
|
||||
});
|
||||
|
||||
it('has exactly one explicit mapping for every GitSourceErrorCode', () => {
|
||||
const codes: GitSourceErrorCode[] = [
|
||||
'REPO_NOT_FOUND', 'AUTH_FAILED', 'REF_NOT_FOUND', 'REF_DELETED', 'UNSUPPORTED_REF',
|
||||
'SSH_HOST_KEY_FAILED', 'FILE_NOT_FOUND', 'NETWORK_TIMEOUT', 'GIT_ERROR', 'STALE_PLAN',
|
||||
'PLAN_FINGERPRINT_REQUIRED', 'PLAN_BLOCKED', 'LEGACY_PENDING', 'PLAN_UNAVAILABLE',
|
||||
'OPERATION_IN_FLIGHT',
|
||||
];
|
||||
for (const code of codes) {
|
||||
expect(typeof gitSourceStatus(code)).toBe('number');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhookPullStatus', () => {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Validates the Git transport support matrix (docs/git-transport-support.yaml)
|
||||
* against the reality it claims to describe, so a published claim can never
|
||||
* silently outrun its evidence.
|
||||
*
|
||||
* The renderer this test imports (backend/scripts/git-support-matrix/*)
|
||||
* lives outside backend's tsconfig `rootDir` (pinned to `src`), the same
|
||||
* constraint git-transport-auth.integration.test.ts documents for its own
|
||||
* cross-directory fixture reuse. A static `import` there would fail
|
||||
* `tsc --noEmit`; `require()` at runtime does not, since TS never has to
|
||||
* resolve or type-check a file it was not statically asked to include.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ts from 'typescript';
|
||||
import { gitSourceStatus } from '../utils/gitSourceHttp';
|
||||
import type { GitSourceErrorCode } from '../services/GitSourceService';
|
||||
import { resolveTestHandle } from './__helpers__/testHandleResolver';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const matrixRenderer = require('../../scripts/git-support-matrix/render');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { loadClaimSet, REPO_ROOT } = require('../../scripts/git-support-matrix/loadClaimSet');
|
||||
|
||||
const CLOSED_ENUMS = {
|
||||
transport: ['https', 'ssh'],
|
||||
ref: ['branch', 'tag', 'sha'],
|
||||
auth: ['none', 'pat', 'deploy-key'],
|
||||
host: ['github', 'gitlab', 'gitea', 'forgejo', 'bitbucket', 'generic'],
|
||||
ca: ['system', 'per-source', 'not-applicable'],
|
||||
node_path: ['local', 'direct-proxy', 'pilot'],
|
||||
port: ['default', 'nonstandard'],
|
||||
support: ['supported', 'unsupported', 'unverified'],
|
||||
};
|
||||
|
||||
const REQUIRED_CLAIM_KEYS = ['id', 'transport', 'ref', 'auth', 'host', 'ca', 'node_path', 'support', 'qualifiers'];
|
||||
const OPTIONAL_CLAIM_KEYS = ['port', 'evidence'];
|
||||
const ALLOWED_CLAIM_KEYS = new Set([...REQUIRED_CLAIM_KEYS, ...OPTIONAL_CLAIM_KEYS]);
|
||||
|
||||
interface Claim {
|
||||
id: string;
|
||||
transport: string;
|
||||
ref: string;
|
||||
auth: string;
|
||||
host: string;
|
||||
ca: string;
|
||||
node_path: string;
|
||||
port?: string;
|
||||
support: string;
|
||||
qualifiers: string[];
|
||||
limitations?: string[];
|
||||
evidence?: {
|
||||
kind: 'automated' | 'live';
|
||||
outcome: 'success' | 'rejected';
|
||||
handles?: { file: string; title: string }[];
|
||||
attestation?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Attestation {
|
||||
id: string;
|
||||
date: string;
|
||||
source_commit: string;
|
||||
sencho_image_digest?: string;
|
||||
host: string;
|
||||
node_path: string;
|
||||
transport?: string;
|
||||
ref?: string;
|
||||
auth?: string;
|
||||
ca?: string;
|
||||
result: 'success' | 'rejected';
|
||||
}
|
||||
|
||||
/** Pure schema validation: closed enums, required keys, no unknown fields, resolvable limitation refs. */
|
||||
function validateClaimSchema(claim: Record<string, unknown>, limitationIds: Set<string>): string[] {
|
||||
const errors: string[] = [];
|
||||
const keys = Object.keys(claim);
|
||||
|
||||
for (const key of REQUIRED_CLAIM_KEYS) {
|
||||
if (!(key in claim)) errors.push(`missing required key "${key}"`);
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (!ALLOWED_CLAIM_KEYS.has(key)) errors.push(`unknown key "${key}"`);
|
||||
}
|
||||
for (const [field, allowed] of Object.entries(CLOSED_ENUMS)) {
|
||||
if (field === 'support' && typeof claim.support === 'string' && !allowed.includes(claim.support)) {
|
||||
errors.push(`invalid support "${String(claim.support)}"`);
|
||||
} else if (field in claim && field !== 'support') {
|
||||
const value = (claim as Record<string, unknown>)[field];
|
||||
if (typeof value === 'string' && !allowed.includes(value)) {
|
||||
errors.push(`invalid ${field} "${value}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(claim.limitations)) {
|
||||
for (const id of claim.limitations as string[]) {
|
||||
if (!limitationIds.has(id)) errors.push(`unknown limitation id "${id}"`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Pure evidence-semantics validation, independent of file/AST resolution. */
|
||||
function validateClaimEvidence(claim: Claim): string[] {
|
||||
const errors: string[] = [];
|
||||
const { support, evidence } = claim;
|
||||
|
||||
if (support === 'unverified') {
|
||||
if (evidence) errors.push('unverified claim must not carry evidence');
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!evidence) {
|
||||
errors.push(`${support} claim requires evidence`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
const expectedOutcome = support === 'supported' ? 'success' : 'rejected';
|
||||
if (evidence.outcome !== expectedOutcome) {
|
||||
errors.push(`${support} claim requires evidence.outcome "${expectedOutcome}", got "${evidence.outcome}"`);
|
||||
}
|
||||
|
||||
if (evidence.kind === 'automated' && (!evidence.handles || evidence.handles.length === 0)) {
|
||||
errors.push('automated evidence requires at least one handle');
|
||||
}
|
||||
if (evidence.kind === 'live' && !evidence.attestation) {
|
||||
errors.push('live evidence requires an attestation id');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Cross-checks a live claim's dimensions and baseline against its attestation. */
|
||||
function validateLiveEvidenceIntegrity(claim: Claim, attestationsById: Map<string, Attestation>, expectedBaseline: string): string[] {
|
||||
if (claim.support === 'unverified' || claim.evidence?.kind !== 'live') return [];
|
||||
const errors: string[] = [];
|
||||
const attestation = claim.evidence.attestation ? attestationsById.get(claim.evidence.attestation) : undefined;
|
||||
|
||||
if (!attestation) {
|
||||
errors.push(`claim "${claim.id}" references missing attestation "${String(claim.evidence.attestation)}"`);
|
||||
return errors;
|
||||
}
|
||||
if (attestation.source_commit !== expectedBaseline) {
|
||||
errors.push(`claim "${claim.id}"'s attestation is stale: source_commit "${attestation.source_commit}" != implementation_baseline "${expectedBaseline}"`);
|
||||
}
|
||||
if (attestation.host !== claim.host) errors.push(`claim "${claim.id}" host mismatch with its attestation`);
|
||||
if (attestation.node_path !== claim.node_path) errors.push(`claim "${claim.id}" node_path mismatch with its attestation`);
|
||||
if (attestation.transport !== undefined && attestation.transport !== claim.transport) errors.push(`claim "${claim.id}" transport mismatch with its attestation`);
|
||||
if (attestation.ref !== undefined && attestation.ref !== claim.ref) errors.push(`claim "${claim.id}" ref mismatch with its attestation`);
|
||||
if (attestation.auth !== undefined && attestation.auth !== claim.auth) errors.push(`claim "${claim.id}" auth mismatch with its attestation`);
|
||||
if (attestation.ca !== undefined && attestation.ca !== claim.ca) errors.push(`claim "${claim.id}" ca mismatch with its attestation`);
|
||||
const expectedOutcome = claim.support === 'supported' ? 'success' : 'rejected';
|
||||
if (attestation.result !== expectedOutcome) errors.push(`claim "${claim.id}" attestation result "${attestation.result}" contradicts claim support "${claim.support}"`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
function extractStringUnionMembers(filePath: string, typeName: string): string[] {
|
||||
const sourceText = fs.readFileSync(filePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
const members: string[] = [];
|
||||
const collect = (typeNode: ts.TypeNode): void => {
|
||||
if (ts.isUnionTypeNode(typeNode)) {
|
||||
typeNode.types.forEach(collect);
|
||||
} else if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) {
|
||||
members.push(typeNode.literal.text);
|
||||
}
|
||||
};
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) {
|
||||
collect(node.type);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
if (members.length === 0) throw new Error(`Type alias ${typeName} not found (or has no string-literal members) in ${filePath}`);
|
||||
return members;
|
||||
}
|
||||
|
||||
describe('git transport support matrix', () => {
|
||||
const { support, attestations } = loadClaimSet() as { support: { claims: Claim[]; limitations: { id: string; title: string; statement: string }[]; error_model: { code: string; label: string; status: number; meaning: string }[]; reconciliation_only_codes: string[]; implementation_baseline: string }; attestations: { attestations: Attestation[] } };
|
||||
const limitationIds = new Set(support.limitations.map((l) => l.id));
|
||||
const attestationsById = new Map(attestations.attestations.map((a) => [a.id, a]));
|
||||
|
||||
describe('schema', () => {
|
||||
it('every real claim is schema-valid', () => {
|
||||
for (const claim of support.claims) {
|
||||
expect(validateClaimSchema(claim as unknown as Record<string, unknown>, limitationIds), claim.id).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('every claim id is unique', () => {
|
||||
const ids = support.claims.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('rejects an invalid enum value', () => {
|
||||
const bad = { ...support.claims[0], transport: 'ftp' };
|
||||
expect(validateClaimSchema(bad, limitationIds)).toContain('invalid transport "ftp"');
|
||||
});
|
||||
|
||||
it('rejects an unknown field', () => {
|
||||
const bad = { ...support.claims[0], bogusField: true };
|
||||
expect(validateClaimSchema(bad, limitationIds)).toContain('unknown key "bogusField"');
|
||||
});
|
||||
|
||||
it('rejects a dangling limitation reference', () => {
|
||||
const bad = { ...support.claims[0], limitations: ['does-not-exist'] };
|
||||
expect(validateClaimSchema(bad, limitationIds)).toContain('unknown limitation id "does-not-exist"');
|
||||
});
|
||||
|
||||
it('rejects a claim missing a required key', () => {
|
||||
const bad = { ...support.claims[0] } as Record<string, unknown>;
|
||||
delete bad.host;
|
||||
expect(validateClaimSchema(bad, limitationIds)).toContain('missing required key "host"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evidence semantics', () => {
|
||||
it('every real claim satisfies evidence semantics', () => {
|
||||
for (const claim of support.claims) {
|
||||
expect(validateClaimEvidence(claim), claim.id).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a supported claim with no evidence', () => {
|
||||
const bad: Claim = { ...support.claims.find((c) => c.support === 'supported')!, evidence: undefined };
|
||||
expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence');
|
||||
});
|
||||
|
||||
it('rejects an unverified claim that carries evidence', () => {
|
||||
const template = support.claims.find((c) => c.support === 'supported')!;
|
||||
const bad: Claim = { ...template, support: 'unverified' };
|
||||
expect(validateClaimEvidence(bad)).toContain('unverified claim must not carry evidence');
|
||||
});
|
||||
|
||||
it('rejects a supported claim whose evidence outcome is "rejected"', () => {
|
||||
const template = support.claims.find((c) => c.support === 'supported' && c.evidence)!;
|
||||
const bad: Claim = { ...template, evidence: { ...template.evidence!, outcome: 'rejected' } };
|
||||
expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence.outcome "success", got "rejected"');
|
||||
});
|
||||
|
||||
it('rejects automated evidence with no handles', () => {
|
||||
const template = support.claims.find((c) => c.evidence?.kind === 'automated')!;
|
||||
const bad: Claim = { ...template, evidence: { ...template.evidence!, handles: [] } };
|
||||
expect(validateClaimEvidence(bad)).toContain('automated evidence requires at least one handle');
|
||||
});
|
||||
|
||||
it('requires an unsupported claim to carry rejection evidence, not silence', () => {
|
||||
const template = support.claims.find((c) => c.support === 'supported')!;
|
||||
const bad: Claim = { ...template, support: 'unsupported', evidence: undefined };
|
||||
expect(validateClaimEvidence(bad)).toContain('unsupported claim requires evidence');
|
||||
});
|
||||
});
|
||||
|
||||
describe('page binding', () => {
|
||||
it('the committed MDX is byte-identical to a fresh render of the YAML', () => {
|
||||
const mdxPath = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx');
|
||||
expect(matrixRenderer.renderFullMdx()).toBe(fs.readFileSync(mdxPath, 'utf8'));
|
||||
});
|
||||
|
||||
it('re-rendering with a mutated claim produces different generated output', () => {
|
||||
const mutated = {
|
||||
support: {
|
||||
...support,
|
||||
claims: support.claims.map((c, i) => (i === 0 ? { ...c, support: 'unsupported' } : c)),
|
||||
},
|
||||
attestations,
|
||||
};
|
||||
const original = matrixRenderer.renderGeneratedBlock({ support, attestations });
|
||||
const changed = matrixRenderer.renderGeneratedBlock(mutated);
|
||||
expect(changed).not.toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proof handles', () => {
|
||||
const automatedClaims = support.claims.filter((c) => c.evidence?.kind === 'automated');
|
||||
|
||||
it('has at least one automated claim to check', () => {
|
||||
expect(automatedClaims.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const claim of automatedClaims) {
|
||||
for (const handle of claim.evidence!.handles ?? []) {
|
||||
it(`resolves "${handle.title}" in ${handle.file} (claim ${claim.id})`, () => {
|
||||
const absPath = path.join(REPO_ROOT, handle.file);
|
||||
const sourceText = fs.readFileSync(absPath, 'utf8');
|
||||
const result = resolveTestHandle(absPath, sourceText, handle.title);
|
||||
expect(result, JSON.stringify(result)).toEqual({ ok: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mutation coverage for the resolver's found/duplicate/skip/ancestor
|
||||
// logic itself lives in testHandleResolver.test.ts; this suite only
|
||||
// needs to prove every real handle actually resolves.
|
||||
});
|
||||
|
||||
describe('live evidence integrity', () => {
|
||||
it('every real live claim (if any) is internally consistent', () => {
|
||||
for (const claim of support.claims) {
|
||||
expect(validateLiveEvidenceIntegrity(claim, attestationsById, support.implementation_baseline), claim.id).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a live claim referencing a missing attestation', () => {
|
||||
const bad: Claim = {
|
||||
id: 'synthetic-missing-attestation', transport: 'https', ref: 'branch', auth: 'pat',
|
||||
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
|
||||
evidence: { kind: 'live', outcome: 'success', attestation: 'does-not-exist' },
|
||||
};
|
||||
expect(validateLiveEvidenceIntegrity(bad, attestationsById, support.implementation_baseline).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects a live claim whose attestation baseline is stale', () => {
|
||||
const fakeAttestations = new Map<string, Attestation>([
|
||||
['att-stale', { id: 'att-stale', date: '2020-01-01', source_commit: 'deadbeef', host: 'github', node_path: 'local', result: 'success' }],
|
||||
]);
|
||||
const bad: Claim = {
|
||||
id: 'synthetic-stale', transport: 'https', ref: 'branch', auth: 'pat',
|
||||
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
|
||||
evidence: { kind: 'live', outcome: 'success', attestation: 'att-stale' },
|
||||
};
|
||||
const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline);
|
||||
expect(errors.some((e) => e.includes('stale'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a live claim whose node_path does not match its attestation', () => {
|
||||
const fakeAttestations = new Map<string, Attestation>([
|
||||
['att-mismatch', { id: 'att-mismatch', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'direct-proxy', result: 'success' }],
|
||||
]);
|
||||
const bad: Claim = {
|
||||
id: 'synthetic-mismatch', transport: 'https', ref: 'branch', auth: 'pat',
|
||||
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
|
||||
evidence: { kind: 'live', outcome: 'success', attestation: 'att-mismatch' },
|
||||
};
|
||||
const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline);
|
||||
expect(errors.some((e) => e.includes('node_path mismatch'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an attestation whose result contradicts the claim support', () => {
|
||||
const fakeAttestations = new Map<string, Attestation>([
|
||||
['att-contradict', { id: 'att-contradict', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'local', result: 'rejected' }],
|
||||
]);
|
||||
const bad: Claim = {
|
||||
id: 'synthetic-contradict', transport: 'https', ref: 'branch', auth: 'pat',
|
||||
host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [],
|
||||
evidence: { kind: 'live', outcome: 'success', attestation: 'att-contradict' },
|
||||
};
|
||||
const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline);
|
||||
expect(errors.some((e) => e.includes('contradicts'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error model partition', () => {
|
||||
const transportFacingCodes = extractStringUnionMembers(
|
||||
path.join(REPO_ROOT, 'backend', 'src', 'services', 'git', 'errors.ts'),
|
||||
'TransportFacingCode',
|
||||
);
|
||||
const gitSourceErrorCodes = extractStringUnionMembers(
|
||||
path.join(REPO_ROOT, 'backend', 'src', 'services', 'GitSourceService.ts'),
|
||||
'GitSourceErrorCode',
|
||||
);
|
||||
|
||||
it('the matrix error_model is exactly TransportFacingCode plus REF_DELETED and FILE_NOT_FOUND', () => {
|
||||
const expected = new Set([...transportFacingCodes, 'REF_DELETED', 'FILE_NOT_FOUND']);
|
||||
const actual = new Set(support.error_model.map((e) => e.code));
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('reconciliation_only_codes plus the matrix error_model partitions all of GitSourceErrorCode exactly once', () => {
|
||||
const matrixCodes = support.error_model.map((e) => e.code);
|
||||
const reconciliationCodes: string[] = support.reconciliation_only_codes;
|
||||
const combined = [...matrixCodes, ...reconciliationCodes];
|
||||
|
||||
expect(new Set(combined).size).toBe(combined.length); // no code in both sets
|
||||
expect(new Set(combined)).toEqual(new Set(gitSourceErrorCodes)); // covers every code
|
||||
});
|
||||
|
||||
it('every published status matches the real gitSourceStatus mapping', () => {
|
||||
for (const entry of support.error_model) {
|
||||
expect(gitSourceStatus(entry.code as GitSourceErrorCode), entry.code).toBe(entry.status);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a matrix that leaves a code unclassified', () => {
|
||||
const incomplete = support.error_model.filter((e) => e.code !== 'GIT_ERROR').map((e) => e.code);
|
||||
const reconciliationCodes: string[] = support.reconciliation_only_codes;
|
||||
const combined = [...incomplete, ...reconciliationCodes];
|
||||
expect(new Set(combined)).not.toEqual(new Set(gitSourceErrorCodes));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('is documented as a limitation, not a supported claim', () => {
|
||||
expect(limitationIds.has('no-rate-limit-classification')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,7 @@ import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { classifyGitFailure, isTransportFailure } from '../services/git/errors';
|
||||
import { nativeGitTransport, verifyFastForward } from '../services/git/nativeGitTransport';
|
||||
|
||||
function gitAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
import { requireGitBinary } from './__helpers__/externalDeps';
|
||||
|
||||
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
|
||||
const VALID_TOKEN = 'sencho-integration-test-token-do-not-leak';
|
||||
@@ -174,7 +171,7 @@ function serveAuthedRepo(bareDir: string): Promise<{ url: string; close: () => v
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!gitAvailable())('authenticated native git transport (real git, real TLS, real auth)', () => {
|
||||
describe.skipIf(!requireGitBinary())('authenticated native git transport (real git, real TLS, real auth)', () => {
|
||||
let repoUrl: string;
|
||||
let closeServer: () => void;
|
||||
let prevExtraCaCerts: string | undefined;
|
||||
|
||||
@@ -13,14 +13,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { classifyGitFailure, isTransportFailure } from '../services/git/errors';
|
||||
import { nativeGitTransport } from '../services/git/nativeGitTransport';
|
||||
import { scanHostKeys } from '../services/git/sshTrust';
|
||||
|
||||
function gitAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
|
||||
function sshdAvailable(): boolean {
|
||||
return spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0;
|
||||
}
|
||||
import { requireGitBinary, requireSshd } from './__helpers__/externalDeps';
|
||||
|
||||
const FILE_CONTENT = 'hello from the ssh fixture repo\n';
|
||||
|
||||
@@ -182,7 +175,7 @@ async function startSshGitServer(bareDir: string, port: number): Promise<Omit<Ss
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => {
|
||||
describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => {
|
||||
let fixture: SshGitFixture;
|
||||
const workspaces: string[] = [];
|
||||
let scratchDirs: string[] = [];
|
||||
@@ -296,7 +289,7 @@ describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key transport on the default SSH port', () => {
|
||||
describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key transport on the default SSH port', () => {
|
||||
let fixture: SshGitFixture;
|
||||
const workspaces: string[] = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveTestHandle } from './__helpers__/testHandleResolver';
|
||||
|
||||
const FILE = 'fixture.test.ts';
|
||||
|
||||
describe('resolveTestHandle', () => {
|
||||
it('resolves a plain it() declaration', () => {
|
||||
const src = `it('does the thing', () => { expect(1).toBe(1); });`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('resolves a plain test() declaration', () => {
|
||||
const src = `test('does the thing', () => {});`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('fails when the title is not present', () => {
|
||||
const src = `it('does something else', () => {});`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' });
|
||||
});
|
||||
|
||||
it('fails on a duplicate title with two runnable declarations', () => {
|
||||
const src = `
|
||||
it('does the thing', () => {});
|
||||
it('does the thing', () => {});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'duplicate' });
|
||||
});
|
||||
|
||||
it('fails when the test itself is .skip', () => {
|
||||
const src = `it.skip('does the thing', () => {});`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
|
||||
});
|
||||
|
||||
it('fails when the test itself is .todo', () => {
|
||||
const src = `it.todo('does the thing');`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
|
||||
});
|
||||
|
||||
it('fails when the test itself is .failing', () => {
|
||||
const src = `it.failing('does the thing', () => {});`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
|
||||
});
|
||||
|
||||
it('fails when the test itself is .skipIf', () => {
|
||||
const src = `it.skipIf(true)('does the thing', () => {});`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' });
|
||||
});
|
||||
|
||||
it('fails under an unconditionally skipped describe', () => {
|
||||
const src = `
|
||||
describe.skip('suite', () => {
|
||||
it('does the thing', () => {});
|
||||
});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
|
||||
});
|
||||
|
||||
it('fails under a describe.runIf, approved-looking predicate or not', () => {
|
||||
const src = `
|
||||
describe.runIf(requireGitBinary())('suite', () => {
|
||||
it('does the thing', () => {});
|
||||
});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
|
||||
});
|
||||
|
||||
it('fails under a describe.skipIf with an arbitrary, unapproved predicate', () => {
|
||||
const src = `
|
||||
describe.skipIf(!someLocalCheck())('suite', () => {
|
||||
it('does the thing', () => {});
|
||||
});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
|
||||
});
|
||||
|
||||
it('passes under a describe.skipIf that calls the approved requireGitBinary helper', () => {
|
||||
const src = `
|
||||
describe.skipIf(!requireGitBinary())('suite', () => {
|
||||
it('does the thing', () => {});
|
||||
});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('passes under a describe.skipIf that calls the approved requireSshd helper alongside other checks', () => {
|
||||
const src = `
|
||||
describe.skipIf(!requireGitBinary() || !requireSshd())('suite', () => {
|
||||
it('does the thing', () => {});
|
||||
});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('fails when an approved outer describe.skipIf wraps an unapproved inner describe.skip', () => {
|
||||
const src = `
|
||||
describe.skipIf(!requireGitBinary())('outer', () => {
|
||||
describe.skip('inner', () => {
|
||||
it('does the thing', () => {});
|
||||
});
|
||||
});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' });
|
||||
});
|
||||
|
||||
it('resolves a title nested two levels inside approved describe.skipIf blocks', () => {
|
||||
const src = `
|
||||
describe.skipIf(!requireGitBinary())('outer', () => {
|
||||
describe('inner (no modifier)', () => {
|
||||
it('does the thing', () => {});
|
||||
});
|
||||
});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not resolve a title only present in a comment', () => {
|
||||
const src = `
|
||||
// it('does the thing', () => {});
|
||||
it('does another thing', () => {});
|
||||
`;
|
||||
expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' });
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,16 @@ export function gitSourceStatus(code: GitSourceErrorCode): number {
|
||||
return 409;
|
||||
case 'NETWORK_TIMEOUT':
|
||||
return 504;
|
||||
default:
|
||||
case 'GIT_ERROR':
|
||||
return 400;
|
||||
default: {
|
||||
// Exhaustiveness guard: if a new code is added to the union without a
|
||||
// case here, this becomes a compile error instead of silently mapping
|
||||
// to 400.
|
||||
const _exhaustive: never = code;
|
||||
void _exhaustive;
|
||||
return 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"features/health-gated-updates",
|
||||
"features/deploy-enforcement",
|
||||
"features/git-sources",
|
||||
"features/git-transport-support",
|
||||
"features/blueprint-model",
|
||||
"features/scheduled-operations",
|
||||
"features/auto-update-policies",
|
||||
|
||||
@@ -9,6 +9,8 @@ Git Sources turn any stack into a GitOps target. Point Sencho at a repository an
|
||||
Git Sources are available on every tier, including Community.
|
||||
</Note>
|
||||
|
||||
For exactly which transports, ref types, authentication methods, and Git hosts are supported today, with the evidence behind each, see [Git Transport Support](/features/git-transport-support).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Open a stack and click the **Git Source** button in the editor toolbar.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Git Transport Support
|
||||
sidebarTitle: Transport Support
|
||||
description: Which transports, reference types, authentication methods, and Git hosts Git Sources supports, with the evidence behind each claim.
|
||||
---
|
||||
|
||||
<Note>
|
||||
Git Sources, and everything on this page, is available on every tier, including Community.
|
||||
</Note>
|
||||
|
||||
This page states exactly what Git Sources supports today: which transports, reference types, authentication methods, TLS trust modes, and Git hosts, and what happens where a combination is not supported or not yet verified. For how to configure a Git source, see [Git Sources](/features/git-sources).
|
||||
|
||||
Every "Supported" row here is backed by a test that runs on every change to Sencho, or by a dated pass against a real instance of that host. A row marked "Not yet verified" is not a claim of failure: it means that specific combination has not been exercised yet, so it is not advertised as working. Nothing on this page is inferred from a related combination that behaved correctly.
|
||||
|
||||
<!-- GENERATED:BEGIN (run `npm run matrix:render` in backend/ to regenerate, do not edit by hand) -->
|
||||
|
||||
## Transports
|
||||
|
||||
| Transport | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| HTTPS | Supported | 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 | Supported | A read-only deploy key with strict host-key verification. Standard (22) and nonstandard ports are both supported. |
|
||||
|
||||
## Reference types
|
||||
|
||||
| Reference type | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Branch | Supported | Tracks the head of a branch; each pull resolves and pins the exact commit. |
|
||||
| Tag | Supported | Both annotated and lightweight tags resolve to their target commit. |
|
||||
| Commit SHA | Supported | A full commit SHA is pinned directly; the Git host must advertise the commit on some branch or tag. |
|
||||
|
||||
## Authentication
|
||||
|
||||
| Method | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Public (no auth) | Supported | For public repositories. |
|
||||
| Personal Access Token | Supported | Stored encrypted at rest, never returned after save. |
|
||||
| SSH deploy key | Supported | Stored encrypted at rest; the server host key is verified on every fetch. |
|
||||
|
||||
## Git hosts
|
||||
|
||||
| Host | HTTPS | SSH | Branch | Tag | Commit SHA | Evidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Generic (self-hosted or any Git server) | Supported | Supported | Supported | Supported | Supported | Automated, every change |
|
||||
| GitHub | Supported | Not yet verified | Supported | Not yet verified | Supported | Live, 2026-09-01 |
|
||||
| GitLab | Supported | Not yet verified | Supported | Supported | Supported | Live, 2026-09-01 |
|
||||
| Gitea | Supported | Supported | Supported | Supported | Supported | Live, 2026-09-01 |
|
||||
| Forgejo | Supported | Supported | Supported | Supported | Supported | Live, 2026-09-01 |
|
||||
| Bitbucket | Supported | Not yet verified | Supported | Not yet verified | Supported | Live, 2026-09-01 |
|
||||
|
||||
## TLS and certificate authorities
|
||||
|
||||
| Mode | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| System trust (default) | Supported | The host running the fetch trusts its system certificate store. |
|
||||
| Per-source custom CA | Supported | 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 supported
|
||||
|
||||
- **No rate-limit classification.** A Git host rate-limit response (for example GitHub's secondary rate limits) is not classified as its own error state. It surfaces as an authentication failure or a generic transport error depending on the host's exact response. Wait and retry; there is no dedicated rate-limit message or backoff guidance yet.
|
||||
- **No Git LFS.** Compose and env files tracked via Git LFS are rejected rather than silently fetched as pointer stubs. Commit plain files instead.
|
||||
- **No submodules.** Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when .gitmodules is present.
|
||||
- **No sparse or partial clone.** Every fetch materializes the complete repository at the resolved commit (shallow, single-branch); there is no sparse or partial clone for large monorepos.
|
||||
- **No GitHub App authentication.** Authentication is Personal Access Token or SSH deploy key only. GitHub App installation tokens are not supported.
|
||||
- **No provider pull/merge request revisions.** Sources track a branch, a tag, or a pinned commit SHA. A provider-specific pull request or merge request revision (for example GitHub's refs/pull/N/head) is not a supported ref shape.
|
||||
- **Outbound target restrictions.** Repository targets that resolve to loopback, link-local, multicast, or selected special-use addresses are refused before any request is sent. Private hosts on an operator's own LAN or VPN are not affected by this restriction.
|
||||
- **Branch/tag name collisions.** A bare ref name resolves as a branch first, then as a tag. If an operator renames a branch and a tag of the same name later appears, the source silently starts resolving the tag instead. A ref name that is also a valid 40 or 64 character hex string resolves as a commit SHA before either lookup.
|
||||
|
||||
<!-- GENERATED:END -->
|
||||
|
||||
## How these claims are verified
|
||||
|
||||
Two kinds of evidence back the rows above:
|
||||
|
||||
- **Automated.** A real `git` client, talking to a real local test server over HTTPS or SSH, drives the exact same code path Sencho uses in production. These tests run on every change, so a regression here fails the build before it reaches a release.
|
||||
- **Live.** A dated pass against a real instance of the named host (a public GitHub, GitLab, Gitea, Forgejo, or Bitbucket repository, or a self-hosted instance under Sencho's control). This is repeated periodically, not on every change, so its date tells you how current the result is.
|
||||
|
||||
A host that is not listed, or a combination marked "Not yet verified," most likely still works: Git Sources speaks the standard Git smart-HTTP and SSH protocols, not anything host-specific. It simply has not been exercised as its own row yet.
|
||||
|
||||
<Card title="Git Sources" icon="code-branch" href="/features/git-sources">
|
||||
Configure a repository, review pull previews, and read the field-by-field reference for every setting mentioned above.
|
||||
</Card>
|
||||
@@ -0,0 +1,365 @@
|
||||
# Retained live-evidence results for docs/git-transport-support.yaml claims
|
||||
# with `evidence.kind: live`.
|
||||
#
|
||||
# This is the reproducibility artifact for every non-automated claim: a
|
||||
# future engineer (or SEN-362's GA verification) can re-run the exact
|
||||
# procedure in scripts/git-attestation/README.md and compare against what is
|
||||
# recorded here. Never record raw credential-bearing output, tokens, deploy
|
||||
# keys, hostnames or URLs beyond generic fixture identities, or fleet
|
||||
# credentials: only structured pass/fail metadata and scrubbed commands.
|
||||
#
|
||||
# Schema (schema: attestation-v1):
|
||||
# id: referenced by a claim's evidence.attestation.
|
||||
# date: when the attestation ran (YYYY-MM-DD).
|
||||
# source_commit: the Sencho commit the claim set's implementation_baseline
|
||||
# names; a claim binds to this, not to the runtime image digest below.
|
||||
# sencho_image_digest: the exact runtime image executed. Recorded because
|
||||
# a runtime attestation executes an image while claims are committed
|
||||
# from a revision; retained so the executed runtime is identifiable even
|
||||
# though it is not what claims bind to.
|
||||
# host: the closed enum value matching the claim (github, gitlab, gitea,
|
||||
# forgejo, bitbucket, generic); never a descriptive string.
|
||||
# host_version: optional, self-hosted only: the exact image reference run.
|
||||
# Omitted for hosted SaaS, where inventing a server version would be false.
|
||||
# node_path: local | direct-proxy | pilot: the execution path exercised.
|
||||
# transport / ref / auth / ca: duplicated from the referencing claim so the
|
||||
# validator can assert exact-dimension equality, not extrapolation.
|
||||
# repository / ref_name: the exact fixture repository and literal ref
|
||||
# exercised (a branch, tag, or commit, not the ref *kind*).
|
||||
# command: a scrubbed, non-credential-bearing description of what ran.
|
||||
# result: success | rejected, matching the claim's evidence.outcome.
|
||||
|
||||
version: '1'
|
||||
schema: 'attestation-v1'
|
||||
|
||||
attestations:
|
||||
# ===== Named hosts, public read-only (HTTPS, no auth) =====
|
||||
- id: att-2026-09-01-github-branch
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: github
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
ca: system
|
||||
repository: octocat/Hello-World (GitHub's own public demo repository)
|
||||
ref_name: master
|
||||
command: POST /api/git-sources/browse against the real Sencho instance; branch tip resolved and fetched
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-github-sha
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: github
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: none
|
||||
ca: system
|
||||
repository: octocat/Hello-World
|
||||
ref_name: 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d (master tip)
|
||||
command: POST /api/git-sources/browse pinning the branch tip's own commit SHA
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitlab-branch
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitlab
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
ca: system
|
||||
repository: gitlab-org/gitlab-test (GitLab's own canonical test fixture repository)
|
||||
ref_name: master
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitlab-tag
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitlab
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: none
|
||||
ca: system
|
||||
repository: gitlab-org/gitlab-test
|
||||
ref_name: v1.0.0 (annotated)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitlab-sha
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitlab
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: none
|
||||
ca: system
|
||||
repository: gitlab-org/gitlab-test
|
||||
ref_name: 6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9 (v1.0.0's peeled commit)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-bitbucket-branch
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: bitbucket
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
ca: system
|
||||
repository: atlassian_tutorial/helloworld (Atlassian's own public tutorial repository)
|
||||
ref_name: master
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-bitbucket-sha
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: bitbucket
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: none
|
||||
ca: system
|
||||
repository: atlassian_tutorial/helloworld
|
||||
ref_name: 65d938f39f364da3f90767e008022ffe45c562af (master tip)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
# ===== Self-hosted Gitea, own throwaway instance on the QA fleet =====
|
||||
- id: att-2026-09-01-gitea-https-branch
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitea
|
||||
host_version: gitea/gitea:1.22
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: pat
|
||||
ca: per-source
|
||||
repository: throwaway fixture repository on a disposable Gitea instance, torn down after this pass
|
||||
ref_name: main
|
||||
command: POST /api/git-sources/browse with a per-source CA bundle and a Personal Access Token
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitea-https-tag
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitea
|
||||
host_version: gitea/gitea:1.22
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: pat
|
||||
ca: per-source
|
||||
repository: throwaway fixture repository on a disposable Gitea instance
|
||||
ref_name: v1.0 (annotated)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitea-https-sha
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitea
|
||||
host_version: gitea/gitea:1.22
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: pat
|
||||
ca: per-source
|
||||
repository: throwaway fixture repository on a disposable Gitea instance
|
||||
ref_name: pinned commit SHA (branch tip)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitea-ssh-branch
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitea
|
||||
host_version: gitea/gitea:1.22
|
||||
node_path: local
|
||||
transport: ssh
|
||||
ref: branch
|
||||
auth: deploy-key
|
||||
ca: not-applicable
|
||||
repository: throwaway fixture repository on a disposable Gitea instance
|
||||
ref_name: main
|
||||
command: POST /api/git-sources/browse with a read-only deploy key and the host key fetched via Sencho's own probe endpoint
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitea-ssh-tag
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitea
|
||||
host_version: gitea/gitea:1.22
|
||||
node_path: local
|
||||
transport: ssh
|
||||
ref: tag
|
||||
auth: deploy-key
|
||||
ca: not-applicable
|
||||
repository: throwaway fixture repository on a disposable Gitea instance
|
||||
ref_name: v1.0-light (lightweight)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-gitea-ssh-sha
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: gitea
|
||||
host_version: gitea/gitea:1.22
|
||||
node_path: local
|
||||
transport: ssh
|
||||
ref: sha
|
||||
auth: deploy-key
|
||||
ca: not-applicable
|
||||
repository: throwaway fixture repository on a disposable Gitea instance
|
||||
ref_name: pinned commit SHA (branch tip)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
# ===== Self-hosted Forgejo, own throwaway instance on the QA fleet =====
|
||||
- id: att-2026-09-01-forgejo-https-branch
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: forgejo
|
||||
host_version: codeberg.org/forgejo/forgejo:7
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: pat
|
||||
ca: per-source
|
||||
repository: throwaway fixture repository on a disposable Forgejo instance, torn down after this pass
|
||||
ref_name: main
|
||||
command: POST /api/git-sources/browse with a per-source CA bundle and a Personal Access Token
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-forgejo-https-tag
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: forgejo
|
||||
host_version: codeberg.org/forgejo/forgejo:7
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: pat
|
||||
ca: per-source
|
||||
repository: throwaway fixture repository on a disposable Forgejo instance
|
||||
ref_name: v1.0 (annotated)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-forgejo-https-sha
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: forgejo
|
||||
host_version: codeberg.org/forgejo/forgejo:7
|
||||
node_path: local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: pat
|
||||
ca: per-source
|
||||
repository: throwaway fixture repository on a disposable Forgejo instance
|
||||
ref_name: pinned commit SHA (branch tip)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-forgejo-ssh-branch
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: forgejo
|
||||
host_version: codeberg.org/forgejo/forgejo:7
|
||||
node_path: local
|
||||
transport: ssh
|
||||
ref: branch
|
||||
auth: deploy-key
|
||||
ca: not-applicable
|
||||
repository: throwaway fixture repository on a disposable Forgejo instance
|
||||
ref_name: main
|
||||
command: POST /api/git-sources/browse with a read-only deploy key and the host key fetched via Sencho's own probe endpoint
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-forgejo-ssh-tag
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: forgejo
|
||||
host_version: codeberg.org/forgejo/forgejo:7
|
||||
node_path: local
|
||||
transport: ssh
|
||||
ref: tag
|
||||
auth: deploy-key
|
||||
ca: not-applicable
|
||||
repository: throwaway fixture repository on a disposable Forgejo instance
|
||||
ref_name: v1.0-light (lightweight)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-forgejo-ssh-sha
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: forgejo
|
||||
host_version: codeberg.org/forgejo/forgejo:7
|
||||
node_path: local
|
||||
transport: ssh
|
||||
ref: sha
|
||||
auth: deploy-key
|
||||
ca: not-applicable
|
||||
repository: throwaway fixture repository on a disposable Forgejo instance
|
||||
ref_name: pinned commit SHA (branch tip)
|
||||
command: POST /api/git-sources/browse
|
||||
result: success
|
||||
|
||||
# ===== Distribution: direct-proxy and Pilot node paths =====
|
||||
- id: att-2026-09-01-github-direct-proxy
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: github
|
||||
node_path: direct-proxy
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
ca: system
|
||||
repository: octocat/Hello-World
|
||||
ref_name: master
|
||||
command: POST /api/git-sources/browse with x-node-id targeting a remote proxy-mode fleet node
|
||||
result: success
|
||||
|
||||
- id: att-2026-09-01-github-pilot
|
||||
date: '2026-09-01'
|
||||
source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16
|
||||
host: github
|
||||
node_path: pilot
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
ca: system
|
||||
repository: octocat/Hello-World
|
||||
ref_name: master
|
||||
command: POST /api/git-sources/browse with x-node-id targeting a Pilot-agent fleet node
|
||||
result: success
|
||||
@@ -0,0 +1,631 @@
|
||||
# Git transport support matrix: canonical source of truth (SEN-9 PR 5).
|
||||
#
|
||||
# Every entry here is a claim about one exercised combination, not an
|
||||
# independently-tested dimension: a PAT test, a tag test, and a GitLab
|
||||
# observation do not jointly prove "GitLab + PAT + tag" unless something
|
||||
# actually ran that exact combination. `docs/features/git-transport-support.mdx`
|
||||
# is generated from this file (backend/scripts/git-support-matrix/render.js);
|
||||
# a backend test (git-support-matrix.test.ts) enforces byte-for-byte parity
|
||||
# and validates every rule below. Internal test paths, PR numbers, and Linear
|
||||
# IDs never appear on the published page; they live here instead.
|
||||
#
|
||||
# Schema (schema: matrix-v1):
|
||||
# implementation_baseline: the git commit this file's claims describe.
|
||||
# claims[]: one entry per exercised (transport, ref, auth, host, ca,
|
||||
# node_path[, port]) combination.
|
||||
# - support: supported | unsupported | unverified
|
||||
# - supported requires evidence with outcome: success
|
||||
# - unsupported requires evidence with outcome: rejected (a reproducible
|
||||
# refusal, not silence; an unsupported claim with no evidence is as
|
||||
# unreproducible as an unproven supported one)
|
||||
# - unverified forbids evidence entirely
|
||||
# - evidence.kind: automated (a handle into a test file + exact test
|
||||
# title) or live (a pointer into docs/git-transport-attestations.yaml).
|
||||
# Every evidence record repeats all six combination dimensions, and the
|
||||
# validator requires them to match the claim exactly: a direct-proxy
|
||||
# result proves the direct-proxy combination, nothing else.
|
||||
# limitations[]: named gaps, each with its operator-facing consequence.
|
||||
# error_model[]: the transport-facing error codes this matrix covers
|
||||
# (TransportFacingCode plus REF_DELETED and FILE_NOT_FOUND), each with
|
||||
# its HTTP status and one-line meaning. GitOps plan-lifecycle codes
|
||||
# (STALE_PLAN, PLAN_FINGERPRINT_REQUIRED, PLAN_BLOCKED, LEGACY_PENDING,
|
||||
# PLAN_UNAVAILABLE, OPERATION_IN_FLIGHT) belong to reconciliation, not
|
||||
# transport, and are listed separately so the partition is visible.
|
||||
# attestations: pointer to the retained live-evidence file.
|
||||
|
||||
version: '1'
|
||||
schema: 'matrix-v1'
|
||||
implementation_baseline: 79b86ddcd4aefdd6941f098e35990ab397b13c72
|
||||
attestations: docs/git-transport-attestations.yaml
|
||||
|
||||
claims:
|
||||
# ===== Automated: real git, real TLS/SSH, against a local fixture server =====
|
||||
# host: generic, node_path: local for all of these: no proxy hop, no
|
||||
# branded host. Branded-host and non-local node-path claims live below,
|
||||
# pending or drawn from the live attestation pass.
|
||||
|
||||
- id: https-pat-branch-system-generic-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: pat
|
||||
host: generic
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- History rewritten on this ref after resolution is detected and rejected as non-fast-forward, not silently deployed.
|
||||
evidence:
|
||||
kind: automated
|
||||
outcome: success
|
||||
handles:
|
||||
- file: backend/src/__tests__/git-transport-auth.integration.test.ts
|
||||
title: clones a private repo end-to-end with a valid token
|
||||
- file: backend/src/__tests__/git-transport-auth.integration.test.ts
|
||||
title: treats a linear branch advance as a fast-forward
|
||||
- file: backend/src/__tests__/git-transport-auth.integration.test.ts
|
||||
title: treats a multi-commit branch advance as a fast-forward
|
||||
- file: backend/src/__tests__/git-transport-auth.integration.test.ts
|
||||
title: rejects rewritten history as non-fast-forward
|
||||
|
||||
- id: https-pat-tag-system-generic-local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: pat
|
||||
host: generic
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Proven for both an annotated tag (peeled through its ^{} commit) and a lightweight tag.
|
||||
evidence:
|
||||
kind: automated
|
||||
outcome: success
|
||||
handles:
|
||||
- file: backend/src/__tests__/git-transport-auth.integration.test.ts
|
||||
title: resolves and fetches an annotated tag through the peeled commit
|
||||
- file: backend/src/__tests__/git-transport-auth.integration.test.ts
|
||||
title: resolves and fetches a lightweight tag
|
||||
|
||||
- id: https-pat-sha-system-generic-local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: pat
|
||||
host: generic
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers: []
|
||||
evidence:
|
||||
kind: automated
|
||||
outcome: success
|
||||
handles:
|
||||
- file: backend/src/__tests__/git-transport-auth.integration.test.ts
|
||||
title: resolves and fetches a pinned commit SHA
|
||||
|
||||
- id: https-none-branch-persource-generic-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
host: generic
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Also proven to survive a same-origin redirect (the repository relocating on the same host).
|
||||
evidence:
|
||||
kind: automated
|
||||
outcome: success
|
||||
handles:
|
||||
- file: backend/src/__tests__/git-private-ca.integration.test.ts
|
||||
title: clones a private-CA HTTPS repo when the per-source CA PEM is supplied
|
||||
- file: backend/src/__tests__/git-redirect.integration.test.ts
|
||||
title: resolves a ref through an unauthenticated same-host redirect
|
||||
|
||||
- id: https-pat-branch-persource-generic-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: pat
|
||||
host: generic
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Proven through a same-origin redirect; the token is forwarded to the relocated path and never offered to a different host.
|
||||
evidence:
|
||||
kind: automated
|
||||
outcome: success
|
||||
handles:
|
||||
- file: backend/src/__tests__/git-redirect.integration.test.ts
|
||||
title: resolves a ref through an authenticated same-host redirect and sends the token to the relocated path
|
||||
|
||||
- id: ssh-deploy-key-branch-na-generic-local-nonstandard-port
|
||||
transport: ssh
|
||||
ref: branch
|
||||
auth: deploy-key
|
||||
host: generic
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
port: nonstandard
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Full round trip (resolve, fetch, and content verification) proven at this port.
|
||||
evidence:
|
||||
kind: automated
|
||||
outcome: success
|
||||
handles:
|
||||
- file: backend/src/__tests__/git-transport-ssh.integration.test.ts
|
||||
title: resolves and fetches over SSH with a deploy key and trusted host key
|
||||
- file: backend/src/__tests__/git-transport-ssh.integration.test.ts
|
||||
title: 'resolves over ssh:// with a nonstandard port'
|
||||
|
||||
- id: ssh-deploy-key-branch-na-generic-local-default-port
|
||||
transport: ssh
|
||||
ref: branch
|
||||
auth: deploy-key
|
||||
host: generic
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
port: default
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Ref resolution proven at the default port; the full content fetch is proven only at a nonstandard port (see the sibling claim), not separately re-run here.
|
||||
evidence:
|
||||
kind: automated
|
||||
outcome: success
|
||||
handles:
|
||||
- file: backend/src/__tests__/git-transport-ssh.integration.test.ts
|
||||
title: resolves over scp-style URL on the default SSH port
|
||||
|
||||
# ===== Live: named Git hosts, external SaaS and self-hosted =====
|
||||
# Evidence lives in docs/git-transport-attestations.yaml. A row stays
|
||||
# `unverified` (no evidence permitted) until a live pass actually exercises
|
||||
# it; nothing here is extrapolated from a different host or node_path.
|
||||
# Live-attested 2026-09-01 (see the attestation ids referenced below).
|
||||
|
||||
- id: https-none-branch-system-github-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
host: github
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-github-branch
|
||||
|
||||
- id: https-none-tag-system-github-local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: none
|
||||
host: github
|
||||
ca: system
|
||||
node_path: local
|
||||
support: unverified
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation.
|
||||
- Not attested: no small, stable, publicly tagged GitHub repository was found for this pass within a reasonable search.
|
||||
|
||||
- id: https-none-sha-system-github-local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: none
|
||||
host: github
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-github-sha
|
||||
|
||||
- id: https-pat-branch-system-github-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: pat
|
||||
host: github
|
||||
ca: system
|
||||
node_path: local
|
||||
support: unverified
|
||||
qualifiers:
|
||||
- Not attested: requires a real GitHub Personal Access Token, which this pass does not hold.
|
||||
|
||||
- id: ssh-deploy-key-branch-na-github-local
|
||||
transport: ssh
|
||||
ref: branch
|
||||
auth: deploy-key
|
||||
host: github
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
support: unverified
|
||||
qualifiers:
|
||||
- Not attested: requires a real GitHub-registered SSH deploy key, which this pass does not hold.
|
||||
|
||||
- id: https-pat-branch-persource-gitea-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: pat
|
||||
host: gitea
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Attested against a private repository on a disposable, self-signed Gitea instance; the self-signed certificate is trusted via the per-source custom CA field, not system trust.
|
||||
- A wrong token against this private repository was separately confirmed to classify as an authentication failure, and a wrong SSH host key as a host-key mismatch.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitea-https-branch
|
||||
|
||||
- id: https-pat-tag-persource-gitea-local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: pat
|
||||
host: gitea
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Annotated tag, resolved through its peeled commit.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitea-https-tag
|
||||
|
||||
- id: https-pat-sha-persource-gitea-local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: pat
|
||||
host: gitea
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers: []
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitea-https-sha
|
||||
|
||||
- id: ssh-deploy-key-branch-na-gitea-local
|
||||
transport: ssh
|
||||
ref: branch
|
||||
auth: deploy-key
|
||||
host: gitea
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Host key fetched and trusted through Sencho's own probe endpoint, exactly as an operator would.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitea-ssh-branch
|
||||
|
||||
- id: ssh-deploy-key-tag-na-gitea-local
|
||||
transport: ssh
|
||||
ref: tag
|
||||
auth: deploy-key
|
||||
host: gitea
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Lightweight tag.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitea-ssh-tag
|
||||
|
||||
- id: ssh-deploy-key-sha-na-gitea-local
|
||||
transport: ssh
|
||||
ref: sha
|
||||
auth: deploy-key
|
||||
host: gitea
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers: []
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitea-ssh-sha
|
||||
|
||||
- id: https-pat-branch-persource-forgejo-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: pat
|
||||
host: forgejo
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Attested against a private repository on a disposable, self-signed Forgejo instance; the self-signed certificate is trusted via the per-source custom CA field, not system trust.
|
||||
- A wrong token against this private repository was separately confirmed to classify as an authentication failure, and a wrong SSH host key as a host-key mismatch.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-forgejo-https-branch
|
||||
|
||||
- id: https-pat-tag-persource-forgejo-local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: pat
|
||||
host: forgejo
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Annotated tag, resolved through its peeled commit.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-forgejo-https-tag
|
||||
|
||||
- id: https-pat-sha-persource-forgejo-local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: pat
|
||||
host: forgejo
|
||||
ca: per-source
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers: []
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-forgejo-https-sha
|
||||
|
||||
- id: ssh-deploy-key-branch-na-forgejo-local
|
||||
transport: ssh
|
||||
ref: branch
|
||||
auth: deploy-key
|
||||
host: forgejo
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Host key fetched and trusted through Sencho's own probe endpoint, exactly as an operator would.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-forgejo-ssh-branch
|
||||
|
||||
- id: ssh-deploy-key-tag-na-forgejo-local
|
||||
transport: ssh
|
||||
ref: tag
|
||||
auth: deploy-key
|
||||
host: forgejo
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Lightweight tag.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-forgejo-ssh-tag
|
||||
|
||||
- id: ssh-deploy-key-sha-na-forgejo-local
|
||||
transport: ssh
|
||||
ref: sha
|
||||
auth: deploy-key
|
||||
host: forgejo
|
||||
ca: not-applicable
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers: []
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-forgejo-ssh-sha
|
||||
|
||||
- id: https-none-branch-system-gitlab-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
host: gitlab
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitlab-branch
|
||||
|
||||
- id: https-none-tag-system-gitlab-local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: none
|
||||
host: gitlab
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitlab-tag
|
||||
|
||||
- id: https-none-sha-system-gitlab-local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: none
|
||||
host: gitlab
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-gitlab-sha
|
||||
|
||||
- id: https-none-branch-system-bitbucket-local
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
host: bitbucket
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-bitbucket-branch
|
||||
|
||||
- id: https-none-tag-system-bitbucket-local
|
||||
transport: https
|
||||
ref: tag
|
||||
auth: none
|
||||
host: bitbucket
|
||||
ca: system
|
||||
node_path: local
|
||||
support: unverified
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation.
|
||||
- Not attested: the public fixture repository used for this pass carries no tags.
|
||||
|
||||
- id: https-none-sha-system-bitbucket-local
|
||||
transport: https
|
||||
ref: sha
|
||||
auth: none
|
||||
host: bitbucket
|
||||
ca: system
|
||||
node_path: local
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-bitbucket-sha
|
||||
|
||||
# ===== Distribution: does the same combination hold off the local node =====
|
||||
# Scoped to host: github (a real branded host reachable over public egress)
|
||||
# rather than the self-hosted fixtures above: the fleet's inbound firewall
|
||||
# only opens ports 22 and 1852, so a remote or Pilot node cannot reach a
|
||||
# container port newly published on the hub, but every node has unrestricted
|
||||
# outbound egress to the public internet.
|
||||
|
||||
- id: https-none-branch-system-github-direct-proxy
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
host: github
|
||||
ca: system
|
||||
node_path: direct-proxy
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Proves the direct-proxy path forwards and executes the fetch on the target node; it does not by itself prove any other host, ref, auth, or CA combination on this path.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-github-direct-proxy
|
||||
|
||||
- id: https-none-branch-system-github-pilot
|
||||
transport: https
|
||||
ref: branch
|
||||
auth: none
|
||||
host: github
|
||||
ca: system
|
||||
node_path: pilot
|
||||
support: supported
|
||||
qualifiers:
|
||||
- Proves the Pilot dial-out path forwards and executes the fetch on the target node; it does not by itself prove any other host, ref, auth, or CA combination on this path.
|
||||
evidence:
|
||||
kind: live
|
||||
outcome: success
|
||||
attestation: att-2026-09-01-github-pilot
|
||||
|
||||
limitations:
|
||||
- id: no-rate-limit-classification
|
||||
title: No rate-limit classification
|
||||
statement: A Git host rate-limit response (for example GitHub's secondary rate limits) is not classified as its own error state. It surfaces as an authentication failure or a generic transport error depending on the host's exact response. Wait and retry; there is no dedicated rate-limit message or backoff guidance yet.
|
||||
|
||||
- id: no-git-lfs
|
||||
title: No Git LFS
|
||||
statement: Compose and env files tracked via Git LFS are rejected rather than silently fetched as pointer stubs. Commit plain files instead.
|
||||
|
||||
- id: no-submodules
|
||||
title: No submodules
|
||||
statement: Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when .gitmodules is present.
|
||||
|
||||
- id: no-sparse-partial-clone
|
||||
title: No sparse or partial clone
|
||||
statement: Every fetch materializes the complete repository at the resolved commit (shallow, single-branch); there is no sparse or partial clone for large monorepos.
|
||||
|
||||
- id: no-github-app-authentication
|
||||
title: No GitHub App authentication
|
||||
statement: Authentication is Personal Access Token or SSH deploy key only. GitHub App installation tokens are not supported.
|
||||
|
||||
- id: no-provider-pull-request-revisions
|
||||
title: No provider pull/merge request revisions
|
||||
statement: Sources track a branch, a tag, or a pinned commit SHA. A provider-specific pull request or merge request revision (for example GitHub's refs/pull/N/head) is not a supported ref shape.
|
||||
|
||||
- id: outbound-target-restrictions
|
||||
title: Outbound target restrictions
|
||||
statement: Repository targets that resolve to loopback, link-local, multicast, or selected special-use addresses are refused before any request is sent. Private hosts on an operator's own LAN or VPN are not affected by this restriction.
|
||||
|
||||
- id: branch-tag-name-collision
|
||||
title: Branch/tag name collisions
|
||||
statement: A bare ref name resolves as a branch first, then as a tag. If an operator renames a branch and a tag of the same name later appears, the source silently starts resolving the tag instead. A ref name that is also a valid 40 or 64 character hex string resolves as a commit SHA before either lookup.
|
||||
|
||||
error_model:
|
||||
- code: REPO_NOT_FOUND
|
||||
label: Repository not found
|
||||
status: 404
|
||||
meaning: The repository does not exist, or (indistinguishably, matching GitHub's own private-repo masking) exists but is private and no usable credential was supplied.
|
||||
- code: AUTH_FAILED
|
||||
label: Authentication failed
|
||||
status: 400
|
||||
meaning: The Git host rejected the supplied credential. Mapped to 400, never 401, so an upstream Git-host auth failure never triggers the dashboard's own session logout.
|
||||
- code: SSH_HOST_KEY_FAILED
|
||||
label: SSH host key mismatch
|
||||
status: 400
|
||||
meaning: The server's SSH host key does not match the fingerprint trusted for this source.
|
||||
- code: REF_NOT_FOUND
|
||||
label: Ref not found
|
||||
status: 404
|
||||
meaning: The configured branch, tag, or commit SHA does not exist on the remote.
|
||||
- code: REF_DELETED
|
||||
label: Ref deleted or rewritten
|
||||
status: 404
|
||||
meaning: A ref that previously resolved to a commit no longer matches that history (deleted, force-pushed, or superseded by a same-named tag).
|
||||
- code: UNSUPPORTED_REF
|
||||
label: Commit not reachable on this host
|
||||
status: 400
|
||||
meaning: A pinned commit SHA that the Git host will not serve because it is not advertised by any branch or tag tip.
|
||||
- code: NETWORK_TIMEOUT
|
||||
label: Network timeout
|
||||
status: 504
|
||||
meaning: A connect, fetch, or DNS-resolution timeout, or a target the host actively refused.
|
||||
- code: GIT_ERROR
|
||||
label: Git error
|
||||
status: 400
|
||||
meaning: Any other transport failure not covered above (invalid URL, disallowed target, TLS/certificate problem, oversized repository, canceled fetch), classified with a specific operator-facing message.
|
||||
- code: FILE_NOT_FOUND
|
||||
label: File not found
|
||||
status: 404
|
||||
meaning: A configured compose or env file path does not exist on the resolved commit.
|
||||
|
||||
reconciliation_only_codes:
|
||||
# Not part of this transport matrix; listed so the partition of
|
||||
# GitSourceErrorCode is visible and auditable. Owned by the GitOps
|
||||
# change-plan lifecycle, not by repository transport.
|
||||
- STALE_PLAN
|
||||
- PLAN_FINGERPRINT_REQUIRED
|
||||
- PLAN_BLOCKED
|
||||
- LEGACY_PENDING
|
||||
- PLAN_UNAVAILABLE
|
||||
- OPERATION_IN_FLIGHT
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Unit coverage for the shared dependency probes in externalDeps.ts.
|
||||
*
|
||||
* Not a browser test: these run against the probe functions directly with an
|
||||
* injected predicate, so absence of git/sshd can be exercised without
|
||||
* removing system binaries.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { requireGitBinary, requireSshd } from './externalDeps';
|
||||
|
||||
test.describe('external dependency probes', () => {
|
||||
test.afterEach(() => {
|
||||
delete process.env.CI;
|
||||
});
|
||||
|
||||
test('requireGitBinary returns true when git is present, locally or in CI', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireGitBinary(() => true)).toBe(true);
|
||||
process.env.CI = '1';
|
||||
expect(requireGitBinary(() => true)).toBe(true);
|
||||
});
|
||||
|
||||
test('requireGitBinary returns false when git is absent locally', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireGitBinary(() => false)).toBe(false);
|
||||
});
|
||||
|
||||
test('requireGitBinary throws when git is absent under CI', () => {
|
||||
process.env.CI = '1';
|
||||
expect(() => requireGitBinary(() => false)).toThrow(/git is required in CI/);
|
||||
});
|
||||
|
||||
test('requireSshd returns true when sshd is present, locally or in CI', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireSshd(() => true)).toBe(true);
|
||||
process.env.CI = '1';
|
||||
expect(requireSshd(() => true)).toBe(true);
|
||||
});
|
||||
|
||||
test('requireSshd returns false when sshd is absent locally', () => {
|
||||
delete process.env.CI;
|
||||
expect(requireSshd(() => false)).toBe(false);
|
||||
});
|
||||
|
||||
test('requireSshd throws when sshd is absent under CI', () => {
|
||||
process.env.CI = '1';
|
||||
expect(() => requireSshd(() => false)).toThrow(/sshd is required in CI/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Shared availability probes for the real-git and real-sshd E2E fixtures.
|
||||
*
|
||||
* Mirrors backend/src/__tests__/__helpers__/externalDeps.ts. Kept as a
|
||||
* separate file rather than a shared import: backend's tsconfig pins
|
||||
* `rootDir` to backend/src, so a cross-directory import would fail
|
||||
* `tsc --noEmit` there.
|
||||
*
|
||||
* `gitServer.helper.ts` and `sshGit.helper.ts` used to each probe with their
|
||||
* own local `spawnSync` check and let a missing dependency silently skip the
|
||||
* spec, in CI as well as locally. These wrappers keep that local-dev
|
||||
* behavior but throw under CI, where the dependency is expected to be
|
||||
* present and a skip would be a false claim of coverage.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
export type DependencyProbe = () => boolean;
|
||||
|
||||
export const defaultGitProbe: DependencyProbe = () =>
|
||||
spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
export const defaultSshdProbe: DependencyProbe = () =>
|
||||
spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
function requireDependency(name: string, hint: string, probe: DependencyProbe): boolean {
|
||||
const present = probe();
|
||||
if (!present && process.env.CI) {
|
||||
throw new Error(`${name} is required in CI but was not found. ${hint}`);
|
||||
}
|
||||
return present;
|
||||
}
|
||||
|
||||
/** True when the system `git` binary is available; throws under CI if not. */
|
||||
export function requireGitBinary(probe: DependencyProbe = defaultGitProbe): boolean {
|
||||
return requireDependency(
|
||||
'git',
|
||||
'Ensure the CI image installs the git CLI before running the E2E suite.',
|
||||
probe,
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a local `sshd` binary is available; throws under CI if not. */
|
||||
export function requireSshd(probe: DependencyProbe = defaultSshdProbe): boolean {
|
||||
return requireDependency(
|
||||
'sshd',
|
||||
'Ensure the CI image installs openssh-server and frees loopback port 22 (see .github/workflows/ci.yml).',
|
||||
probe,
|
||||
);
|
||||
}
|
||||
@@ -12,17 +12,18 @@
|
||||
* NODE_EXTRA_CA_CERTS (wired in CI and in the local validation lifecycle).
|
||||
* The key is a throwaway test certificate with no security value.
|
||||
*
|
||||
* Soft-skips when the system git binary is unavailable.
|
||||
* Soft-skips when the system git binary is unavailable (locally; throws
|
||||
* under CI, see externalDeps.ts).
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { requireGitBinary } from './externalDeps';
|
||||
|
||||
export function gitAvailable(): boolean {
|
||||
const probe = spawnSync('git', ['--version'], { stdio: 'ignore' });
|
||||
return probe.status === 0;
|
||||
return requireGitBinary();
|
||||
}
|
||||
|
||||
/** Build a git repository with the given files on `branch`, returns the repo dir. */
|
||||
|
||||
@@ -10,10 +10,10 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { requireGitBinary, requireSshd } from './externalDeps';
|
||||
|
||||
export function sshGitFixtureAvailable(): boolean {
|
||||
return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0
|
||||
&& spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0;
|
||||
return requireGitBinary() && requireSshd();
|
||||
}
|
||||
|
||||
const COMPOSE_FIXTURE = `services:
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"test": "cd backend && npm test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"matrix:render": "cd backend && npm run matrix:render",
|
||||
"catalog:validate": "node scripts/website-catalog/canonical-validate.mjs",
|
||||
"catalog:sync": "node scripts/website-catalog/sync-feature-catalog.mjs",
|
||||
"catalog:drift": "node scripts/website-catalog/check-website-drift.mjs",
|
||||
|
||||
Reference in New Issue
Block a user