/** * 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 { 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 { 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( `/repos/${owner}/${repo}/git/ref/heads/main` ); const headSha = refRes.data.object.sha; const commitRes = await this.gh.get( `/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 { 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(`/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 { const res = await withRetry( () => this.gh.post(`/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 { const res = await withRetry( () => this.gh.post(`/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 { 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 } ): Promise { 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'), }; } }