@@ -806,6 +806,147 @@ export function scrubLoopbackSitePortsForFirewall(tsCloudConfig: any): any {
806806 return { ...tsCloudConfig , sites : scrubbed }
807807}
808808
809+ /**
810+ * GitHub Deployments integration for the Hetzner deploy path (best-effort).
811+ *
812+ * Records each server-* site's release as a GitHub Deployment against the repo
813+ * that produced it — derived from the site's own git worktree (`root`), so no
814+ * per-site config is needed: a site whose files come from `../adblock/dist/site`
815+ * records against `chrisbbreuer/very-good-adblock`, a stacks-served site against
816+ * `stacksjs/stacks`, etc. Deploys then show up under the repo's Deployments tab
817+ * and the Deployments API — for MANUAL local deploys too, not just CI.
818+ *
819+ * Uses the `gh` CLI (already authenticated on the deploying machine). Skipped when
820+ * running inside GitHub Actions (the workflow's own `environment:` records the
821+ * deployment natively, so doing it here would duplicate), when opted out with
822+ * `TS_CLOUD_GITHUB_DEPLOYMENTS=0`, or when `gh`/a GitHub remote is unavailable.
823+ * Every failure is logged and swallowed — recording a deployment must never fail
824+ * an otherwise-successful release.
825+ */
826+ interface GithubDeploymentRecord {
827+ repo : string
828+ id : number
829+ environment : string
830+ environmentUrl ?: string
831+ }
832+
833+ function githubDeploymentsEnabled ( ) : boolean {
834+ return process . env . GITHUB_ACTIONS !== 'true' && process . env . TS_CLOUD_GITHUB_DEPLOYMENTS !== '0'
835+ }
836+
837+ async function ghCliAvailable ( ) : Promise < boolean > {
838+ try {
839+ const { execSync } = await import ( 'node:child_process' )
840+ execSync ( 'gh --version' , { stdio : 'ignore' } )
841+ return true
842+ }
843+ catch {
844+ return false
845+ }
846+ }
847+
848+ /**
849+ * owner/repo + HEAD sha for the git worktree a site's files come from, or null
850+ * when `root` is missing or has no GitHub remote. Git resolves from any subdir of
851+ * a worktree, including a gitignored build dir like `../adblock/dist/site`.
852+ */
853+ async function resolveSiteGithubSource ( root : string ) : Promise < { repo : string , ref : string } | null > {
854+ try {
855+ const { execSync } = await import ( 'node:child_process' )
856+ const run = ( cmd : string ) => execSync ( cmd , { cwd : root , stdio : [ 'ignore' , 'pipe' , 'ignore' ] } ) . toString ( ) . trim ( )
857+ const match = run ( 'git config --get remote.origin.url' ) . match ( / g i t h u b \. c o m [: / ] ( [ ^ / ] + \/ [ ^ / ] + ?) (?: \. g i t ) ? $ / )
858+ if ( ! match )
859+ return null
860+ return { repo : match [ 1 ] , ref : run ( 'git rev-parse HEAD' ) }
861+ }
862+ catch {
863+ return null
864+ }
865+ }
866+
867+ /** POST a deployment status. Best-effort; a failure here never fails the deploy. */
868+ async function setGithubDeploymentStatus ( record : GithubDeploymentRecord , state : 'in_progress' | 'success' | 'failure' ) : Promise < void > {
869+ try {
870+ const { execSync } = await import ( 'node:child_process' )
871+ const body = JSON . stringify ( {
872+ state,
873+ environment : record . environment ,
874+ ...( record . environmentUrl ? { environment_url : record . environmentUrl } : { } ) ,
875+ description : state === 'success' ? 'Deployed' : state === 'failure' ? 'Deploy failed' : 'Deploying' ,
876+ } )
877+ execSync ( `gh api -X POST repos/${ record . repo } /deployments/${ record . id } /statuses --input -` , { input : body , stdio : [ 'pipe' , 'ignore' , 'ignore' ] } )
878+ }
879+ catch ( err ) {
880+ log . warn ( `GitHub deployment status (${ state } ) skipped for ${ record . repo } : ${ getErrorMessage ( err ) } ` )
881+ }
882+ }
883+
884+ /** POST a GitHub deployment + mark it in_progress. Best-effort → null on failure. */
885+ async function startGithubDeployment ( source : { repo : string , ref : string } , environment : string , environmentUrl ?: string ) : Promise < GithubDeploymentRecord | null > {
886+ try {
887+ const { execSync } = await import ( 'node:child_process' )
888+ const body = JSON . stringify ( {
889+ ref : source . ref ,
890+ environment,
891+ description : `buddy deploy (${ environment } )` ,
892+ auto_merge : false ,
893+ required_contexts : [ ] ,
894+ production_environment : environment === 'production' ,
895+ } )
896+ const out = execSync ( `gh api -X POST repos/${ source . repo } /deployments --input - --jq '.id'` , { input : body , stdio : [ 'pipe' , 'pipe' , 'ignore' ] } ) . toString ( ) . trim ( )
897+ const id = Number ( out )
898+ if ( ! out || ! Number . isInteger ( id ) || id <= 0 )
899+ return null
900+ const record : GithubDeploymentRecord = { repo : source . repo , id, environment, environmentUrl }
901+ await setGithubDeploymentStatus ( record , 'in_progress' )
902+ return record
903+ }
904+ catch ( err ) {
905+ log . warn ( `GitHub deployment record skipped for ${ source . repo } : ${ getErrorMessage ( err ) } ` )
906+ return null
907+ }
908+ }
909+
910+ /**
911+ * Record a GitHub Deployment for each server-* site being shipped, against the
912+ * repo its files come from (deduped by repo+ref so the apex/www pair, or several
913+ * stacks-served sites, collapse to one). Returns the records to finalize once the
914+ * release succeeds or fails. See {@link githubDeploymentsEnabled}.
915+ */
916+ async function startGithubDeployments ( args : {
917+ sites : Record < string , any >
918+ onlySite : string | undefined
919+ environment : string
920+ resolveSiteKind : ( site : any ) => 'bucket' | 'server-app' | 'server-static' | 'server-php' | 'redirect'
921+ } ) : Promise < GithubDeploymentRecord [ ] > {
922+ const { sites, onlySite, environment, resolveSiteKind } = args
923+ const records : GithubDeploymentRecord [ ] = [ ]
924+ if ( ! githubDeploymentsEnabled ( ) || ! ( await ghCliAvailable ( ) ) )
925+ return records
926+
927+ const seen = new Set < string > ( )
928+ for ( const [ siteName , site ] of Object . entries < any > ( sites ) ) {
929+ if ( ! site || ( onlySite && siteName !== onlySite ) )
930+ continue
931+ const kind = resolveSiteKind ( site )
932+ if ( kind === 'bucket' || kind === 'redirect' )
933+ continue
934+ const source = await resolveSiteGithubSource ( site . root || '.' )
935+ if ( ! source )
936+ continue
937+ const key = `${ source . repo } @${ source . ref } `
938+ if ( seen . has ( key ) )
939+ continue
940+ seen . add ( key )
941+ const record = await startGithubDeployment ( source , environment , site . domain ? `https://${ site . domain } ` : undefined )
942+ if ( record ) {
943+ records . push ( record )
944+ log . info ( `GitHub deployment ${ record . repo } #${ record . id } → ${ environment } ` )
945+ }
946+ }
947+ return records
948+ }
949+
809950async function runHetznerDeploy ( args : {
810951 tsCloudConfig : any
811952 environment : 'production' | 'staging' | 'development'
@@ -984,6 +1125,12 @@ async function runHetznerDeploy(args: {
9841125 process . env [ envKey ] = envValue
9851126 }
9861127
1128+ // Open a GitHub Deployment per shipped site (best-effort, non-fatal). Done here
1129+ // — after the static builds ran (so each site's `root` exists to derive its
1130+ // repo/ref) and before shipping — so the deployment shows as `in_progress`
1131+ // while the release is uploaded, then success/failure below.
1132+ const githubDeployments = await startGithubDeployments ( { sites, onlySite, environment, resolveSiteKind } )
1133+
9871134 log . info ( onlySite ? `Shipping site '${ onlySite } ' to the server...` : 'Shipping release to the server...' )
9881135 // For a single-site deploy, hand ts-cloud a config whose sites are narrowed to
9891136 // just that one so it ships only it (provisioning already reloaded rpx with the
@@ -1025,6 +1172,12 @@ async function runHetznerDeploy(args: {
10251172 if ( ok )
10261173 await reconcileHetznerDns ( onlySite ? { [ onlySite ] : sites [ onlySite ] } : sites , ip , log )
10271174
1175+ // Close out every GitHub Deployment we opened (before the failure branch's
1176+ // process.exit below, so a failed release is recorded as failed, not left
1177+ // dangling in_progress).
1178+ for ( const record of githubDeployments )
1179+ await setGithubDeploymentStatus ( record , ok ? 'success' : 'failure' )
1180+
10281181 console . log ( '' )
10291182 if ( ok ) {
10301183 await outro ( `Deployed to Hetzner. Your site is live at http://${ ip } :3000` , { startTime, useSeconds : true } )
0 commit comments