Spaces:
Running
Running
| /** | |
| * src/services/githubActions.ts | |
| * | |
| * Implements Pipelines 2 & 3: | |
| * | |
| * Pipeline 2 β POST /api/build | |
| * Pushes reviewed Dart code to GitHub via the Git Data API (blobs + trees), | |
| * then fires a workflow_dispatch event on the APK/AAB builder workflow. | |
| * Injects dynamically generated keystore credentials as secure inputs. | |
| * | |
| * Pipeline 3 β POST /api/webview | |
| * Fires a workflow_dispatch on the same repo (GITHUB_REPO_NAME / build.yml). | |
| * Supports build_type: webview | pwa_twa | twa_native | |
| * Zero LLM calls. Estimated build time: 8-15 minutes. | |
| * | |
| * Why Git Data API instead of git push? | |
| * HF Spaces containers have no git binary and no persistent SSH keys. | |
| * The REST API approach works with only a GitHub PAT token. | |
| */ | |
| import axios, { AxiosInstance, AxiosError } from 'axios'; | |
| import crypto from 'crypto'; | |
| import path from 'path'; | |
| import unzipper from 'unzipper'; | |
| import { logger } from '../utils/logger'; | |
| import { ENV } from '../config/env'; | |
| import { withRetry } from '../utils/retry'; | |
| // βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface BuildTriggerResult { | |
| trackingId: string; | |
| repo: string; | |
| branch: string; | |
| workflowFile: string; | |
| status: 'triggered'; | |
| } | |
| export interface WebViewTriggerResult { | |
| trackingId: string; | |
| status: 'triggered'; | |
| estimatedMinutes: number; | |
| } | |
| interface KeystoreCredentials { | |
| keystorePassword: string; | |
| keyAlias: string; | |
| keyPassword: string; | |
| } | |
| /** A single file entry for the GitHub Git Tree API */ | |
| interface GitTreeEntry { | |
| path: string; | |
| mode: '100644' | '100755' | '040000'; | |
| type: 'blob' | 'tree'; | |
| sha: string; | |
| } | |
| interface GitHubRef { | |
| object: { sha: string }; | |
| } | |
| interface GitHubCommit { | |
| tree: { sha: string }; | |
| } | |
| interface GitHubBlob { | |
| sha: string; | |
| } | |
| interface GitHubTree { | |
| sha: string; | |
| } | |
| interface GitHubNewCommit { | |
| sha: string; | |
| } | |
| // βββ GitHub API Client ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function createGitHubClient(): AxiosInstance { | |
| return axios.create({ | |
| baseURL: 'https://api.github.com', | |
| headers: { | |
| Authorization: `Bearer ${ENV.GITHUB_TOKEN}`, | |
| Accept: 'application/vnd.github.v3+json', | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| 'Content-Type': 'application/json', | |
| }, | |
| timeout: 45_000, | |
| }); | |
| } | |
| // βββ GitHubActionsService βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export class GitHubActionsService { | |
| private readonly gh = createGitHubClient(); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Pipeline 2: Push Dart code + trigger APK build | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * 1. Parses the ZIP buffer to extract Dart/YAML files. | |
| * 2. Pushes them to a dedicated branch via the Git Data API. | |
| * 3. Dispatches the build workflow with secure keystore credentials. | |
| * | |
| * @param dartZipBuffer - Buffer containing the reviewed Flutter project ZIP | |
| * @param trackingId - UUID that links this job to the webhook callback | |
| */ | |
| async triggerBuild(dartZipBuffer: Buffer, trackingId: string): Promise<BuildTriggerResult> { | |
| const owner = ENV.GITHUB_REPO_OWNER; | |
| const repo = ENV.GITHUB_REPO_NAME; | |
| const branch = `titan/${trackingId.slice(0, 8)}`; | |
| const workflow = 'build.yml'; | |
| logger.info(`GitHubActions: triggering build β repo=${owner}/${repo}, branch=${branch}`); | |
| // Step 1: Get current HEAD SHA of main | |
| const { headSha, baseTreeSha } = await this.getMainHead(owner, repo); | |
| // Step 2: Upload each Dart/YAML file as a GitHub blob and build a tree | |
| logger.info(`GitHubActions: uploading blobs from ZIP buffer...`); | |
| const treeEntries = await this.zipBufferToGitTree(dartZipBuffer, owner, repo); | |
| if (treeEntries.length === 0) { | |
| throw new Error('No Dart or YAML files found in the provided ZIP buffer'); | |
| } | |
| logger.info(`GitHubActions: uploading ${treeEntries.length} blobs done`); | |
| // Step 3: Create a new Git tree on top of main's tree | |
| const newTreeSha = await this.createGitTree(owner, repo, baseTreeSha, treeEntries); | |
| // Step 4: Create the commit | |
| const newCommitSha = await this.createCommit( | |
| owner, repo, | |
| `feat(titan): transpiler upload [${trackingId.slice(0, 8)}]`, | |
| newTreeSha, | |
| headSha | |
| ); | |
| // Step 5: Create (or force-update) the branch | |
| await this.upsertBranch(owner, repo, branch, newCommitSha); | |
| // Step 6: Generate keystore credentials and dispatch workflow | |
| const keystore = this.generateKeystoreCredentials(); | |
| await withRetry( | |
| () => this.dispatchWorkflow(owner, repo, workflow, { | |
| ref: branch, | |
| inputs: { | |
| tracking_id: trackingId, | |
| source_branch: branch, | |
| keystore_password: keystore.keystorePassword, | |
| key_alias: keystore.keyAlias, | |
| key_password: keystore.keyPassword, | |
| webhook_url: `${ENV.PUBLIC_URL}/api/webhook/github`, | |
| }, | |
| }), | |
| { maxRetries: 3, delayMs: 2_000 } | |
| ); | |
| logger.info(`GitHubActions: β build dispatched for tracking=${trackingId}`); | |
| return { trackingId, repo: `${owner}/${repo}`, branch, workflowFile: workflow, status: 'triggered' }; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Pipeline 3: WebView / PWA-TWA fast track | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Fires a workflow_dispatch on GITHUB_REPO_NAME / build.yml. | |
| * Supports three build modes via the build_type input: | |
| * - webview β Flutter InAppWebView (default) | |
| * - pwa_twa β Google Bubblewrap TWA via Chrome engine | |
| * - twa_nativeβ TWA with native Android fallback | |
| * | |
| * ββ Change log βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * v3: repo is now always ENV.GITHUB_REPO_NAME (same repo as build.yml). | |
| * workflow is always 'build.yml'. | |
| * Added build_type + manifest_url inputs. | |
| */ | |
| async triggerWebViewBuild(params: { | |
| url: string; | |
| appName: string; | |
| iconUrl?: string; | |
| trackingId: string; | |
| enableCamera?: boolean; | |
| enableStorage?: boolean; | |
| enableLocation?: boolean; | |
| enableNotifications?: boolean; | |
| buildType?: string; | |
| manifestUrl?: string; | |
| }): Promise<WebViewTriggerResult> { | |
| const { | |
| url, | |
| appName, | |
| iconUrl = '', | |
| trackingId, | |
| enableCamera = true, | |
| enableStorage = true, | |
| enableLocation = false, | |
| enableNotifications = false, | |
| buildType = 'webview', | |
| manifestUrl = '', | |
| } = params; | |
| // ββ Always use GITHUB_REPO_NAME β that's where build.yml lives βββββββββββ | |
| const owner = ENV.GITHUB_REPO_OWNER; | |
| const repo = ENV.GITHUB_REPO_NAME; // β ΩΨ§Ω GITHUB_WEBVIEW_REPOΨ Ψ΅ΩΨΩΩΨ | |
| const workflow = 'build.yml'; // β Ψ«Ψ§Ψ¨Ψͺ Ψ―Ψ§Ψ¦Ω Ψ§Ω | |
| logger.info( | |
| `GitHubActions: triggering WebView build β ` + | |
| `url="${url}", app="${appName}", buildType="${buildType}", tracking=${trackingId}` | |
| ); | |
| await withRetry( | |
| () => this.dispatchWorkflow(owner, repo, workflow, { | |
| ref: 'main', | |
| inputs: { | |
| app_url: url, | |
| app_name: appName, | |
| icon_url: iconUrl, | |
| tracking_id: trackingId, | |
| webhook_url: `${ENV.PUBLIC_URL}/api/webhook/github`, | |
| enable_camera: String(enableCamera), | |
| enable_storage: String(enableStorage), | |
| enable_location: String(enableLocation), | |
| enable_notifications: String(enableNotifications), | |
| build_type: buildType, // β Ψ¬Ψ―ΩΨ― | |
| manifest_url: manifestUrl, // β Ψ¬Ψ―ΩΨ― | |
| hf_token: ENV.HF_TOKEN, | |
| hf_dataset: ENV.HF_DATASET_REPO, | |
| }, | |
| }), | |
| { maxRetries: 3, delayMs: 2_000 } | |
| ); | |
| const estimated = (buildType === 'pwa_twa' || buildType === 'twa_native') ? 12 : 10; | |
| logger.info( | |
| `GitHubActions: β WebView workflow dispatched ` + | |
| `(tracking=${trackingId}, buildType=${buildType})` | |
| ); | |
| return { trackingId, status: 'triggered', estimatedMinutes: estimated }; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Private β GitHub Git Data API | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Fetches the HEAD commit SHA and its tree SHA from `main`. */ | |
| private async getMainHead( | |
| owner: string, repo: string | |
| ): Promise<{ headSha: string; baseTreeSha: string }> { | |
| const refRes = await this.gh.get<GitHubRef>( | |
| `/repos/${owner}/${repo}/git/ref/heads/main` | |
| ); | |
| const headSha = refRes.data.object.sha; | |
| const commitRes = await this.gh.get<GitHubCommit>( | |
| `/repos/${owner}/${repo}/git/commits/${headSha}` | |
| ); | |
| return { headSha, baseTreeSha: commitRes.data.tree.sha }; | |
| } | |
| /** | |
| * Reads a ZIP buffer, uploads each Dart/YAML/YML file as a GitHub blob, | |
| * and returns an array of GitTreeEntry objects ready for tree creation. | |
| * | |
| * File size guard: skips files > 5 MB (GitHub blob API limit for text). | |
| */ | |
| private async zipBufferToGitTree( | |
| buffer: Buffer, | |
| owner: string, | |
| repo: string | |
| ): Promise<GitTreeEntry[]> { | |
| const ALLOWED_EXTENSIONS = new Set(['.dart', '.yaml', '.yml']); | |
| const MAX_BLOB_SIZE = 5 * 1024 * 1024; // 5 MB | |
| const directory = await unzipper.Open.buffer(buffer); | |
| const entries: GitTreeEntry[] = []; | |
| // Upload blobs sequentially to avoid hammering the GitHub API | |
| for (const file of directory.files) { | |
| if (file.type === 'Directory') continue; | |
| const ext = path.extname(file.path).toLowerCase(); | |
| if (!ALLOWED_EXTENSIONS.has(ext)) continue; | |
| const content = await file.buffer(); | |
| if (content.length > MAX_BLOB_SIZE) { | |
| logger.warn(`GitHubActions: skipping large file "${file.path}" (${content.length} bytes)`); | |
| continue; | |
| } | |
| const blobRes = await withRetry( | |
| () => this.gh.post<GitHubBlob>(`/repos/${owner}/${repo}/git/blobs`, { | |
| content: content.toString('base64'), | |
| encoding: 'base64', | |
| }), | |
| { maxRetries: 3, delayMs: 1_000 } | |
| ); | |
| entries.push({ | |
| path: file.path, | |
| mode: '100644', | |
| type: 'blob', | |
| sha: blobRes.data.sha, | |
| }); | |
| logger.debug(`GitHubActions: blob uploaded β "${file.path}"`); | |
| } | |
| return entries; | |
| } | |
| /** Creates a new Git tree on top of `baseTreeSha`. */ | |
| private async createGitTree( | |
| owner: string, | |
| repo: string, | |
| baseTreeSha: string, | |
| entries: GitTreeEntry[] | |
| ): Promise<string> { | |
| const res = await withRetry( | |
| () => this.gh.post<GitHubTree>(`/repos/${owner}/${repo}/git/trees`, { | |
| base_tree: baseTreeSha, | |
| tree: entries, | |
| }), | |
| { maxRetries: 3, delayMs: 1_500 } | |
| ); | |
| return res.data.sha; | |
| } | |
| /** Creates a Git commit. */ | |
| private async createCommit( | |
| owner: string, | |
| repo: string, | |
| message: string, | |
| treeSha: string, | |
| parentSha: string | |
| ): Promise<string> { | |
| const res = await withRetry( | |
| () => this.gh.post<GitHubNewCommit>(`/repos/${owner}/${repo}/git/commits`, { | |
| message, | |
| tree: treeSha, | |
| parents: [parentSha], | |
| }), | |
| { maxRetries: 3, delayMs: 1_500 } | |
| ); | |
| return res.data.sha; | |
| } | |
| /** | |
| * Creates a new branch pointing to `commitSha`. | |
| * If the branch already exists, force-updates it. | |
| */ | |
| private async upsertBranch( | |
| owner: string, | |
| repo: string, | |
| branch: string, | |
| commitSha: string | |
| ): Promise<void> { | |
| try { | |
| await this.gh.post(`/repos/${owner}/${repo}/git/refs`, { | |
| ref: `refs/heads/${branch}`, | |
| sha: commitSha, | |
| }); | |
| } catch (err) { | |
| const ae = err as AxiosError; | |
| if (ae.response?.status === 422) { | |
| // Branch already exists β force push | |
| await this.gh.patch(`/repos/${owner}/${repo}/git/refs/heads/${branch}`, { | |
| sha: commitSha, | |
| force: true, | |
| }); | |
| } else { | |
| throw err; | |
| } | |
| } | |
| logger.info(`GitHubActions: branch "${branch}" β ${commitSha.slice(0, 7)}`); | |
| } | |
| /** | |
| * Fires a workflow_dispatch event. | |
| * @param params.ref - The branch or tag to run the workflow on | |
| * @param params.inputs - Workflow input key-value pairs | |
| */ | |
| private async dispatchWorkflow( | |
| owner: string, | |
| repo: string, | |
| workflowFile: string, | |
| params: { ref: string; inputs: Record<string, string> } | |
| ): Promise<void> { | |
| await this.gh.post( | |
| `/repos/${owner}/${repo}/actions/workflows/${workflowFile}/dispatches`, | |
| { ref: params.ref, inputs: params.inputs } | |
| ); | |
| logger.info(`GitHubActions: dispatched workflow "${workflowFile}" on ref "${params.ref}"`); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Security β Keystore Credentials | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Generates cryptographically random keystore credentials for APK signing. | |
| * These are injected as workflow inputs and NEVER stored persistently. | |
| */ | |
| private generateKeystoreCredentials(): KeystoreCredentials { | |
| return { | |
| keystorePassword: crypto.randomBytes(20).toString('hex'), | |
| keyAlias: `titan-${crypto.randomBytes(4).toString('hex')}`, | |
| keyPassword: crypto.randomBytes(20).toString('hex'), | |
| }; | |
| } | |
| } | |