Skip to content

Commit f3ab791

Browse files
Brooooooklynclaude
andauthored
fix(api): env-aware GitHub check-run details_url (staging linked to prod domain) (#196)
The "Void Build" check-run's details_url was hardcoded `https://${projectSlug}.void.app` at every call site, so on staging the check run linked to the production traffic domain instead of `*.staging.void.app` (observed live: a staging build's check-run pointed at `moonglade.void.app`). Add a `projectUrl(config)` helper that resolves the base domain from the per-environment `SITE_DOMAIN` binding (`env.SITE_DOMAIN ?? SITE_DOMAIN`) — the same source the deploy-URL output uses — and route all 8 check-run details_url emitters through it: the create POST, the 5 direct terminal finalizes, the `finalizeCheckRunToStatus` status→conclusion helper, and cancelBuild's finalize (which previously passed no details_url at all). Prod (SITE_DOMAIN unset or `void.app`) is byte-identical to before. Tests: staging create+finalize link to `{slug}.staging.void.app`; the finalizeCheckRunToStatus PATCH path (pre-start prior-run finalize) links to staging; unset SITE_DOMAIN falls back to prod; cancelBuild now emits details_url. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ce37a2a commit f3ab791

2 files changed

Lines changed: 86 additions & 8 deletions

File tree

‎packages/api/src/build-manager.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
signDeployToken,
66
} from './auth/deploy-token.ts';
77
import { type BuildConfig, type BuildLogLine, isContainerCapacityError } from './build-types.ts';
8+
import { SITE_DOMAIN } from './constants.ts';
89
import {
910
createDb,
1011
getBuild,
@@ -339,7 +340,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
339340
name: 'Void Build',
340341
headSha: config.commitSha,
341342
externalId: config.buildId,
342-
detailsUrl: `https://${config.projectSlug}.void.app`,
343+
detailsUrl: this.projectUrl(config),
343344
title: 'Void build in progress',
344345
summary: `Building \`${config.branch}\` @ ${config.commitSha.slice(0, 7)}`,
345346
});
@@ -591,7 +592,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
591592
// never contradicts the DB.
592593
if (won) {
593594
await this.finalizeCheckRun(config, 'failure', {
594-
detailsUrl: `https://${config.projectSlug}.void.app`,
595+
detailsUrl: this.projectUrl(config),
595596
title: 'Void build failed',
596597
summary: message,
597598
});
@@ -605,6 +606,17 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
605606
}
606607
}
607608

609+
/**
610+
* The project's public traffic URL — used as the check-run `details_url`. Staging
611+
* and production serve user traffic on different base domains (`staging.void.app`
612+
* vs `void.app`), so the link must be env-aware: read the per-environment
613+
* `SITE_DOMAIN` binding (the same source the deploy-URL output uses), falling back
614+
* to the production constant when unset.
615+
*/
616+
private projectUrl(config: BuildConfig): string {
617+
return `https://${config.projectSlug}.${this.env.SITE_DOMAIN ?? SITE_DOMAIN}`;
618+
}
619+
608620
/**
609621
* Finalize this build's GitHub Check Run with a terminal conclusion. BEST-EFFORT:
610622
* resolves the run id from, in order, an explicit in-hand id → the in-memory
@@ -680,7 +692,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
680692
config,
681693
conclusion as CheckRunConclusion,
682694
{
683-
detailsUrl: `https://${config.projectSlug}.void.app`,
695+
detailsUrl: this.projectUrl(config),
684696
title: `Void build ${conclusion === 'success' ? 'succeeded' : conclusion}`,
685697
summary: `Build ${status}.`,
686698
},
@@ -797,7 +809,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
797809
// hand) so a deployment lookup miss does NOT cost the link.
798810
if (won) {
799811
await this.finalizeCheckRun(config, 'success', {
800-
detailsUrl: `https://${config.projectSlug}.void.app`,
812+
detailsUrl: this.projectUrl(config),
801813
title: 'Void build succeeded',
802814
summary: deploymentId ? 'Deployment is live.' : 'Build complete.',
803815
});
@@ -820,7 +832,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
820832
// the out-of-band recorded outcome so GitHub matches the DB.
821833
if (won) {
822834
await this.finalizeCheckRun(config, 'failure', {
823-
detailsUrl: `https://${config.projectSlug}.void.app`,
835+
detailsUrl: this.projectUrl(config),
824836
title: 'Void build failed',
825837
summary: `Build exited with code ${params.exitCode}.`,
826838
});
@@ -860,7 +872,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
860872
// the out-of-band recorded outcome so GitHub matches the DB.
861873
if (won) {
862874
await this.finalizeCheckRun(config, 'failure', {
863-
detailsUrl: `https://${config.projectSlug}.void.app`,
875+
detailsUrl: this.projectUrl(config),
864876
title: 'Void build failed',
865877
summary: message,
866878
});
@@ -939,7 +951,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
939951
// the out-of-band recorded outcome so GitHub matches the DB.
940952
if (won) {
941953
await this.finalizeCheckRun(config, 'failure', {
942-
detailsUrl: `https://${config.projectSlug}.void.app`,
954+
detailsUrl: this.projectUrl(config),
943955
title: 'Void build failed',
944956
summary: 'build timed out',
945957
});
@@ -995,6 +1007,7 @@ export class BuildManager extends Container<ApiEnv['Bindings']> {
9951007
// outcome so GitHub matches the DB.
9961008
if (won) {
9971009
await this.finalizeCheckRun(config, 'cancelled', {
1010+
detailsUrl: this.projectUrl(config),
9981011
title: 'Void build cancelled',
9991012
summary: reason,
10001013
});

‎packages/api/test/integration/api-check-runs.test.ts‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,12 @@ describe('BuildManager check-run lifecycle — finalize terminal sites', () => {
646646
await m.cancelBuild('project disconnected');
647647

648648
expect(patches).toHaveLength(1);
649-
expect(patches[0].body).toMatchObject({ conclusion: 'cancelled' });
649+
// details_url is now set on the cancel finalize (was previously omitted) and is
650+
// env-aware via projectUrl() — default env (no SITE_DOMAIN) → prod fallback.
651+
expect(patches[0].body).toMatchObject({
652+
conclusion: 'cancelled',
653+
details_url: 'https://my-app.void.app',
654+
});
650655
const output = patches[0].body?.output as { summary?: string };
651656
expect(output.summary).toBe('project disconnected');
652657
});
@@ -1383,3 +1388,63 @@ describe('BuildManager check-run lifecycle — pre-start finalize is CAS-aware (
13831388
expect(dbState.status.get('b1')).toBe('cancelled');
13841389
});
13851390
});
1391+
1392+
describe('BuildManager check-run lifecycle — details_url is env-aware (SITE_DOMAIN)', () => {
1393+
// The check-run details_url links to the project's public traffic URL. Staging and
1394+
// production serve on different base domains, so the link must follow the
1395+
// per-environment SITE_DOMAIN binding (the same source the deploy-URL output uses),
1396+
// not a hardcoded `.void.app`.
1397+
it('staging SITE_DOMAIN → create + finalize link to {slug}.staging.void.app', async () => {
1398+
const { posts, patches } = installFetch();
1399+
const ctx = makeStubCtx();
1400+
const env = makeEnv();
1401+
env.SITE_DOMAIN = 'staging.void.app';
1402+
const m = makeManager(ctx, env);
1403+
1404+
await m.startBuild(makeConfig());
1405+
expect(posts).toHaveLength(1);
1406+
expect(posts[0].body).toMatchObject({ details_url: 'https://my-app.staging.void.app' });
1407+
1408+
await m.onStop({ exitCode: 0, reason: 'exit' });
1409+
expect(patches).toHaveLength(1);
1410+
expect(patches[0].body).toMatchObject({ details_url: 'https://my-app.staging.void.app' });
1411+
});
1412+
1413+
it('unset SITE_DOMAIN → falls back to the production {slug}.void.app', async () => {
1414+
const { posts } = installFetch();
1415+
const ctx = makeStubCtx();
1416+
const env = makeEnv(); // SITE_DOMAIN unset
1417+
const m = makeManager(ctx, env);
1418+
1419+
await m.startBuild(makeConfig());
1420+
expect(posts).toHaveLength(1);
1421+
expect(posts[0].body).toMatchObject({ details_url: 'https://my-app.void.app' });
1422+
});
1423+
1424+
// The status→conclusion helper (`finalizeCheckRunToStatus`) is a SEPARATE detailsUrl
1425+
// site from the create POST + the direct finalizeCheckRun terminal sites. It is reached
1426+
// by the pre-start prior-run finalizes and by the lost-CAS recorded-outcome path, so it
1427+
// must be env-aware too. Exercise it via the non-identical-head pre-start branch with a
1428+
// prior-attempt run id, and assert the PATCH links to the staging domain.
1429+
it('staging SITE_DOMAIN → finalizeCheckRunToStatus PATCH links to the staging domain', async () => {
1430+
vi.mocked(getBranchHeadComparisonStatus).mockResolvedValueOnce('behind');
1431+
dbState.checkRunIds.set('b1', String(CHECK_RUN_ID)); // prior-attempt run to finalize
1432+
const { posts, patches } = installFetch();
1433+
const ctx = makeStubCtx();
1434+
const env = makeEnv();
1435+
env.SITE_DOMAIN = 'staging.void.app';
1436+
const m = makeManager(ctx, env);
1437+
1438+
await m.startBuild(makeConfig());
1439+
1440+
// Cancelled before the create block → no POST; the prior run is finalized via
1441+
// finalizeCheckRunToStatus, whose PATCH must use the staging domain.
1442+
expect(posts).toHaveLength(0);
1443+
expect(patches).toHaveLength(1);
1444+
expect(patches[0].body).toMatchObject({
1445+
status: 'completed',
1446+
conclusion: 'cancelled',
1447+
details_url: 'https://my-app.staging.void.app',
1448+
});
1449+
});
1450+
});

0 commit comments

Comments
 (0)