Skip to content

Commit cf89ff0

Browse files
committed
feat(deploy): record GitHub Deployments from the Hetzner deploy path
Each shipped server-* site now opens a GitHub Deployment against the repo its files come from (derived from the site's git worktree, deduped by repo+ref), transitioning in_progress -> success/failure. Covers manual local deploys, which CI's environment: cannot. Best-effort and non-fatal; skipped under GITHUB_ACTIONS (avoids duplicating the workflow's native record), with TS_CLOUD_GITHUB_DEPLOYMENTS=0, or when gh/a GitHub remote is unavailable.
1 parent cdea200 commit cf89ff0

1 file changed

Lines changed: 153 additions & 0 deletions

File tree

  • storage/framework/core/buddy/src/commands

storage/framework/core/buddy/src/commands/deploy.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/)
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+
809950
async 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

Comments
 (0)