Skip to content

Commit 7875515

Browse files
committed
fix(serve): compat fallback for incoming push when receive-pack is disabled
1 parent 7294f39 commit 7875515

7 files changed

Lines changed: 340 additions & 3 deletions

docs/ci-trigger-spec-ja.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ incoming ref は `POST /git/<session_id>/git-receive-pack` の request body か
2222
- `RELAY_TRIGGER_WEBHOOK_URL` が設定されている
2323
- incoming ref が設定済みプレフィックスのいずれかに一致する
2424
- `git-receive-pack` の relay 応答ステータスが 2xx である
25+
- host が `receive-pack not enabled` を返した場合でも、`refs/relay/incoming/...` push については
26+
relay が互換レスポンス(2xx)に変換できる
2527

2628
設定値:
2729

@@ -35,6 +37,7 @@ incoming ref は `POST /git/<session_id>/git-receive-pack` の request body か
3537
- 2xx は成功
3638
- 非 2xx とネットワークエラーは trigger dispatch failure としてログ記録
3739
- `git-receive-pack` 応答が非 2xx の場合、incoming-ref webhook は送信しない
40+
- 互換変換が適用された incoming push は 2xx として扱われ、incoming-ref webhook を送信する
3841

3942
## 3. 送信 Webhook Payload
4043

docs/ci-trigger-spec.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Relay dispatches a webhook only when all of the following are true:
2222
- `RELAY_TRIGGER_WEBHOOK_URL` is configured
2323
- Incoming ref matches one of configured prefixes
2424
- Relay responded to `git-receive-pack` with a 2xx status
25+
- Even if the host returns `receive-pack not enabled`, relay may translate that into a synthetic 2xx
26+
for `refs/relay/incoming/...` pushes
2527

2628
Configuration:
2729

@@ -35,6 +37,7 @@ Dispatch result behavior:
3537
- Any 2xx is treated as success
3638
- Non-2xx or network errors are logged as trigger dispatch failures
3739
- If `git-receive-pack` returns non-2xx, relay does not emit incoming-ref webhooks
40+
- Incoming push compatibility translation is treated as 2xx and emits incoming-ref webhooks
3841

3942
## 3. Outbound Webhook Payload
4043

docs/usage-guide-ja.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ bit relay sync push relay+https://bit-relay.mizchi.workers.dev
194194
| `--auto-fetch` | feature broadcast 検知時に自動 fetch |
195195
| `--repo <name>` | リポジトリ名を指定(名前付きセッションを有効化) |
196196

197+
互換モードとして、host 側が `receive-pack not enabled` を返す環境でも `refs/relay/incoming/...` への
198+
push は relay 側で受理できる(CI トリガー用途)。
199+
197200
### relay URL 形式
198201

199202
| 形式 | 動作 |

docs/usage-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ Once verified, relay sessions use named paths (e.g., `alice/my-repo`) instead of
198198
| `--auto-fetch` | Auto-fetch when feature broadcasts are detected |
199199
| `--repo <name>` | Advertise a repository name (enables named sessions) |
200200

201+
As a compatibility behavior, even when the host returns `receive-pack not enabled`, relay can still
202+
accept pushes to `refs/relay/incoming/...` for CI triggering.
203+
201204
### Relay URL Formats
202205

203206
| Format | Behavior |

src/git_serve_session.ts

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface PendingGitRequest {
77
headers: Record<string, string>;
88
bodyBase64: string | null;
99
incomingRefs: string[];
10+
allRelayRefs: string[];
1011
resolve: (response: Response) => void;
1112
timeoutId: ReturnType<typeof setTimeout>;
1213
createdAt: number;
@@ -28,6 +29,10 @@ export interface GitServeSessionState {
2829
const REQUEST_TIMEOUT_MS = 60_000;
2930
const POLL_TIMEOUT_MS = 30_000;
3031
export const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
32+
const RECEIVE_PACK_DISABLED_MESSAGE = 'receive-pack not enabled';
33+
const ZERO_OID = '0000000000000000000000000000000000000000';
34+
const textEncoder = new TextEncoder();
35+
const textDecoder = new TextDecoder();
3136

3237
export interface GitServeSessionOptions {
3338
sessionTtlMs?: number;
@@ -38,6 +43,7 @@ export interface GitServeSessionOptions {
3843
}
3944

4045
const INCOMING_REF_PATTERN = /refs\/relay\/incoming\/[A-Za-z0-9._/-]{1,255}/g;
46+
const RELAY_REF_PATTERN = /refs\/[A-Za-z0-9._/-]{1,255}/g;
4147

4248
function generateRequestId(): string {
4349
return crypto.randomUUID();
@@ -71,7 +77,7 @@ function fromBase64(b64: string): Uint8Array {
7177
}
7278

7379
function extractIncomingRefs(bodyBytes: Uint8Array): string[] {
74-
const decoded = new TextDecoder().decode(bodyBytes);
80+
const decoded = textDecoder.decode(bodyBytes);
7581
const matches = decoded.match(INCOMING_REF_PATTERN);
7682
if (!matches || matches.length === 0) return [];
7783
const seen = new Set<string>();
@@ -84,10 +90,88 @@ function extractIncomingRefs(bodyBytes: Uint8Array): string[] {
8490
return refs;
8591
}
8692

93+
function extractAllRelayRefs(bodyBytes: Uint8Array): string[] {
94+
const decoded = textDecoder.decode(bodyBytes);
95+
const matches = decoded.match(RELAY_REF_PATTERN);
96+
if (!matches || matches.length === 0) return [];
97+
const seen = new Set<string>();
98+
const refs: string[] = [];
99+
for (const ref of matches) {
100+
if (seen.has(ref)) continue;
101+
seen.add(ref);
102+
refs.push(ref);
103+
}
104+
return refs;
105+
}
106+
87107
function isSuccessfulHttpStatus(status: number): boolean {
88108
return status >= 200 && status < 300;
89109
}
90110

111+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
112+
const copied = new Uint8Array(bytes.byteLength);
113+
copied.set(bytes);
114+
return copied.buffer;
115+
}
116+
117+
function toPktLine(payload: string): string {
118+
const length = textEncoder.encode(payload).byteLength + 4;
119+
return length.toString(16).padStart(4, '0') + payload;
120+
}
121+
122+
function buildReceivePackAdvertisementBody(): ArrayBuffer {
123+
const capabilities = [
124+
'report-status',
125+
'report-status-v2',
126+
'delete-refs',
127+
'ofs-delta',
128+
'atomic',
129+
'quiet',
130+
];
131+
const firstRef = `${ZERO_OID} capabilities^{}\0${capabilities.join(' ')}\n`;
132+
const body = `${toPktLine('# service=git-receive-pack\n')}0000${toPktLine(firstRef)}0000`;
133+
return toArrayBuffer(textEncoder.encode(body));
134+
}
135+
136+
function buildReceivePackResultBody(incomingRefs: string[]): ArrayBuffer {
137+
let body = toPktLine('unpack ok\n');
138+
for (const ref of incomingRefs) {
139+
body += toPktLine(`ok ${ref}\n`);
140+
}
141+
body += '0000';
142+
return toArrayBuffer(textEncoder.encode(body));
143+
}
144+
145+
function splitPath(pathWithQuery: string): { pathname: string; search: string } {
146+
const [pathname, search = ''] = pathWithQuery.split('?', 2);
147+
return { pathname, search };
148+
}
149+
150+
function isReceivePackInfoRefsPath(pathWithQuery: string): boolean {
151+
const { pathname, search } = splitPath(pathWithQuery);
152+
if (pathname !== '/info/refs') return false;
153+
const params = new URLSearchParams(search);
154+
return params.get('service') === 'git-receive-pack';
155+
}
156+
157+
function isReceivePackRpcPath(pathWithQuery: string): boolean {
158+
const { pathname } = splitPath(pathWithQuery);
159+
return pathname === '/git-receive-pack';
160+
}
161+
162+
function shouldCompatAcceptIncomingOnlyPush(pending: PendingGitRequest): boolean {
163+
if (!isReceivePackRpcPath(pending.path)) return false;
164+
if (pending.incomingRefs.length === 0) return false;
165+
if (pending.allRelayRefs.length === 0) return false;
166+
return pending.allRelayRefs.every((ref) => ref.startsWith('refs/relay/incoming/'));
167+
}
168+
169+
function isReceivePackDisabledBody(responseBody: ArrayBuffer | null): boolean {
170+
if (!responseBody) return false;
171+
const bodyText = textDecoder.decode(new Uint8Array(responseBody)).trim().toLowerCase();
172+
return bodyText === RECEIVE_PACK_DISABLED_MESSAGE;
173+
}
174+
91175
export interface PersistableSessionState {
92176
active: boolean;
93177
sessionToken: string;
@@ -230,6 +314,10 @@ export function createGitServeSession(options?: GitServeSessionOptions): {
230314
bodyBytes !== null
231315
? extractIncomingRefs(bodyBytes)
232316
: [];
317+
const allRelayRefs = request.method === 'POST' && gitPath.endsWith('/git-receive-pack') &&
318+
bodyBytes !== null
319+
? extractAllRelayRefs(bodyBytes)
320+
: [];
233321

234322
const headers: Record<string, string> = {};
235323
for (const [key, value] of request.headers.entries()) {
@@ -267,6 +355,7 @@ export function createGitServeSession(options?: GitServeSessionOptions): {
267355
headers,
268356
bodyBase64,
269357
incomingRefs,
358+
allRelayRefs,
270359
resolve,
271360
timeoutId,
272361
createdAt: Date.now(),
@@ -382,10 +471,28 @@ export function createGitServeSession(options?: GitServeSessionOptions): {
382471
}
383472
}
384473

385-
if (pending.incomingRefs.length > 0 && isSuccessfulHttpStatus(status)) {
474+
let finalStatus = status;
475+
let finalHeaders = httpHeaders;
476+
let finalBody = responseBody;
477+
const receivePackDisabled = status === 403 && isReceivePackDisabledBody(responseBody);
478+
if (receivePackDisabled && isReceivePackInfoRefsPath(pending.path)) {
479+
finalStatus = 200;
480+
finalHeaders = new Headers(httpHeaders);
481+
finalHeaders.set('content-type', 'application/x-git-receive-pack-advertisement');
482+
finalHeaders.set('cache-control', 'no-cache');
483+
finalBody = buildReceivePackAdvertisementBody();
484+
} else if (receivePackDisabled && shouldCompatAcceptIncomingOnlyPush(pending)) {
485+
finalStatus = 200;
486+
finalHeaders = new Headers(httpHeaders);
487+
finalHeaders.set('content-type', 'application/x-git-receive-pack-result');
488+
finalHeaders.set('cache-control', 'no-cache');
489+
finalBody = buildReceivePackResultBody(pending.incomingRefs);
490+
}
491+
492+
if (pending.incomingRefs.length > 0 && isSuccessfulHttpStatus(finalStatus)) {
386493
emitIncomingRefEvents(pending.incomingRefs);
387494
}
388-
pending.resolve(new Response(responseBody, { status, headers: httpHeaders }));
495+
pending.resolve(new Response(finalBody, { status: finalStatus, headers: finalHeaders }));
389496

390497
return Response.json({ ok: true });
391498
}

tests/git_serve_session_test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,133 @@ Deno.test('git-receive-pack does not emit incoming_ref events on non-2xx respons
396396
}
397397
});
398398

399+
Deno.test('receive-pack disabled error is translated for refs/relay/incoming push flow', async () => {
400+
const seenRefs: string[] = [];
401+
const session = createGitServeSession({
402+
onIncomingRef(event) {
403+
seenRefs.push(event.ref);
404+
},
405+
});
406+
407+
try {
408+
const token = await registerSession(session);
409+
410+
const discoveryPromise = session.fetch(
411+
new Request(`http://do/git/info/refs?service=git-receive-pack&session_token=${token}`),
412+
);
413+
await new Promise((r) => setTimeout(r, 10));
414+
const discoveryPoll = await session.fetch(
415+
new Request(`http://do/poll?timeout=1&session_token=${token}`),
416+
);
417+
const discoveryPollBody = await discoveryPoll.json();
418+
assertEquals(discoveryPollBody.requests[0].path, '/info/refs?service=git-receive-pack');
419+
await session.fetch(
420+
new Request(`http://do/respond?session_token=${token}`, {
421+
method: 'POST',
422+
headers: { 'content-type': 'application/json' },
423+
body: JSON.stringify({
424+
request_id: discoveryPollBody.requests[0].request_id,
425+
status: 403,
426+
headers: { 'content-type': 'text/plain; charset=utf-8' },
427+
body_base64: btoa('receive-pack not enabled'),
428+
}),
429+
}),
430+
);
431+
const discoveryRes = await discoveryPromise;
432+
assertEquals(discoveryRes.status, 200);
433+
assertEquals(
434+
discoveryRes.headers.get('content-type'),
435+
'application/x-git-receive-pack-advertisement',
436+
);
437+
const discoveryBody = await discoveryRes.text();
438+
assertEquals(discoveryBody.includes('# service=git-receive-pack'), true);
439+
440+
const receivePackPromise = session.fetch(
441+
new Request(`http://do/git/git-receive-pack?session_token=${token}`, {
442+
method: 'POST',
443+
headers: { 'content-type': 'application/x-git-receive-pack-request' },
444+
body: 'refs/relay/incoming/main/ci-compat-1',
445+
}),
446+
);
447+
await new Promise((r) => setTimeout(r, 10));
448+
const receivePackPoll = await session.fetch(
449+
new Request(`http://do/poll?timeout=1&session_token=${token}`),
450+
);
451+
const receivePackPollBody = await receivePackPoll.json();
452+
assertEquals(receivePackPollBody.requests[0].path, '/git-receive-pack');
453+
await session.fetch(
454+
new Request(`http://do/respond?session_token=${token}`, {
455+
method: 'POST',
456+
headers: { 'content-type': 'application/json' },
457+
body: JSON.stringify({
458+
request_id: receivePackPollBody.requests[0].request_id,
459+
status: 403,
460+
headers: { 'content-type': 'text/plain; charset=utf-8' },
461+
body_base64: btoa('receive-pack not enabled'),
462+
}),
463+
}),
464+
);
465+
466+
const receivePackRes = await receivePackPromise;
467+
assertEquals(receivePackRes.status, 200);
468+
assertEquals(
469+
receivePackRes.headers.get('content-type'),
470+
'application/x-git-receive-pack-result',
471+
);
472+
const receivePackBody = await receivePackRes.text();
473+
assertEquals(receivePackBody.includes('unpack ok'), true);
474+
assertEquals(receivePackBody.includes('ok refs/relay/incoming/main/ci-compat-1'), true);
475+
assertEquals(seenRefs, ['refs/relay/incoming/main/ci-compat-1']);
476+
} finally {
477+
session.cleanup();
478+
}
479+
});
480+
481+
Deno.test('receive-pack disabled fallback is not applied when non-incoming refs are included', async () => {
482+
const seenRefs: string[] = [];
483+
const session = createGitServeSession({
484+
onIncomingRef(event) {
485+
seenRefs.push(event.ref);
486+
},
487+
});
488+
489+
try {
490+
const token = await registerSession(session);
491+
492+
const receivePackPromise = session.fetch(
493+
new Request(`http://do/git/git-receive-pack?session_token=${token}`, {
494+
method: 'POST',
495+
headers: { 'content-type': 'application/x-git-receive-pack-request' },
496+
body: 'refs/heads/main\nrefs/relay/incoming/main/ci-mixed',
497+
}),
498+
);
499+
await new Promise((r) => setTimeout(r, 10));
500+
const receivePackPoll = await session.fetch(
501+
new Request(`http://do/poll?timeout=1&session_token=${token}`),
502+
);
503+
const receivePackPollBody = await receivePackPoll.json();
504+
await session.fetch(
505+
new Request(`http://do/respond?session_token=${token}`, {
506+
method: 'POST',
507+
headers: { 'content-type': 'application/json' },
508+
body: JSON.stringify({
509+
request_id: receivePackPollBody.requests[0].request_id,
510+
status: 403,
511+
headers: { 'content-type': 'text/plain; charset=utf-8' },
512+
body_base64: btoa('receive-pack not enabled'),
513+
}),
514+
}),
515+
);
516+
517+
const receivePackRes = await receivePackPromise;
518+
assertEquals(receivePackRes.status, 403);
519+
assertEquals(await receivePackRes.text(), 'receive-pack not enabled');
520+
assertEquals(seenRefs, []);
521+
} finally {
522+
session.cleanup();
523+
}
524+
});
525+
399526
Deno.test('cleanup resolves pending requests with 410', async () => {
400527
const session = createGitServeSession();
401528
const token = await registerSession(session);

0 commit comments

Comments
 (0)