diff --git a/README.md b/README.md index 4f56554..40ffe04 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,81 @@ -# opensource-template +# Mieweb OpenSource CI/CD Pipeline Generator -1) Replace this readme with a good one. [Working backwards](https://docs.google.com/document/d/1zxa0Rgq56xGHOgY51DbZJVUlWMRh2pbd6AupVYI9IZc) -2) use npx create to create the framework -3) Use API first thinking (no UI first) +An interactive, `npx`-executable CLI tool that generates highly customized, production-ready GitHub Actions pipelines for your projects. + +Instead of starting from a generic boilerplate template, this tool asks you about your project's framework (Node.js, Meteor, React Native, etc.), your hosting preference (Proxmox/Bare-metal, Docker), and your mobile build targets (iOS, Android, Both). It then generates the exact YAML file and deployment scripts you need. + +## šŸš€ Quick Start + +To generate a CI/CD pipeline, go to the root of your existing repository and run: + +```bash +npx @mieweb/opensource-ci-cd-template +``` + +*No installation is required. This will securely launch the interactive CLI within your terminal.* + +## šŸ› ļø How It Works + +The CLI wizard will guide you through: +1. **Framework Selection:** E.g., Meteor (with Cordova), standard Node.js applications, Next.js, or React Native. +2. **Deployment Target:** + * **Proxmox / Bare-Metal:** Generates advanced deployment scripts (using `systemd`) ensuring zero-downtime symlink-based deployments over SSH. + * **Docker:** Scaffold standard Dockerfile/Docker Compose GitHub Actions flows. + * **None (Mobile-only):** If you just want to build and deploy to the App Store or Google Play. +3. **Mobile Builds:** Do you need iOS (App Store/TestFlight), Android (Play Store), both, or neither? +4. **Triggers:** Configure whether the pipeline runs on Git pushes to specific branches, release tags, or manual workflow dispatches. + +## šŸ“ What Does It Generate? + +Depending on your choices, the CLI creates the following structure right inside your repository: + +```text +.github/ + workflows/ + ci-cd.yml # The customized GitHub Actions pipeline +scripts/ # (If Proxmox/Bare-metal selected) + setup-systemd.sh # One-time server bootstrapping script + start-app.sh # Zero-downtime restart handler + app.service # systemd service template +PIPELINE_SETUP.md # ⭐ Your personalized guide! +``` + +### ⭐ The `PIPELINE_SETUP.md` Guide +We don't just generate YAML files and leave you to figure it out. The generator writes a custom **Markdown Guide** tailored entirely to your specific answers. + +It contains **exact, step-by-step terminal commands** instructing you how to: +- Generate a new remote SSH keypair for bare-metal deployments without a passphrase. +- Create an Android release Keystore (`.keystore`) and encode it to Base64. +- Generate an Apple Distribution Certificate (`.p12`) and Provisioning Profile for iOS builds. +- Create a Google Play API Service Account JSON for automatic release track publishing. + +## šŸ’» Development & Testing Locally + +Want to contribute to the CLI or test changes? + +```bash +# Clone the repository +git clone https://github.com/mieweb/template-mieweb-opensource.git +cd template-mieweb-opensource + +# Install dependencies +npm install + +# Link the CLI globally so you can run it anywhere on your machine locally +npm link + +# In a dummy project directory somewhere else on your machine: +opensource-ci-cd-template +``` + +## šŸ“¦ Publishing + +To publish any updates to the npm registry: + +```bash +npm login +npm publish --access public +``` + +--- +*Created and maintained by the Mieweb Team.* diff --git a/bin/index.js b/bin/index.js new file mode 100755 index 0000000..dda978f --- /dev/null +++ b/bin/index.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import ejs from 'ejs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function run() { + console.log(chalk.blue.bold('\nšŸš€ Welcome to the Mieweb Advanced CI/CD Pipeline Generator!\n')); + console.log(chalk.gray('This tool will generate production-ready, cache-optimized GitHub Actions workflows.')); + console.log(chalk.gray('It includes actual build scripts, deployment configurations, and mobile publishing steps.\n')); + + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'framework', + message: 'What framework is your project using?', + choices: [ + { name: 'Meteor.js (Full-stack + Cordova Mobile)', value: 'meteor' }, + { name: 'Node.js / Next.js (Server/Web only)', value: 'node' }, + { name: 'React Native / Expo (Mobile only)', value: 'react-native' } + ] + }, + { + type: 'list', + name: 'trigger', + message: 'When should the production deployment run?', + choices: [ + { name: 'On release creation (e.g., v1.0.0)', value: 'release' }, + { name: 'On push to main/master branch', value: 'push' } + ] + }, + { + type: 'confirm', + name: 'deployServer', + message: 'Do you need to deploy a server/backend?', + default: true, + when: (answers) => answers.framework !== 'react-native' + }, + { + type: 'list', + name: 'serverMethod', + message: 'How do you want to deploy the server?', + choices: [ + { name: 'Proxmox / Bare-metal VM (SSH + systemd + Zero-downtime symlinks)', value: 'proxmox' }, + { name: 'Docker (Build image, push to GHCR, deploy via docker-compose)', value: 'docker' } + ], + when: (answers) => answers.deployServer + }, + { + type: 'list', + name: 'mobileBuilds', + message: 'Do you need to build and publish mobile apps?', + choices: [ + { name: 'Both Android (Play Store) and iOS (TestFlight)', value: 'both' }, + { name: 'Android only (Play Store)', value: 'android' }, + { name: 'iOS only (TestFlight)', value: 'ios' }, + { name: 'None', value: 'none' } + ], + when: (answers) => answers.framework === 'meteor' || answers.framework === 'react-native' + }, + { + type: 'confirm', + name: 'useFastlane', + message: 'Use Fastlane for mobile build & signing? (Recommended — handles code signing, match, and publishing)', + default: true, + when: (answers) => answers.mobileBuilds && answers.mobileBuilds !== 'none' + } + ]); + + // Normalize answers for templates + if (answers.framework === 'node') answers.mobileBuilds = 'none'; + if (answers.framework === 'react-native') answers.deployServer = false; + if (!answers.useFastlane) answers.useFastlane = false; + + console.log(chalk.yellow('\nGenerating your advanced CI/CD pipeline...\n')); + + const templateDir = path.join(__dirname, '../templates'); + const targetDir = process.cwd(); + const githubWorkflowsDir = path.join(targetDir, '.github/workflows'); + const scriptsDir = path.join(targetDir, 'scripts'); + + await fs.ensureDir(githubWorkflowsDir); + + if (answers.serverMethod === 'proxmox') { + await fs.ensureDir(scriptsDir); + } + + const renderTemplate = async (templatePath, targetPath) => { + const fullTemplatePath = path.join(templateDir, templatePath); + if (await fs.pathExists(fullTemplatePath)) { + const templateContent = await fs.readFile(fullTemplatePath, 'utf-8'); + const rendered = ejs.render(templateContent, answers); + await fs.writeFile(targetPath, rendered); + console.log(chalk.green(`āœ… Created ${path.relative(targetDir, targetPath)}`)); + } else { + console.log(chalk.red(`āŒ Template not found: ${templatePath}`)); + } + }; + + // 1. Generate Main Workflow based on Framework + await renderTemplate(`frameworks/${answers.framework}/ci-cd.yml.ejs`, path.join(githubWorkflowsDir, 'deploy-production.yml')); + + // 2. Generate Deployment Scripts (if Proxmox) + if (answers.serverMethod === 'proxmox') { + await renderTemplate('deployment/proxmox/setup-systemd.sh.ejs', path.join(scriptsDir, 'setup-systemd.sh')); + await renderTemplate('deployment/proxmox/app.service.ejs', path.join(scriptsDir, 'app.service')); + await renderTemplate('deployment/proxmox/start-app.sh.ejs', path.join(scriptsDir, 'start-app.sh')); + + // Make scripts executable + await fs.chmod(path.join(scriptsDir, 'setup-systemd.sh'), 0o755); + await fs.chmod(path.join(scriptsDir, 'start-app.sh'), 0o755); + } + + // 3. Generate Dockerfile (if Docker) + if (answers.serverMethod === 'docker') { + await renderTemplate(`deployment/docker/Dockerfile.${answers.framework}.ejs`, path.join(targetDir, 'Dockerfile')); + await renderTemplate('deployment/docker/docker-compose.yml.ejs', path.join(targetDir, 'docker-compose.yml')); + } + + // 4. Generate Fastlane files (if Fastlane) + if (answers.useFastlane) { + const fastlaneDir = path.join(targetDir, 'fastlane'); + await fs.ensureDir(fastlaneDir); + + await renderTemplate('fastlane/Gemfile.ejs', path.join(targetDir, 'Gemfile')); + await renderTemplate('fastlane/Appfile.ejs', path.join(fastlaneDir, 'Appfile')); + await renderTemplate('fastlane/Matchfile.ejs', path.join(fastlaneDir, 'Matchfile')); + await renderTemplate('fastlane/Fastfile.ejs', path.join(fastlaneDir, 'Fastfile')); + + // Append .gitignore additions + const gitignorePath = path.join(targetDir, '.gitignore'); + const additionsPath = path.join(templateDir, 'fastlane/gitignore-additions.txt'); + if (await fs.pathExists(additionsPath)) { + const additions = await fs.readFile(additionsPath, 'utf-8'); + const existing = (await fs.pathExists(gitignorePath)) ? await fs.readFile(gitignorePath, 'utf-8') : ''; + if (!existing.includes('# Fastlane')) { + await fs.appendFile(gitignorePath, '\n' + additions); + console.log(chalk.green('āœ… Appended Fastlane entries to .gitignore')); + } + } + } + + // 5. Generate Setup Guide + await renderTemplate('PIPELINE_SETUP.md.ejs', path.join(targetDir, 'PIPELINE_SETUP.md')); + + console.log(chalk.blue.bold('\nšŸŽ‰ Advanced Pipeline generated successfully!')); + console.log(chalk.white(`Please read ${chalk.bold('PIPELINE_SETUP.md')} for the next steps.\n`)); +} + +run().catch(err => { + console.error(chalk.red('Error generating pipeline:'), err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6d33fbd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,738 @@ +{ + "name": "template-mieweb-opensource", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "template-mieweb-opensource", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "chalk": "^4.1.2", + "ejs": "^4.0.1", + "fs-extra": "^11.3.3", + "inquirer": "^8.2.7" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ejs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-4.0.1.tgz", + "integrity": "sha512-krvQtxc0btwSm/nvnt1UpnaFDFVJpJ0fdckmALpCgShsr/iGYHTnJiUliZTgmzq/UxTX33TtOQVKaNigMQp/6Q==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.9.1" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..70c6f9d --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "@mieweb/opensource-ci-cd-template", + "version": "1.0.0", + "description": "1) Replace this readme with a good one. [Working backwards](https://docs.google.com/document/d/1zxa0Rgq56xGHOgY51DbZJVUlWMRh2pbd6AupVYI9IZc) 2) use npx create to create the framework 3) Use API first thinking (no UI first)", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/mieweb/template-mieweb-opensource.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/mieweb/template-mieweb-opensource/issues" + }, + "homepage": "https://github.com/mieweb/template-mieweb-opensource#readme", + "dependencies": { + "chalk": "^4.1.2", + "ejs": "^4.0.1", + "fs-extra": "^11.3.3", + "inquirer": "^8.2.7" + }, + "type": "module", + "bin": { + "opensource-ci-cd-template": "./bin/index.js" + } +} diff --git a/templates/PIPELINE_SETUP.md.ejs b/templates/PIPELINE_SETUP.md.ejs new file mode 100644 index 0000000..14ca40f --- /dev/null +++ b/templates/PIPELINE_SETUP.md.ejs @@ -0,0 +1,193 @@ +# CI/CD Pipeline Setup Guide + +Congratulations! Your advanced CI/CD pipeline has been generated. To make it work, you need to configure a few things in your GitHub repository. + +## 1. GitHub Secrets & Variables + +Go to your repository on GitHub, navigate to **Settings > Secrets and variables > Actions**. + +### Variables (Non-sensitive) +<% if (deployServer) { %> +* `DEPLOY_HOST`: The IP address or hostname of your server. +* `DEPLOY_PORT`: The SSH port (default is 22). +<% } %> +<% if (framework === 'meteor') { %> +* `SERVER_URL`: The URL of your production server (e.g., `https://api.myapp.com`). +<% } %> +<% if (mobileBuilds === 'android' || mobileBuilds === 'both') { %> +* `ANDROID_PACKAGE_NAME`: Your Android bundle ID (e.g., `com.company.app`). +<% } %> +<% if (mobileBuilds === 'ios' || mobileBuilds === 'both') { %> +* `IOS_BUNDLE_ID`: Your iOS bundle ID (e.g., `com.company.app`). +<% if (!useFastlane) { %> +* `IOS_SCHEME_NAME`: Your Xcode scheme name (usually your app name). +<% } %> +<% if (useFastlane) { %> +* `MATCH_GIT_URL`: The HTTPS URL of your private signing repo (e.g., `https://github.com/my-org/mobile-signing.git`). +* `MATCH_GIT_BRANCH`: The branch in your signing repo for this app's profiles (e.g., `main`). +<% } %> +<% } %> + +### Secrets (Sensitive) +<% if (deployServer) { %> +### Server Deployment Secrets + +To allow GitHub Actions to connect to your server securely, you need to generate an SSH key pair. + +**1. Create the SSH Key Pair (`DEPLOY_SSH_KEY`):** +* Open your terminal and generate a new SSH key specifically for GitHub Actions (do not use a passphrase): + ```bash + ssh-keygen -t ed25519 -C "github-actions@deploy" -f ~/.ssh/github_actions_deploy -N "" + ``` +* This creates two files: `github_actions_deploy` (private key) and `github_actions_deploy.pub` (public key). +* **Copy the Private Key:** + * **Mac:** `pbcopy < ~/.ssh/github_actions_deploy` + * **Linux:** `cat ~/.ssh/github_actions_deploy | xclip -selection clipboard` +* Go to your GitHub repository > **Settings > Secrets and variables > Actions**, and paste the private key into a new secret named `DEPLOY_SSH_KEY`. + +**2. Authorize the Key on Your Server:** +* You must place the *public* key on your target server so it accepts the connection. +* **Copy the Public Key:** + * **Mac:** `pbcopy < ~/.ssh/github_actions_deploy.pub` + * **Linux:** `cat ~/.ssh/github_actions_deploy.pub | xclip -selection clipboard` +* SSH into your `DEPLOY_HOST` server using your normal credentials. +* Open the authorized keys file: + ```bash + nano ~/.ssh/authorized_keys + ``` +* Paste the public key on a new line at the end of the file, save, and exit (`Ctrl+O`, `Enter`, `Ctrl+X`). +* Ensure the permissions are correct on the server: + ```bash + chmod 600 ~/.ssh/authorized_keys + ``` + +**3. Set the Deploy User (`DEPLOY_USER`):** +* `DEPLOY_USER`: The SSH username you use on the server (e.g., `ubuntu`, `root`). Add this as a secret in GitHub. +<% } %> + +<% if (mobileBuilds === 'android' || mobileBuilds === 'both') { %> +### Android Setup & Secrets + +To publish to the Google Play Store, you need to generate a release Keystore and a Service Account JSON. + +**1. Create the Release Keystore (`ANDROID_KEYSTORE_BASE64`):** +* Open your terminal and run: + ```bash + keytool -genkey -v -keystore release.keystore -alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000 + ``` +* Save the keystore password as `ANDROID_KEYSTORE_PASSWORD`. +* Save the key password as `ANDROID_KEY_PASSWORD`. +* Save the alias name as `ANDROID_KEYSTORE_ALIAS`. +* Base64 encode the file so GitHub Actions can read it: + * **Mac:** `base64 -i release.keystore | pbcopy` + * **Linux:** `base64 -w 0 release.keystore > keystore.b64` +* Paste the copied base64 string into the `ANDROID_KEYSTORE_BASE64` secret. + +**2. Create the Google Play Service Account (`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`):** +* Go to the [Google Cloud Console](https://console.cloud.google.com/). +* Select your project (or create one linked to your Google Play Console). +* Go to **IAM & Admin > Service Accounts** and click **Create Service Account**. +* Name it (e.g., `play-store-deploy`) and grant it the **Service Account User** role. +* Click on the new service account, go to the **Keys** tab, click **Add Key > Create new key**, and select **JSON**. +* **Important:** Open the [Google Play Console](https://play.google.com/console), go to **API access**, and grant this new service account "Admin" (or "Release manager") permissions. +* Copy the entire contents of the downloaded JSON file and paste it into the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` secret. +<% } %> + +<% if (mobileBuilds === 'ios' || mobileBuilds === 'both') { %> +### iOS Setup & Secrets + +To publish to TestFlight or the App Store, you need an Apple Developer account ($99/yr) and several certificates. + +**1. Create a Distribution Certificate (`IOS_DIST_CERT_P12_BASE64`):** +* Log in to the [Apple Developer Portal](https://developer.apple.com/account). +* Go to **Certificates, Identifiers & Profiles > Certificates** and click the `+` button. +* Select **Apple Distribution** and upload a Certificate Signing Request (CSR) generated from your Mac's Keychain Access app. +* Download the `.cer` file and double-click it to install it into your Mac's Keychain. +* Open **Keychain Access**, find the certificate (it will say "Apple Distribution: [Your Name/Company]"), right-click it, and select **Export**. +* Save it as a `.p12` file and specify a strong password. Add this password as the `IOS_DIST_CERT_PASSWORD` secret. +* Base64 encode the `.p12` file (Mac: `base64 -i file.p12 | pbcopy`) and paste it into `IOS_DIST_CERT_P12_BASE64`. + +<% if (!useFastlane) { %> +**2. Create an App ID and Provisioning Profile (`IOS_PROVISIONING_PROFILE_BASE64`):** +* In the Developer Portal, go to **Identifiers** and create an App ID matching your `IOS_BUNDLE_ID` (e.g., `com.company.app`). +* Go to **Profiles**, click `+`, select **App Store** (under Distribution), select your App ID, and select the Distribution Certificate you just created. +* Download the `.mobileprovision` file. +* Base64 encode it (Mac: `base64 -i file.mobileprovision | pbcopy`) and paste it into `IOS_PROVISIONING_PROFILE_BASE64`. +<% } %> + +<% if (useFastlane) { %> +**2. Set Up Fastlane Match (Provisioning Profiles):** + +Match stores your provisioning profiles in a private Git repository and manages them automatically. This eliminates the need for manually exporting and base64-encoding provisioning profiles. + +* Create a **private GitHub repo** for signing (e.g., `my-org/mobile-signing`). This is where match will store encrypted certificates and profiles. +* Run `fastlane match init` locally and choose "git" storage. It will create a `Matchfile` pointing to this repo. +* Run `fastlane match appstore` to generate (or import) an App Store provisioning profile. Match will ask for a passphrase the first time — save this as `MATCH_PASSWORD`. +* For CI access to the private signing repo, create a **Personal Access Token (PAT)** with `repo` scope, base64-encode it as `username:token`, and save as `MATCH_GIT_BASIC_AUTHORIZATION`: + ```bash + echo -n "your-github-username:ghp_yourtoken" | base64 + ``` + +> **Why do we still need `IOS_DIST_CERT_P12_BASE64`?** +> macOS 15+ has a known bug where the system `security import` command rejects `.p12` files that were re-encrypted by match with an empty password. The Fastfile includes a workaround that imports your original password-protected `.p12` directly, bypassing this issue. Match still manages your provisioning profiles without any problems. +<% } %> + +**3. Generate an App Store Connect API Key (`APPLE_API_KEY_P8_BASE64`):** +* Log in to [App Store Connect](https://appstoreconnect.apple.com/). +* Go to **Users and Access > Integrations > App Store Connect API**. +* Click the `+` button to generate a new key. Give it an "App Manager" role. +* Note the **Issuer ID** (at the top of the page) and save it as `APPLE_API_ISSUER_ID`. +* Note the **Key ID** (in the table) and save it as `APPLE_API_KEY_ID`. +* Download the `.p8` file. You can only download this once! +* Base64 encode the `.p8` file (Mac: `base64 -i AuthKey_XXX.p8 | pbcopy`) and paste it into `APPLE_API_KEY_P8_BASE64`. +* Find your Team ID in the Developer Portal (usually a 10-character string like `ABC123DEFG`) and save it as `APPLE_TEAM_ID`. + +<% if (useFastlane) { %> +**Summary of iOS Secrets (Fastlane + Match):** + +| Secret | Description | +|---|---| +| `APPLE_TEAM_ID` | Your 10-character Apple Developer Team ID | +| `APPLE_API_KEY_ID` | Key ID from App Store Connect API | +| `APPLE_API_ISSUER_ID` | Issuer ID from App Store Connect API | +| `APPLE_API_KEY_P8_BASE64` | Base64-encoded `.p8` API key file | +| `IOS_DIST_CERT_P12_BASE64` | Base64-encoded distribution certificate `.p12` | +| `IOS_DIST_CERT_PASSWORD` | Password for the `.p12` certificate | +| `MATCH_PASSWORD` | Passphrase used to encrypt the match signing repo | +| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64-encoded `username:PAT` for private signing repo access | +<% } %> +<% } %> + +<% if (framework === 'meteor' && mobileBuilds !== 'none') { %> +### Firebase Setup (Meteor Cordova) + +If you are using Firebase with Meteor Cordova, you need to provide the configuration files. + +**1. Android (`FIREBASE_SERVICES_JSON_BASE64`):** +* Go to your Firebase Console > Project Settings. +* Download the `google-services.json` file for your Android app. +* Base64 encode it: `base64 -i google-services.json | pbcopy` +* Paste it into the `FIREBASE_SERVICES_JSON_BASE64` secret. + +**2. iOS (`FIREBASE_IOS_PLIST_BASE64`):** +* Download the `GoogleService-Info.plist` file for your iOS app. +* Base64 encode it: `base64 -i GoogleService-Info.plist | pbcopy` +* Paste it into the `FIREBASE_IOS_PLIST_BASE64` secret. +<% } %> + +<% if (serverMethod === 'proxmox') { %> +## 2. Server Setup (Proxmox / Bare-metal) + +We have generated setup scripts in the `scripts/` directory. You need to run these on your server **once** before your first deployment. + +1. SSH into your server. +2. Copy the `scripts/` folder to your server. +3. Run the setup script: + ```bash + ./scripts/setup-systemd.sh + ``` +4. Create a `~/scripts/set-env.sh` file on your server to hold your production environment variables (e.g., `MONGO_URL`, `ROOT_URL`). +<% } %> + +## 3. Next Steps +Commit and push the generated files to your repository to trigger the pipeline! diff --git a/templates/deployment/docker/Dockerfile.meteor.ejs b/templates/deployment/docker/Dockerfile.meteor.ejs new file mode 100644 index 0000000..3f31e45 --- /dev/null +++ b/templates/deployment/docker/Dockerfile.meteor.ejs @@ -0,0 +1,19 @@ +FROM geoffreybooth/meteor-base:2.14 AS builder + +COPY package*.json $APP_SOURCE_FOLDER/ +RUN bash $SCRIPTS_FOLDER/build-app-npm-dependencies.sh + +COPY . $APP_SOURCE_FOLDER/ +RUN bash $SCRIPTS_FOLDER/build-meteor-bundle.sh + +FROM node:14-alpine +ENV APP_BUNDLE_FOLDER /opt/bundle +ENV SCRIPTS_FOLDER /docker + +COPY --from=builder $SCRIPTS_FOLDER $SCRIPTS_FOLDER/ +COPY --from=builder $APP_BUNDLE_FOLDER/bundle $APP_BUNDLE_FOLDER/ + +RUN bash $SCRIPTS_FOLDER/build-meteor-npm-dependencies.sh + +EXPOSE 3000 +CMD ["node", "main.js"] diff --git a/templates/deployment/docker/Dockerfile.node.ejs b/templates/deployment/docker/Dockerfile.node.ejs new file mode 100644 index 0000000..f1b9383 --- /dev/null +++ b/templates/deployment/docker/Dockerfile.node.ejs @@ -0,0 +1,23 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV production + +COPY --from=builder /app/next.config.js ./ +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 +ENV PORT 3000 + +CMD ["node", "server.js"] diff --git a/templates/deployment/docker/docker-compose.yml.ejs b/templates/deployment/docker/docker-compose.yml.ejs new file mode 100644 index 0000000..26ad072 --- /dev/null +++ b/templates/deployment/docker/docker-compose.yml.ejs @@ -0,0 +1,11 @@ +version: '3.8' + +services: + app: + image: ghcr.io/${{ github.repository }}:latest + restart: always + ports: + - "3000:3000" + environment: + - NODE_ENV=production + # Add other environment variables here diff --git a/templates/deployment/proxmox/app.service.ejs b/templates/deployment/proxmox/app.service.ejs new file mode 100644 index 0000000..b4e88b5 --- /dev/null +++ b/templates/deployment/proxmox/app.service.ejs @@ -0,0 +1,27 @@ +[Unit] +Description=Node.js Application Server +After=network.target +StartLimitIntervalSec=60 +StartLimitBurst=3 + +[Service] +Type=simple +User=<%= process.env.USER || 'ubuntu' %> +WorkingDirectory=/home/<%= process.env.USER || 'ubuntu' %>/Builds/current +ExecStart=/home/<%= process.env.USER || 'ubuntu' %>/scripts/start-app.sh +Restart=on-failure +RestartSec=10 + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=app + +NoNewPrivileges=yes +PrivateTmp=yes + +KillMode=mixed +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/templates/deployment/proxmox/setup-systemd.sh.ejs b/templates/deployment/proxmox/setup-systemd.sh.ejs new file mode 100644 index 0000000..4df16d0 --- /dev/null +++ b/templates/deployment/proxmox/setup-systemd.sh.ejs @@ -0,0 +1,37 @@ +#!/bin/bash +# ────────────────────────────────────────────────────────── +# setup-systemd.sh +# One-time setup script to install the systemd service +# ────────────────────────────────────────────────────────── +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICE_FILE="${SCRIPT_DIR}/app.service" +START_SCRIPT="${SCRIPT_DIR}/start-app.sh" + +echo "=== Systemd Setup ===" + +# Step 1: Copy start wrapper script to ~/scripts +mkdir -p "$HOME/scripts" +cp "$START_SCRIPT" "$HOME/scripts/start-app.sh" +chmod +x "$HOME/scripts/start-app.sh" +echo "āœ… Start script installed: ~/scripts/start-app.sh" + +# Step 2: Install systemd unit file +echo "Installing systemd service..." +sudo cp "$SERVICE_FILE" /etc/systemd/system/app.service +sudo systemctl daemon-reload +sudo systemctl enable app +echo "āœ… systemd service installed and enabled" + +# Step 3: Configure sudoers for CI/CD (passwordless restart) +SUDOERS_LINE="${USER} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart app, /usr/bin/systemctl stop app, /usr/bin/systemctl start app, /usr/bin/systemctl status app, /usr/bin/systemctl is-active app, /usr/bin/journalctl -u app *" +SUDOERS_FILE="/etc/sudoers.d/app" + +if [ ! -f "$SUDOERS_FILE" ]; then + echo "$SUDOERS_LINE" | sudo tee "$SUDOERS_FILE" > /dev/null + sudo chmod 440 "$SUDOERS_FILE" + echo "āœ… sudoers configured for passwordless systemctl" +fi + +echo "āœ… Setup complete. Run 'sudo systemctl start app' to begin." diff --git a/templates/deployment/proxmox/start-app.sh.ejs b/templates/deployment/proxmox/start-app.sh.ejs new file mode 100644 index 0000000..5a8a8b4 --- /dev/null +++ b/templates/deployment/proxmox/start-app.sh.ejs @@ -0,0 +1,15 @@ +#!/bin/bash +# Wrapper script to start the application + +# Load NVM if it exists +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Load environment variables if they exist +if [ -f "$HOME/scripts/set-env.sh" ]; then + source "$HOME/scripts/set-env.sh" +fi + +# Start the Node.js application +cd "$HOME/Builds/current" +exec node main.js diff --git a/templates/fastlane/Appfile.ejs b/templates/fastlane/Appfile.ejs new file mode 100644 index 0000000..f606d32 --- /dev/null +++ b/templates/fastlane/Appfile.ejs @@ -0,0 +1,2 @@ +app_identifier(ENV.fetch("IOS_APP_IDENTIFIER", "<%= iosBundleId || 'com.company.app' %>")) +team_id(ENV["APPLE_TEAM_ID"]) diff --git a/templates/fastlane/Fastfile.ejs b/templates/fastlane/Fastfile.ejs new file mode 100644 index 0000000..0bfc388 --- /dev/null +++ b/templates/fastlane/Fastfile.ejs @@ -0,0 +1,237 @@ +require "fileutils" +require "open3" +require "shellwords" +require "tmpdir" + +opt_out_usage + +default_platform(:ios) + +PROJECT_ROOT = File.expand_path("..", __dir__) +FASTLANE_LOGS_DIR = File.join(PROJECT_ROOT, "fastlane", "logs") + +def required_option!(options, key) + value = options[key] + UI.user_error!("Missing option: #{key}") if value.nil? || value.to_s.strip.empty? + value.to_s +end + +def project_path(path) + return path if path.nil? || path.empty? + File.expand_path(path, PROJECT_ROOT) +end + +def release_notes_for(locale, file_path) + resolved_path = project_path(file_path) + return nil if resolved_path.nil? || resolved_path.empty? || !File.exist?(resolved_path) + text = File.read(resolved_path).strip + return nil if text.empty? + [{ language: locale, text: text }] +end + +<% if (mobileBuilds === 'android' || mobileBuilds === 'both') { %> +def android_metadata_path(locale, file_path) + notes = release_notes_for(locale, file_path) + return nil if notes.nil? || notes.empty? + + Dir.mktmpdir("fastlane-android-metadata") do |tmp_dir| + locale_dir = File.join(tmp_dir, locale, "changelogs") + FileUtils.mkdir_p(locale_dir) + File.write(File.join(locale_dir, "default.txt"), notes.first[:text]) + yield tmp_dir + end +end + +def resolve_android_aab(search_root) + resolved_root = project_path(search_root) + matches = Dir.glob(File.join(resolved_root, "**", "*.aab")) + UI.user_error!("No AAB file found under #{search_root}") if matches.empty? + matches.first +end + +def publish_android(options) + search_root = required_option!(options, :aab_search_root) + output_aab_path = project_path(required_option!(options, :output_aab_path)) + keystore_path = project_path(required_option!(options, :keystore_path)) + keystore_password = required_option!(options, :keystore_password) + key_password = required_option!(options, :key_password) + keystore_alias = required_option!(options, :keystore_alias) + package_name = required_option!(options, :package_name) + version_name = options[:version_name].to_s.strip + version_name = required_option!(options, :release_name) if version_name.empty? + track = options[:track].to_s.empty? ? "internal" : options[:track].to_s + release_notes_path = options[:release_notes_path].to_s + release_notes_locale = options[:release_notes_locale].to_s.empty? ? "en-US" : options[:release_notes_locale].to_s + + unsigned_aab = resolve_android_aab(search_root) + + sh( + "jarsigner " \ + "-sigalg SHA256withRSA " \ + "-digestalg SHA-256 " \ + "-keystore #{Shellwords.escape(keystore_path)} " \ + "-storepass #{Shellwords.escape(keystore_password)} " \ + "-keypass #{Shellwords.escape(key_password)} " \ + "#{Shellwords.escape(unsigned_aab)} " \ + "#{Shellwords.escape(keystore_alias)}", + log: false + ) + UI.success("AAB signed successfully") + + sh("jarsigner -verify #{Shellwords.escape(unsigned_aab)}", log: false) + UI.success("AAB signature verified") + + FileUtils.cp(unsigned_aab, output_aab_path) + + upload_options = { + json_key_data: ENV.fetch("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"), + package_name: package_name, + aab: output_aab_path, + track: track, + release_status: "completed", + version_name: version_name, + skip_upload_images: true, + skip_upload_screenshots: true + } + + android_metadata_path(release_notes_locale, release_notes_path) do |metadata_path| + upload_to_play_store(upload_options.merge(metadata_path: metadata_path)) + end + + upload_to_play_store(upload_options) unless release_notes_for(release_notes_locale, release_notes_path) +end +<% } %> + +<% if (mobileBuilds === 'ios' || mobileBuilds === 'both') { %> +def resolve_workspace(workspace_root) + resolved_root = project_path(workspace_root) + preferred = Dir.glob(File.join(resolved_root, "**", "*.xcworkspace")).reject do |path| + path.include?(".xcodeproj/") + end + fallback = Dir.glob(File.join(resolved_root, "**", "*.xcworkspace")) + workspace_path = preferred.first || fallback.first + UI.user_error!("No Xcode workspace found under #{workspace_root}") if workspace_path.nil? + workspace_path +end + +def publish_ios(options) + workspace_root = required_option!(options, :workspace_root) + team_id = required_option!(options, :team_id) + bundle_id = required_option!(options, :bundle_id) + api_key_id = required_option!(options, :api_key_id) + api_issuer_id = required_option!(options, :api_issuer_id) + api_key_path = project_path(required_option!(options, :api_key_path)) + + workspace_path = resolve_workspace(workspace_root) + scheme = File.basename(workspace_path, ".xcworkspace") + podfile_path = File.join(File.dirname(workspace_path), "Podfile") + + api_key = app_store_connect_api_key( + key_id: api_key_id, + issuer_id: api_issuer_id, + key_filepath: api_key_path, + in_house: false + ) + + setup_ci if ENV["CI"] + + match( + type: "appstore", + app_identifier: bundle_id, + api_key: api_key, + readonly: ENV["CI"].to_s == "true" + ) + + # Import signing certificate with private key directly. + # Match handles provisioning profiles fine, but its PKCS12 import + # fails on macOS 15 due to format incompatibility. Use raw security + # import with exec-style args to avoid shell escaping issues. + cert_p12_path = ENV["IOS_DIST_CERT_P12_PATH"] + cert_password = ENV["IOS_DIST_CERT_PASSWORD"] || "" + if cert_p12_path && !cert_p12_path.empty? && File.exist?(cert_p12_path) + keychain_path = File.expand_path("~/Library/Keychains/fastlane_tmp_keychain-db") + Open3.popen3( + "security", "import", cert_p12_path, + "-P", cert_password, + "-A", "-t", "cert", "-f", "pkcs12", + "-k", keychain_path + ) do |_stdin, _stdout, stderr, wait_thr| + err = stderr.read + unless wait_thr.value.success? + UI.user_error!("Certificate import failed: #{err}") + end + end + sh("security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k '' #{Shellwords.escape(keychain_path)}", log: false) + UI.success("Signing certificate imported successfully") + end + + provisioning_profiles = Actions.lane_context[Fastlane::Actions::SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] || {} + provisioning_profile = provisioning_profiles[bundle_id] + UI.user_error!("No match provisioning profile found for #{bundle_id}") if provisioning_profile.to_s.empty? + + FileUtils.mkdir_p(FASTLANE_LOGS_DIR) + + if File.exist?(podfile_path) + podfile_dir = File.dirname(podfile_path) + sh( + "cd #{Shellwords.escape(podfile_dir)} && RUBYOPT=-rlogger bundle exec pod install --silent", + log: false + ) + end + + xcodeproj_path = workspace_path.sub('.xcworkspace', '.xcodeproj') + if File.exist?(xcodeproj_path) + profile_name = ENV["sigh_#{bundle_id}_appstore_profile-name"] || provisioning_profile + + update_code_signing_settings( + path: xcodeproj_path, + use_automatic_signing: false, + team_id: team_id, + code_sign_identity: "Apple Distribution", + profile_name: profile_name, + bundle_identifier: bundle_id + ) + end + + build_app( + workspace: workspace_path, + scheme: scheme, + configuration: "Release", + destination: "generic/platform=iOS", + buildlog_path: FASTLANE_LOGS_DIR, + codesigning_identity: "Apple Distribution", + export_method: "app-store", + export_team_id: team_id, + xcodebuild_formatter: "", + export_options: { + provisioningProfiles: { + bundle_id => provisioning_profile + } + }, + xcargs: "DEVELOPMENT_TEAM=#{team_id}" + ) + + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true + ) +end +<% } %> + +<% if (mobileBuilds === 'android' || mobileBuilds === 'both') { %> +platform :android do + desc "Sign and publish Android AAB to Google Play" + lane :publish do |options| + publish_android(options) + end +end +<% } %> + +<% if (mobileBuilds === 'ios' || mobileBuilds === 'both') { %> +platform :ios do + desc "Archive and upload the iOS app to TestFlight" + lane :publish do |options| + publish_ios(options) + end +end +<% } %> diff --git a/templates/fastlane/Gemfile.ejs b/templates/fastlane/Gemfile.ejs new file mode 100644 index 0000000..4c1a510 --- /dev/null +++ b/templates/fastlane/Gemfile.ejs @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "fastlane" +<% if (framework === 'meteor') { %> +gem "cocoapods", "~> 1.16" +<% } %> diff --git a/templates/fastlane/Matchfile.ejs b/templates/fastlane/Matchfile.ejs new file mode 100644 index 0000000..7eda75e --- /dev/null +++ b/templates/fastlane/Matchfile.ejs @@ -0,0 +1,19 @@ +match_git_url = ENV["MATCH_GIT_URL"].to_s.strip +raise "Missing MATCH_GIT_URL" if match_git_url.empty? + +match_git_branch = ENV["MATCH_GIT_BRANCH"].to_s.strip + +storage_mode("git") +git_url(match_git_url) +git_branch(match_git_branch.empty? ? "main" : match_git_branch) +shallow_clone(true) +readonly(ENV["CI"].to_s == "true") +skip_docs(true) + +# Force legacy openssl enc (MD5 key derivation) for cross-platform compatibility. +# OpenSSL 3.x defaults to PBKDF2, which LibreSSL (on GitHub macOS runners) cannot decrypt. +force_legacy_encryption(true) + +app_identifier([ENV.fetch("IOS_APP_IDENTIFIER", "<%= iosBundleId || 'com.company.app' %>")]) + +git_basic_authorization(ENV["MATCH_GIT_BASIC_AUTHORIZATION"]) if ENV["MATCH_GIT_BASIC_AUTHORIZATION"] diff --git a/templates/fastlane/gitignore-additions.txt b/templates/fastlane/gitignore-additions.txt new file mode 100644 index 0000000..ee81c68 --- /dev/null +++ b/templates/fastlane/gitignore-additions.txt @@ -0,0 +1,6 @@ +# Fastlane +vendor/bundle/ +.bundle/ +fastlane/report.xml +fastlane/Preview.html +fastlane/logs/ diff --git a/templates/frameworks/meteor/ci-cd.yml.ejs b/templates/frameworks/meteor/ci-cd.yml.ejs new file mode 100644 index 0000000..f50314f --- /dev/null +++ b/templates/frameworks/meteor/ci-cd.yml.ejs @@ -0,0 +1,375 @@ +name: Deploy Production - Server & Mobile + +on: +<% if (trigger === 'release') { %> + release: + types: [created] +<% } else { %> + push: + branches: + - main + - master +<% } %> + +permissions: + contents: write + +jobs: +<% if (deployServer) { %> + deploy-server: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + +<% if (serverMethod === 'proxmox') { %> + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + + - name: Deploy to Proxmox Container + run: | + ssh -i ~/.ssh/deploy_key -p ${{ vars.DEPLOY_PORT || '22' }} -o StrictHostKeyChecking=no ${{ secrets.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }} << 'DEPLOY_SCRIPT' + set -e + + echo "=== Pulling latest code ===" + cd ~/app + git fetch origin main + git checkout main + git reset --hard origin/main + + echo "=== Resetting Meteor local cache ===" + export PATH="$HOME/.meteor:$PATH" + meteor reset --allow-superuser + + echo "=== Installing project dependencies ===" + meteor npm install --allow-superuser + + echo "=== Building Meteor server ===" + BUILD_DIR="$HOME/Builds/Webserver-build-$(date +%m-%d-%Y-%H%M%S)" + meteor build "${BUILD_DIR}" \ + --directory \ + --server-only \ + --server=${{ vars.SERVER_URL }} \ + --allow-superuser + + echo "=== Installing dependencies ===" + cd "${BUILD_DIR}/bundle/programs/server" + npm install + + echo "=== Installing bundle-level dependencies ===" + cd "${BUILD_DIR}/bundle" + npm install @babel/runtime + + echo "=== Updating symlink ===" + ln -sfn "${BUILD_DIR}/bundle" "$HOME/Builds/current" + + echo "=== Restarting service ===" + sudo systemctl restart app + + echo "=== Health check ===" + sleep 10 + if sudo systemctl is-active --quiet app; then + echo "Server deployed and running successfully" + else + echo "Server failed to start" + sudo journalctl -u app --no-pager -n 50 + exit 1 + fi + DEPLOY_SCRIPT +<% } else if (serverMethod === 'docker') { %> + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository }}:latest + + - name: Deploy via SSH (Docker Compose) + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ vars.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + docker pull ghcr.io/${{ github.repository }}:latest + docker compose -f docker-compose.yml up -d +<% } %> +<% } %> + +<% if (mobileBuilds === 'android' || mobileBuilds === 'both') { %> + build-and-publish-android: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + +<% if (useFastlane) { %> + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Install Ruby gems + run: bundle install +<% } %> + + - name: Install Meteor + run: | + curl https://install.meteor.com/ | sh + echo "$HOME/.meteor" >> $GITHUB_PATH + + - name: Install Dependencies + run: meteor npm install + + - name: Decode google-services.json + run: | + mkdir -p private/android + echo "${{ secrets.FIREBASE_SERVICES_JSON_BASE64 }}" | base64 -d > private/android/google-services.json + + - name: Build Android AAB + run: | + meteor build ./android-build \ + --platforms android \ + --server=${{ vars.SERVER_URL }} + + - name: Decode Keystore + run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > release-key.jks + +<% if (useFastlane) { %> + - name: Sign and publish Android AAB with Fastlane + env: + ANDROID_KS_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KS_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + run: | + bundle exec fastlane android publish \ + aab_search_root:"android-build" \ + output_aab_path:"app-release-signed.aab" \ + keystore_path:"release-key.jks" \ + keystore_password:"$ANDROID_KS_PASSWORD" \ + key_password:"$ANDROID_KS_KEY_PASSWORD" \ + keystore_alias:"${{ secrets.ANDROID_KEYSTORE_ALIAS }}" \ + package_name:"${{ vars.ANDROID_PACKAGE_NAME }}" \ + version_name:"v${{ github.event.release.tag_name || github.sha }}" \ + track:"internal" +<% } else { %> + - name: Sign AAB + run: | + UNSIGNED_AAB=$(find android-build -name "*.aab" -type f | head -1) + jarsigner -verbose \ + -sigalg SHA256withRSA \ + -digestalg SHA-256 \ + -keystore release-key.jks \ + -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \ + -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" \ + "$UNSIGNED_AAB" \ + "${{ secrets.ANDROID_KEYSTORE_ALIAS }}" + + SIGNED_AAB="app-release.aab" + cp "$UNSIGNED_AAB" "$SIGNED_AAB" + echo "SIGNED_AAB=${SIGNED_AAB}" >> $GITHUB_ENV + + - name: Upload to Google Play Store + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + packageName: ${{ vars.ANDROID_PACKAGE_NAME }} + releaseFiles: ${{ env.SIGNED_AAB }} + track: internal + status: completed +<% } %> +<% } %> + +<% if (mobileBuilds === 'ios' || mobileBuilds === 'both') { %> + build-and-publish-ios: + runs-on: macos-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + +<% if (useFastlane) { %> + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Install Ruby gems + run: bundle install +<% } %> + + - name: Install Meteor + run: | + curl https://install.meteor.com/ | sh + echo "$HOME/.meteor" >> $GITHUB_PATH + + - name: Install Dependencies + run: meteor npm install + + - name: Decode GoogleService-Info.plist + run: | + mkdir -p private/ios + echo "${{ secrets.FIREBASE_IOS_PLIST_BASE64 }}" | base64 -d > private/ios/GoogleService-Info.plist + + - name: Build iOS + run: | + meteor build ./ios-build \ + --platforms ios \ + --server=${{ vars.SERVER_URL }} + +<% if (useFastlane) { %> + - name: Decode App Store Connect API key + run: | + mkdir -p ~/private_keys + echo "${{ secrets.APPLE_API_KEY_P8_BASE64 }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8 + + - name: Decode distribution certificate + env: + IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }} + run: | + CERT_PATH="$RUNNER_TEMP/distribution.p12" + echo "$IOS_DIST_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + echo "IOS_DIST_CERT_P12_PATH=$CERT_PATH" >> $GITHUB_ENV + + - name: Archive and upload iOS with Fastlane + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + IOS_APP_IDENTIFIER: ${{ vars.IOS_BUNDLE_ID }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + MATCH_GIT_URL: ${{ vars.MATCH_GIT_URL }} + MATCH_GIT_BRANCH: ${{ vars.MATCH_GIT_BRANCH }} + IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + run: | + bundle exec fastlane ios publish \ + workspace_root:"ios-build" \ + team_id:"${{ secrets.APPLE_TEAM_ID }}" \ + bundle_id:"${{ vars.IOS_BUNDLE_ID }}" \ + api_key_id:"${{ secrets.APPLE_API_KEY_ID }}" \ + api_issuer_id:"${{ secrets.APPLE_API_ISSUER_ID }}" \ + api_key_path:"$HOME/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8" + + - name: Upload iOS build logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-build-logs + path: | + fastlane/logs/** + ~/Library/Logs/gym/** + if-no-files-found: warn +<% } else { %> + - name: Install Apple Certificate & Provisioning Profile + env: + CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + PROV_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -hex 16) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision + echo "$PROV_PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH" + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + PROFILE_UUID=$(security cms -D -i "$PROFILE_PATH" | grep -A1 UUID | grep string | sed 's/.*\(.*\)<\/string>/\1/') + cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/${PROFILE_UUID}.mobileprovision + + echo "KEYCHAIN_PATH=${KEYCHAIN_PATH}" >> $GITHUB_ENV + echo "PROFILE_UUID=${PROFILE_UUID}" >> $GITHUB_ENV + + - name: Archive iOS App + run: | + WORKSPACE_PATH=$(find ios-build -name "*.xcworkspace" -type d | head -1) + SCHEME="${{ vars.IOS_SCHEME_NAME }}" + + xcodebuild archive \ + -workspace "$WORKSPACE_PATH" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath $RUNNER_TEMP/App.xcarchive \ + -allowProvisioningUpdates \ + OTHER_CODE_SIGN_FLAGS="--keychain ${{ env.KEYCHAIN_PATH }}" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM=${{ secrets.APPLE_TEAM_ID }} \ + PROVISIONING_PROFILE_SPECIFIER="${{ env.PROFILE_UUID }}" + + - name: Export iOS App + run: | + cat > $RUNNER_TEMP/ExportOptions.plist << EOF + + + + + method + app-store-connect + provisioningProfiles + + ${{ vars.IOS_BUNDLE_ID }} + ${{ env.PROFILE_UUID }} + + + + EOF + + mkdir -p ~/private_keys + echo "${{ secrets.APPLE_API_KEY_P8_BASE64 }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8 + + xcodebuild -exportArchive \ + -archivePath $RUNNER_TEMP/App.xcarchive \ + -exportPath $RUNNER_TEMP/export \ + -exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \ + -allowProvisioningUpdates \ + -authenticationKeyPath ~/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8 \ + -authenticationKeyID ${{ secrets.APPLE_API_KEY_ID }} \ + -authenticationKeyIssuerID ${{ secrets.APPLE_API_ISSUER_ID }} + + - name: Upload to TestFlight + run: | + xcrun altool --upload-app -f $RUNNER_TEMP/export/*.ipa -t ios --apiKey ${{ secrets.APPLE_API_KEY_ID }} --apiIssuer ${{ secrets.APPLE_API_ISSUER_ID }} +<% } %> +<% } %> diff --git a/templates/frameworks/node/ci-cd.yml.ejs b/templates/frameworks/node/ci-cd.yml.ejs new file mode 100644 index 0000000..48ed115 --- /dev/null +++ b/templates/frameworks/node/ci-cd.yml.ejs @@ -0,0 +1,91 @@ +name: Deploy Production - Server + +on: +<% if (trigger === 'release') { %> + release: + types: [created] +<% } else { %> + push: + branches: + - main + - master +<% } %> + +permissions: + contents: write + +jobs: +<% if (deployServer) { %> + deploy-server: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + +<% if (serverMethod === 'proxmox') { %> + - name: Build Application + run: | + npm ci + npm run build + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + + - name: Deploy to Proxmox Container + run: | + # Create a tarball of the build + tar -czf build.tar.gz .next package.json package-lock.json public + + # SCP the build to the server + scp -i ~/.ssh/deploy_key -P ${{ vars.DEPLOY_PORT || '22' }} -o StrictHostKeyChecking=no build.tar.gz ${{ secrets.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }}:~/Builds/new_build.tar.gz + + # SSH in to extract and restart + ssh -i ~/.ssh/deploy_key -p ${{ vars.DEPLOY_PORT || '22' }} -o StrictHostKeyChecking=no ${{ secrets.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }} << 'DEPLOY_SCRIPT' + set -e + + BUILD_DIR="$HOME/Builds/build-$(date +%m-%d-%Y-%H%M%S)" + mkdir -p "$BUILD_DIR" + tar -xzf ~/Builds/new_build.tar.gz -C "$BUILD_DIR" + + cd "$BUILD_DIR" + npm ci --production + + ln -sfn "$BUILD_DIR" "$HOME/Builds/current" + sudo systemctl restart app + DEPLOY_SCRIPT +<% } else if (serverMethod === 'docker') { %> + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository }}:latest + + - name: Deploy via SSH (Docker Compose) + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ vars.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + docker pull ghcr.io/${{ github.repository }}:latest + docker compose -f docker-compose.yml up -d +<% } %> +<% } %> diff --git a/templates/frameworks/react-native/ci-cd.yml.ejs b/templates/frameworks/react-native/ci-cd.yml.ejs new file mode 100644 index 0000000..4584fec --- /dev/null +++ b/templates/frameworks/react-native/ci-cd.yml.ejs @@ -0,0 +1,163 @@ +name: Build and Publish Mobile Apps + +on: +<% if (trigger === 'release') { %> + release: + types: [created] +<% } else { %> + push: + branches: + - main + - master +<% } %> + +jobs: +<% if (mobileBuilds === 'android' || mobileBuilds === 'both') { %> + build-android: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install Dependencies + run: npm ci + + - name: Build Android Release + run: | + cd android + ./gradlew bundleRelease + + - name: Sign AAB + run: | + echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/release.keystore + jarsigner -verbose \ + -sigalg SHA256withRSA \ + -digestalg SHA-256 \ + -keystore android/app/release.keystore \ + -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \ + -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" \ + android/app/build/outputs/bundle/release/app-release.aab \ + "${{ secrets.ANDROID_KEYSTORE_ALIAS }}" + + - name: Upload to Google Play Store + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + packageName: ${{ vars.ANDROID_PACKAGE_NAME }} + releaseFiles: android/app/build/outputs/bundle/release/app-release.aab + track: internal + status: completed +<% } %> + +<% if (mobileBuilds === 'ios' || mobileBuilds === 'both') { %> + build-ios: + runs-on: macos-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Install CocoaPods + run: | + cd ios + pod install + + - name: Install Apple Certificate & Provisioning Profile + env: + CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} + PROV_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -hex 16) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision + echo "$PROV_PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH" + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + PROFILE_UUID=$(security cms -D -i "$PROFILE_PATH" | grep -A1 UUID | grep string | sed 's/.*\(.*\)<\/string>/\1/') + cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/${PROFILE_UUID}.mobileprovision + + echo "KEYCHAIN_PATH=${KEYCHAIN_PATH}" >> $GITHUB_ENV + echo "PROFILE_UUID=${PROFILE_UUID}" >> $GITHUB_ENV + + - name: Archive iOS App + run: | + cd ios + WORKSPACE_PATH=$(find . -name "*.xcworkspace" -type d | head -1) + SCHEME="${{ vars.IOS_SCHEME_NAME }}" + + xcodebuild archive \ + -workspace "$WORKSPACE_PATH" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath $RUNNER_TEMP/App.xcarchive \ + -allowProvisioningUpdates \ + OTHER_CODE_SIGN_FLAGS="--keychain ${{ env.KEYCHAIN_PATH }}" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM=${{ secrets.APPLE_TEAM_ID }} \ + PROVISIONING_PROFILE_SPECIFIER="${{ env.PROFILE_UUID }}" + + - name: Export iOS App + run: | + cat > $RUNNER_TEMP/ExportOptions.plist << EOF + + + + + method + app-store-connect + provisioningProfiles + + ${{ vars.IOS_BUNDLE_ID }} + ${{ env.PROFILE_UUID }} + + + + EOF + + mkdir -p ~/private_keys + echo "${{ secrets.APPLE_API_KEY_P8_BASE64 }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8 + + xcodebuild -exportArchive \ + -archivePath $RUNNER_TEMP/App.xcarchive \ + -exportPath $RUNNER_TEMP/export \ + -exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \ + -allowProvisioningUpdates \ + -authenticationKeyPath ~/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8 \ + -authenticationKeyID ${{ secrets.APPLE_API_KEY_ID }} \ + -authenticationKeyIssuerID ${{ secrets.APPLE_API_ISSUER_ID }} + + - name: Upload to TestFlight + run: | + xcrun altool --upload-app -f $RUNNER_TEMP/export/*.ipa -t ios --apiKey ${{ secrets.APPLE_API_KEY_ID }} --apiIssuer ${{ secrets.APPLE_API_ISSUER_ID }} +<% } %>