titan-server / src /services /githubActions.ts
m-h2's picture
Upload 2 files
a69dd77 verified
Raw
History Blame Contribute Delete
15.4 kB
/**
* 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'),
};
}
}