Files
eranos/vite.config.ts
Alex Gleason b8773c47d7 Automate App Store releases via self-hosted Mac runner
Mirror the existing Android publishing flow for iOS. The pipeline
gains two jobs: build-ipa runs on a self-hosted Mac runner and
produces a signed App Store IPA; publish-app-store runs on a shared
Linux runner and submits the prebuilt IPA to App Store Connect.

Build pipeline (.gitlab-ci.yml):
- build-ipa (Mac, stage build, parallel with build-apk): decodes the
  ASC API key, runs match (with api_key, so cert validity is verified
  against Apple before xcodebuild starts), builds web assets, syncs
  Capacitor, stamps MARKETING_VERSION. Uploads Ditto-${CI_COMMIT_TAG}
  .ipa to GitLab's Generic Packages registry.
- publish-app-store (Linux ruby:3.3, needs: [build-ipa]): gem
  install fastlane, decode the ASC API key, extract the changelog
  section into release_notes.txt, fastlane submit_release with
  IPA_PATH pointing at the inherited artifact. No Xcode, no signing,
  no keychain \u2014 pure Apple API call.
- release job now needs both build-apk and build-ipa, and links three
  assets (APK / AAB / IPA).

fastlane (ios/fastlane/Fastfile, Matchfile, Appfile, metadata/):
- Four lanes: build_ipa (CI build), submit_release (CI publish, reads
  IPA_PATH from env), release (single-step convenience for local
  dev), submit_only (debug lane to re-submit an already-uploaded
  build).
- Match config points at the private gitlab.com/soapbox-pub
  /certificates repo. App Store Connect API key is built inline in
  the Fastfile to avoid a collision with match's APP_STORE_CONNECT
  _API_KEY_PATH env var (match wants a JSON descriptor, the action
  writes a raw .p8). CI overrides CODE_SIGN_STYLE=Manual via xcargs
  so the Xcode project can stay on Automatic for local development.

Vite config (vite.config.ts):
- Renames the build-time config override env var from CONFIG_FILE to
  DITTO_CONFIG_FILE. GitLab Runner sets CONFIG_FILE to its own TOML
  config in job env, which broke vite's loader.

App-side changes:
- ios/App/App.xcodeproj/project.pbxproj: team GZLTTH5DLM stamped in;
  MARKETING_VERSION gets stamped from the tag at build time.
- public/CHANGELOG.md, package.json: v2.14.3.

Skills + AGENTS.md updated to reflect the six-job pipeline (test /
deploy unchanged, build now has two jobs, release / publish updated)
and to document Mac-runner operations, fastlane match cert rotation,
and local debugging workflows.
2026-05-11 12:59:04 -07:00

188 lines
5.6 KiB
TypeScript

import process from "node:process";
import { execSync } from "node:child_process";
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig, loadEnv, type Plugin } from "vite";
import { BuildConfigSchema } from "./src/lib/schemas";
/**
* Load and validate the build-time app configuration file.
* Returns the parsed config object, or `undefined` if the file doesn't exist.
* Set the CONFIG_FILE env var to override the default path ("./agora.json").
*/
function loadBuildConfig(): object | undefined {
const configPath = path.resolve(process.env.CONFIG_FILE ?? "./agora.json");
let raw: string;
try {
raw = fs.readFileSync(configPath, "utf-8");
} catch {
// File not found — no build-time config
return undefined;
}
const json = JSON.parse(raw);
const result = BuildConfigSchema.parse(json);
return result;
}
/**
* Copy all files from `src` into `dest`, overwriting existing files.
* Recursively handles subdirectories.
*/
function copyDirSync(src: string, dest: string): void {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Vite plugin that merges an external public directory on top of the default one.
* Set the PUBLIC_DIR env var to a directory path. Files in that directory take
* precedence over files in the built-in `public/` directory.
*
* - In build mode, files are copied into the output after the default public dir.
* - In dev mode, the external directory is served with higher priority.
*/
function mergePublicDir(externalDir: string): Plugin {
const resolved = path.resolve(externalDir);
return {
name: "agora:merge-public-dir",
configureServer(server) {
// Serve files from the external public dir before the default public dir.
server.middlewares.use((req, res, next) => {
if (!req.url) return next();
const urlPath = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
const filePath = path.join(resolved, urlPath);
try {
const stat = fs.statSync(filePath);
if (stat.isFile()) {
// Let Vite's static middleware handle it by pointing to the file.
const stream = fs.createReadStream(filePath);
stream.pipe(res);
return;
}
} catch {
// File not found in external dir — fall through to default public dir
}
next();
});
},
writeBundle(options) {
const outDir = options.dir ?? path.resolve("dist");
copyDirSync(resolved, outDir);
},
};
}
const buildConfig = loadBuildConfig();
const publicDir = process.env.PUBLIC_DIR;
const require = createRequire(import.meta.url);
const pkg = require("./package.json") as { version: string };
/** Short commit SHA — prefer CI env var, fall back to git. */
function getCommitSha(): string {
if (process.env.CI_COMMIT_SHORT_SHA) return process.env.CI_COMMIT_SHORT_SHA;
try {
return execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
/** Git tag for the current commit — prefer CI env var, fall back to git. Empty string if untagged. */
function getCommitTag(): string {
if (process.env.CI_COMMIT_TAG) return process.env.CI_COMMIT_TAG;
try {
return execSync("git describe --exact-match --tags HEAD 2>/dev/null", { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
server: {
host: "::",
port: 8080,
allowedHosts: env.ALLOWED_HOSTS === "*" ? true : undefined,
},
plugins: [
react(),
visualizer({
filename: "dist/bundle.html",
template: "treemap",
gzipSize: true,
}),
...(publicDir ? [mergePublicDir(publicDir)] : []),
],
define: {
'import.meta.env.APP_CONFIG': JSON.stringify(JSON.stringify(buildConfig ?? null)),
'import.meta.env.DITTO_CONFIG': JSON.stringify(JSON.stringify(buildConfig ?? null)),
'import.meta.env.VERSION': JSON.stringify(pkg.version),
'import.meta.env.BUILD_DATE': JSON.stringify(new Date().toISOString()),
'import.meta.env.COMMIT_SHA': JSON.stringify(getCommitSha()),
'import.meta.env.COMMIT_TAG': JSON.stringify(getCommitTag()),
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
server: {
deps: {
inline: ['@samthomson/nostr-messaging'],
},
},
onConsoleLog(log) {
return !log.includes("React Router Future Flag Warning");
},
env: {
DEBUG_PRINT_LIMIT: '0', // Suppress DOM output that exceeds AI context windows
},
},
build: {
target: 'esnext',
rollupOptions: {
output: {
manualChunks(id) {
// Consolidate lucide icons into a single chunk instead of 60+ micro-chunks.
if (id.includes('node_modules/lucide-react')) {
return 'lucide-icons';
}
},
},
},
},
optimizeDeps: {
exclude: ['@capacitor/filesystem', '@capacitor/share'],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
dedupe: ['react', 'react-dom', 'react/jsx-runtime'],
},
};
});