From f5bf18b739aeaf04df5f4c6df1d6b6fa1d942cf1 Mon Sep 17 00:00:00 2001 From: Myx Date: Tue, 10 Mar 2026 23:56:53 +0100 Subject: [PATCH] Add runner ci (test) --- .gitea/workflows/publish-draft-release.yml | 30 + .gitea/workflows/release-draft.yml | 142 +++++ .gitignore | 1 + README.md | 24 + package.json | 18 +- server/data/metoyou.sqlite | Bin 45056 -> 45056 bytes server/data/metoyou.sqlite_old | Bin 0 -> 45056 bytes server/package.json | 10 + server/src/config/variables.ts | 3 +- server/src/cqrs/index.ts | 2 +- server/src/db/database.ts | 59 +- server/src/generated/build-version.ts | 1 + server/src/index.ts | 10 +- server/src/migrations/index.ts | 3 + server/src/routes/health.ts | 17 +- server/src/runtime-paths.ts | 58 ++ tools/gitea-release.js | 654 +++++++++++++++++++++ tools/package-server-executable.js | 111 ++++ tools/resolve-release-version.js | 136 +++++ tools/set-release-version.js | 83 +++ tools/sync-server-build-version.js | 49 ++ 21 files changed, 1372 insertions(+), 39 deletions(-) create mode 100644 .gitea/workflows/publish-draft-release.yml create mode 100644 .gitea/workflows/release-draft.yml create mode 100644 server/data/metoyou.sqlite_old create mode 100644 server/src/generated/build-version.ts create mode 100644 server/src/migrations/index.ts create mode 100644 server/src/runtime-paths.ts create mode 100644 tools/gitea-release.js create mode 100644 tools/package-server-executable.js create mode 100644 tools/resolve-release-version.js create mode 100644 tools/set-release-version.js create mode 100644 tools/sync-server-build-version.js diff --git a/.gitea/workflows/publish-draft-release.yml b/.gitea/workflows/publish-draft-release.yml new file mode 100644 index 0000000..9541733 --- /dev/null +++ b/.gitea/workflows/publish-draft-release.yml @@ -0,0 +1,30 @@ +name: Publish Draft Release + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to publish, for example v1.0.42 + required: true + +jobs: + publish-release: + name: Publish approved draft release + runs-on: linux + steps: + - name: Checkout repository + uses: https://github.com/actions/checkout@v4 + + - name: Setup Node.js + uses: https://github.com/actions/setup-node@v4 + with: + node-version: 20 + + - name: Publish draft release + env: + GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }} + run: > + node tools/gitea-release.js publish + --server-url "${{ github.server_url }}" + --repository "${{ github.repository }}" + --tag "${{ github.event.inputs.tag }}" diff --git a/.gitea/workflows/release-draft.yml b/.gitea/workflows/release-draft.yml new file mode 100644 index 0000000..a2b622b --- /dev/null +++ b/.gitea/workflows/release-draft.yml @@ -0,0 +1,142 @@ +name: Queue Release Build + +on: + push: + branches: + - main + - master + workflow_dispatch: + +jobs: + prepare-release: + name: Prepare draft release + runs-on: linux + outputs: + release_download_url: ${{ steps.release.outputs.release_download_url }} + release_id: ${{ steps.release.outputs.release_id }} + release_name: ${{ steps.version.outputs.release_name }} + release_tag: ${{ steps.version.outputs.release_tag }} + release_version: ${{ steps.version.outputs.release_version }} + steps: + - name: Checkout repository + uses: https://github.com/actions/checkout@v4 + + - name: Setup Node.js + uses: https://github.com/actions/setup-node@v4 + with: + node-version: 20 + + - name: Resolve release version + id: version + run: node tools/resolve-release-version.js --write-output + + - name: Ensure draft release exists + id: release + env: + GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }} + run: > + node tools/gitea-release.js ensure-draft + --server-url "${{ github.server_url }}" + --repository "${{ github.repository }}" + --tag "${{ steps.version.outputs.release_tag }}" + --target "${{ github.sha }}" + --name "${{ steps.version.outputs.release_name }}" + --body "Automated draft release queued from ${{ github.ref_name }} @ ${{ github.sha }}. Desktop auto-update assets, release-manifest.json, and server executables are attached by the platform build jobs. Publish this draft after approval." + --write-output + + build-linux: + name: Build Linux release assets + needs: prepare-release + runs-on: linux + steps: + - name: Checkout repository + uses: https://github.com/actions/checkout@v4 + + - name: Setup Node.js + uses: https://github.com/actions/setup-node@v4 + with: + node-version: 20 + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm install --prefix server + + - name: Set CI release version + run: > + node tools/set-release-version.js + --version "${{ needs.prepare-release.outputs.release_version }}" + + - name: Build Linux desktop and server assets + run: npm run release:build:linux + + - name: Download previous published manifest + env: + GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }} + run: > + node tools/gitea-release.js download-latest-manifest + --server-url "${{ github.server_url }}" + --repository "${{ github.repository }}" + --output dist-electron/release-manifest.previous.json + --allow-missing + + - name: Generate release manifest + run: > + node tools/generate-release-manifest.js + --existing dist-electron/release-manifest.previous.json + --manifest dist-electron/release-manifest.json + --feed-url "${{ needs.prepare-release.outputs.release_download_url }}" + --version "${{ needs.prepare-release.outputs.release_version }}" + + - name: Upload Linux assets to draft release + env: + GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }} + run: > + node tools/gitea-release.js upload-built-assets + --server-url "${{ github.server_url }}" + --repository "${{ github.repository }}" + --release-id "${{ needs.prepare-release.outputs.release_id }}" + --dist-electron dist-electron + --dist-server dist-server + + build-windows: + name: Build Windows release assets + needs: prepare-release + runs-on: windows + defaults: + run: + shell: powershell + steps: + - name: Checkout repository + uses: https://github.com/actions/checkout@v4 + + - name: Setup Node.js + uses: https://github.com/actions/setup-node@v4 + with: + node-version: 20 + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm install --prefix server + + - name: Set CI release version + run: > + node tools/set-release-version.js + --version "${{ needs.prepare-release.outputs.release_version }}" + + - name: Build Windows desktop and server assets + run: npm run release:build:win + + - name: Upload Windows assets to draft release + env: + GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }} + run: > + node tools/gitea-release.js upload-built-assets + --server-url "${{ github.server_url }}" + --repository "${{ github.repository }}" + --release-id "${{ needs.prepare-release.outputs.release_id }}" + --dist-electron dist-electron + --dist-server dist-server diff --git a/.gitignore b/.gitignore index 082e1fc..8f65d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ Thumbs.db .env .certs/ /server/data/variables.json +dist-server/* diff --git a/README.md b/README.md index fb105cc..cbf849a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,30 @@ The manifest format is: `feedUrl` must point to a directory that contains the Electron Builder update descriptors for Windows, macOS, and Linux. +### Automated Gitea release queue + +The Gitea workflows in `.gitea/workflows/release-draft.yml` and `.gitea/workflows/publish-draft-release.yml` keep the existing desktop auto-update flow intact. + +On every push to `main` or `master`, the release workflow: + +1. Computes a semver release version from the current `package.json` major/minor version and the workflow run number. +2. Builds the Linux and Windows Electron packages. +3. Builds standalone server executables for Linux and Windows. +4. Downloads the latest published `release-manifest.json`, merges the new release feed URL, and uploads the updated manifest to the draft release. +5. Uploads the desktop installers, update descriptors, server executables, and `release-manifest.json` to the matching Gitea release page. + +The draft release uses the standard Gitea download path as its `feedUrl`: + + `https://YOUR_GITEA_HOST/OWNER/REPO/releases/download/vX.Y.Z` + +That means the current desktop auto-updater keeps working without any client-side changes once the draft release is approved and published. + +To enable the workflow: + +- Add a repository secret named `GITEA_RELEASE_TOKEN` with permission to create releases and upload release assets. +- Make sure your Gitea runner labels match the workflow `runs-on` values (`linux` and `windows`). +- After the draft release is reviewed, publish it either from the Gitea release page or by running the `Publish Draft Release` workflow with the queued release tag. + ## Main commands - `npm run dev` starts Angular, the server, and Electron diff --git a/package.json b/package.json index 87a4ad0..2ea5504 100644 --- a/package.json +++ b/package.json @@ -30,18 +30,25 @@ "migration:run": "typeorm migration:run -d dist/electron/data-source.js", "migration:revert": "typeorm migration:revert -d dist/electron/data-source.js", "electron:build": "npm run build:prod && npm run build:electron && electron-builder", - "electron:build:win": "npm run build:prod && electron-builder --win", - "electron:build:mac": "npm run build:prod && electron-builder --mac", - "electron:build:linux": "npm run build:prod && electron-builder --linux", - "electron:build:all": "npm run build:prod && electron-builder --win --mac --linux", - "build:prod:all": "npm run build:prod && cd server && npm run build", + "electron:build:win": "npm run build:prod && npm run build:electron && electron-builder --win", + "electron:build:mac": "npm run build:prod && npm run build:electron && electron-builder --mac", + "electron:build:linux": "npm run build:prod && npm run build:electron && electron-builder --linux", + "electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux", + "build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build", "build:prod:win": "npm run build:prod:all && electron-builder --win", "dev": "npm run electron:full", + "dev:app": "npm run electron:dev", "lint": "eslint .", "lint:fix": "npm run format && npm run sort:props && eslint . --fix", "format": "prettier --write \"src/app/**/*.html\"", "format:check": "prettier --check \"src/app/**/*.html\"", + "release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux", + "release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win", "release:manifest": "node tools/generate-release-manifest.js", + "release:set-version": "node tools/set-release-version.js", + "release:version": "node tools/resolve-release-version.js", + "server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64", + "server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe", "sort:props": "node tools/sort-template-properties.js" }, "private": true, @@ -102,6 +109,7 @@ "eslint-plugin-import-newlines": "^1.4.1", "eslint-plugin-prettier": "^5.5.5", "glob": "^10.5.0", + "pkg": "^5.8.1", "postcss": "^8.5.6", "prettier": "^3.8.1", "tailwindcss": "^3.4.19", diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 88c6848f04e358ed18fa701e06b600fc1747e8c3..58aedb3cee41b3711ea99fe7afa3cc73a53ee438 100644 GIT binary patch literal 45056 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 diff --git a/server/data/metoyou.sqlite_old b/server/data/metoyou.sqlite_old new file mode 100644 index 0000000000000000000000000000000000000000..88c6848f04e358ed18fa701e06b600fc1747e8c3 GIT binary patch literal 45056 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 literal 0 HcmV?d00001 diff --git a/server/package.json b/server/package.json index 24089e7..a092ad9 100644 --- a/server/package.json +++ b/server/package.json @@ -3,7 +3,9 @@ "version": "1.0.0", "description": "Signaling server for MetoYou P2P chat application", "main": "dist/index.js", + "bin": "dist/index.js", "scripts": { + "prebuild": "node ../tools/sync-server-build-version.js", "build": "tsc", "start": "node dist/index.js", "dev": "ts-node-dev --respawn src/index.ts" @@ -27,5 +29,13 @@ "@types/ws": "^8.5.8", "ts-node-dev": "^2.0.0", "typescript": "^5.2.2" + }, + "pkg": { + "assets": [ + "node_modules/ansis/**/*" + ], + "scripts": [ + "dist/**/*.js" + ] } } diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index fadf1cf..6aa26dd 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -1,12 +1,13 @@ import fs from 'fs'; import path from 'path'; +import { resolveRuntimePath } from '../runtime-paths'; export interface ServerVariablesConfig { klipyApiKey: string; releaseManifestUrl: string; } -const DATA_DIR = path.join(process.cwd(), 'data'); +const DATA_DIR = resolveRuntimePath('data'); const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json'); function normalizeKlipyApiKey(value: unknown): string { diff --git a/server/src/cqrs/index.ts b/server/src/cqrs/index.ts index 163fc2b..26bc7cd 100644 --- a/server/src/cqrs/index.ts +++ b/server/src/cqrs/index.ts @@ -1,4 +1,4 @@ -import { getDataSource } from '../db'; +import { getDataSource } from '../db/database'; import { CommandType, QueryType, diff --git a/server/src/db/database.ts b/server/src/db/database.ts index cba8c1a..214200d 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -6,12 +6,27 @@ import { ServerEntity, JoinRequestEntity } from '../entities'; +import { serverMigrations } from '../migrations'; +import { findExistingPath, resolveRuntimePath } from '../runtime-paths'; -const DATA_DIR = path.join(process.cwd(), 'data'); +const DATA_DIR = resolveRuntimePath('data'); const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite'); let applicationDataSource: DataSource | undefined; +function resolveSqlJsConfig(): { locateFile: (file: string) => string } { + return { + locateFile: (file) => { + const bundledBinaryPath = path.join(__dirname, '..', '..', 'node_modules', 'sql.js', 'dist', file); + + return findExistingPath( + resolveRuntimePath(file), + bundledBinaryPath + ) ?? bundledBinaryPath; + } + }; +} + export function getDataSource(): DataSource { if (!applicationDataSource?.isInitialized) { throw new Error('DataSource not initialised'); @@ -29,22 +44,34 @@ export async function initDatabase(): Promise { if (fs.existsSync(DB_FILE)) database = fs.readFileSync(DB_FILE); - applicationDataSource = new DataSource({ - type: 'sqljs', - database, - entities: [ - AuthUserEntity, - ServerEntity, - JoinRequestEntity - ], - migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')], - synchronize: false, - logging: false, - autoSave: true, - location: DB_FILE - }); + try { + applicationDataSource = new DataSource({ + type: 'sqljs', + database, + entities: [ + AuthUserEntity, + ServerEntity, + JoinRequestEntity + ], + migrations: serverMigrations, + synchronize: false, + logging: false, + autoSave: true, + location: DB_FILE, + sqlJsConfig: resolveSqlJsConfig() + }); + } catch (error) { + console.error('[DB] Failed to configure the sql.js data source', error); + throw error; + } + + try { + await applicationDataSource.initialize(); + } catch (error) { + console.error('[DB] Failed to initialise the sql.js data source', error); + throw error; + } - await applicationDataSource.initialize(); console.log('[DB] Connection initialised at:', DB_FILE); await applicationDataSource.runMigrations(); diff --git a/server/src/generated/build-version.ts b/server/src/generated/build-version.ts new file mode 100644 index 0000000..79c83c1 --- /dev/null +++ b/server/src/generated/build-version.ts @@ -0,0 +1 @@ +export const SERVER_BUILD_VERSION = "1.0.0"; diff --git a/server/src/index.ts b/server/src/index.ts index 6fdceb6..e933c70 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,11 +4,15 @@ import path from 'path'; import fs from 'fs'; import { createServer as createHttpServer } from 'http'; import { createServer as createHttpsServer } from 'https'; +import { + resolveCertificateDirectory, + resolveEnvFilePath +} from './runtime-paths'; // Load .env from project root (one level up from server/) -dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') }); +dotenv.config({ path: resolveEnvFilePath() }); -import { initDatabase } from './db'; +import { initDatabase } from './db/database'; import { deleteStaleJoinRequests } from './cqrs'; import { createApp } from './app'; import { @@ -23,7 +27,7 @@ const PORT = process.env.PORT || 3001; function buildServer(app: ReturnType) { if (USE_SSL) { - const certDir = path.resolve(__dirname, '..', '..', '.certs'); + const certDir = resolveCertificateDirectory(); const certFile = path.join(certDir, 'localhost.crt'); const keyFile = path.join(certDir, 'localhost.key'); diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts new file mode 100644 index 0000000..a9857bc --- /dev/null +++ b/server/src/migrations/index.ts @@ -0,0 +1,3 @@ +import { InitialSchema1000000000000 } from './1000000000000-InitialSchema'; + +export const serverMigrations = [InitialSchema1000000000000]; diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 1eba3a5..a647d74 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,24 +1,15 @@ import { Router } from 'express'; -import fs from 'fs'; -import path from 'path'; import { getAllPublicServers } from '../cqrs'; import { getReleaseManifestUrl } from '../config/variables'; +import { SERVER_BUILD_VERSION } from '../generated/build-version'; import { connectedUsers } from '../websocket/state'; const router = Router(); function getServerProjectVersion(): string { - try { - const packageJsonPath = path.join(process.cwd(), 'package.json'); - const rawContents = fs.readFileSync(packageJsonPath, 'utf8'); - const parsed = JSON.parse(rawContents) as { version?: unknown }; - - return typeof parsed.version === 'string' && parsed.version.trim().length > 0 - ? parsed.version.trim() - : '0.0.0'; - } catch { - return '0.0.0'; - } + return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0 + ? process.env.METOYOU_SERVER_VERSION.trim() + : SERVER_BUILD_VERSION; } router.get('/health', async (_req, res) => { diff --git a/server/src/runtime-paths.ts b/server/src/runtime-paths.ts new file mode 100644 index 0000000..e9801ef --- /dev/null +++ b/server/src/runtime-paths.ts @@ -0,0 +1,58 @@ +import fs from 'fs'; +import path from 'path'; + +type PackagedProcess = NodeJS.Process & { pkg?: unknown }; + +function uniquePaths(paths: string[]): string[] { + return [...new Set(paths.map((candidate) => path.resolve(candidate)))]; +} + +export function isPackagedRuntime(): boolean { + return Boolean((process as PackagedProcess).pkg); +} + +export function getRuntimeBaseDir(): string { + return isPackagedRuntime() + ? path.dirname(process.execPath) + : process.cwd(); +} + +export function resolveRuntimePath(...segments: string[]): string { + return path.join(getRuntimeBaseDir(), ...segments); +} + +export function resolveProjectRootPath(...segments: string[]): string { + return path.resolve(__dirname, '..', '..', ...segments); +} + +export function findExistingPath(...candidates: string[]): string | null { + for (const candidate of uniquePaths(candidates)) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +export function resolveEnvFilePath(): string { + if (isPackagedRuntime()) { + return resolveRuntimePath('.env'); + } + + return findExistingPath( + resolveRuntimePath('.env'), + resolveProjectRootPath('.env') + ) ?? resolveProjectRootPath('.env'); +} + +export function resolveCertificateDirectory(): string { + if (isPackagedRuntime()) { + return resolveRuntimePath('.certs'); + } + + return findExistingPath( + resolveRuntimePath('.certs'), + resolveProjectRootPath('.certs') + ) ?? resolveRuntimePath('.certs'); +} diff --git a/tools/gitea-release.js b/tools/gitea-release.js new file mode 100644 index 0000000..c6823da --- /dev/null +++ b/tools/gitea-release.js @@ -0,0 +1,654 @@ +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const https = require('https'); + +function parseArgs(argv) { + const args = { _: [] }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (!token.startsWith('--')) { + args._.push(token); + continue; + } + + const key = token.slice(2); + const nextToken = argv[index + 1]; + + if (!nextToken || nextToken.startsWith('--')) { + args[key] = true; + continue; + } + + args[key] = nextToken; + index += 1; + } + + return args; +} + +function requireArg(args, key) { + const value = args[key] ?? process.env[key.toUpperCase().replace(/-/g, '_')]; + + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Missing required argument: --${key}`); + } + + return value.trim(); +} + +function resolveToken(args) { + const token = args.token + || process.env.GITEA_RELEASE_TOKEN + || process.env.GITHUB_TOKEN; + + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error('A Gitea API token is required. Set GITEA_RELEASE_TOKEN or pass --token.'); + } + + return token.trim(); +} + +function normalizeServerUrl(rawValue) { + const parsedUrl = new URL(rawValue.trim()); + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('The Gitea server URL must use http:// or https://.'); + } + + return parsedUrl.toString().replace(/\/$/, ''); +} + +function parseRepository(rawValue) { + const trimmedValue = rawValue.trim(); + const [owner, ...repoParts] = trimmedValue.split('/'); + + if (!owner || repoParts.length === 0) { + throw new Error(`Repository must be in OWNER/REPO format. Received: ${rawValue}`); + } + + return { + owner, + repo: repoParts.join('/') + }; +} + +function buildApiUrl(serverUrl, repository, apiPath, query = {}) { + const { owner, repo } = parseRepository(repository); + const pathname = apiPath + .replace('{owner}', encodeURIComponent(owner)) + .replace('{repo}', encodeURIComponent(repo)); + const url = new URL(`${serverUrl}/api/v1${pathname}`); + + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') { + continue; + } + + url.searchParams.set(key, String(value)); + } + + return url; +} + +function buildAuthHeaders(token) { + return { + Authorization: `token ${token}`, + 'X-Gitea-Token': token + }; +} + +function sendRequest({ + body, + expectedStatuses = [200], + headers = {}, + maxRedirects = 5, + method, + token, + url +}) { + return new Promise((resolve, reject) => { + const requestUrl = typeof url === 'string' ? new URL(url) : url; + const transport = requestUrl.protocol === 'https:' ? https : http; + const request = transport.request({ + method, + hostname: requestUrl.hostname, + port: requestUrl.port || undefined, + path: `${requestUrl.pathname}${requestUrl.search}`, + headers: { + Accept: 'application/json', + ...buildAuthHeaders(token), + ...headers + } + }, (response) => { + const statusCode = response.statusCode || 0; + const location = response.headers.location; + + if (statusCode >= 300 && statusCode < 400 && location && maxRedirects > 0) { + response.resume(); + sendRequest({ + body, + expectedStatuses, + headers, + maxRedirects: maxRedirects - 1, + method, + token, + url: new URL(location, requestUrl) + }).then(resolve, reject); + return; + } + + const chunks = []; + + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const responseBody = Buffer.concat(chunks); + + if (!expectedStatuses.includes(statusCode)) { + reject(new Error(`${method} ${requestUrl} failed with status ${statusCode}: ${responseBody.toString('utf8')}`)); + return; + } + + resolve({ + body: responseBody, + headers: response.headers, + statusCode + }); + }); + }); + + request.on('error', reject); + + if (body) { + request.end(body); + return; + } + + request.end(); + }); +} + +async function sendJsonRequest({ expectedStatuses, jsonBody, method, repository, serverUrl, token, urlPath }) { + const body = jsonBody ? Buffer.from(JSON.stringify(jsonBody), 'utf8') : undefined; + const response = await sendRequest({ + body, + expectedStatuses, + headers: jsonBody ? { + 'Content-Length': body.length, + 'Content-Type': 'application/json' + } : undefined, + method, + token, + url: buildApiUrl(serverUrl, repository, urlPath) + }); + const responseText = response.body.toString('utf8').trim(); + + return responseText.length > 0 ? JSON.parse(responseText) : null; +} + +async function sendJsonRequestWithQuery({ expectedStatuses, jsonBody, method, query, repository, serverUrl, token, urlPath }) { + const body = jsonBody ? Buffer.from(JSON.stringify(jsonBody), 'utf8') : undefined; + const response = await sendRequest({ + body, + expectedStatuses, + headers: jsonBody ? { + 'Content-Length': body.length, + 'Content-Type': 'application/json' + } : undefined, + method, + token, + url: buildApiUrl(serverUrl, repository, urlPath, query) + }); + const responseText = response.body.toString('utf8').trim(); + + return responseText.length > 0 ? JSON.parse(responseText) : null; +} + +async function getReleaseByTag({ repository, serverUrl, tag, token }) { + try { + return await sendJsonRequest({ + expectedStatuses: [200], + method: 'GET', + repository, + serverUrl, + token, + urlPath: `/repos/{owner}/{repo}/releases/tags/${encodeURIComponent(tag)}` + }); + } catch (error) { + if (error instanceof Error && /status 404/.test(error.message)) { + return null; + } + + throw error; + } +} + +async function getReleaseById({ id, repository, serverUrl, token }) { + return sendJsonRequest({ + expectedStatuses: [200], + method: 'GET', + repository, + serverUrl, + token, + urlPath: `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(id))}` + }); +} + +async function createRelease({ body, draft, name, prerelease, repository, serverUrl, tag, target, token }) { + return sendJsonRequest({ + expectedStatuses: [201], + jsonBody: { + body, + draft, + name, + prerelease, + tag_name: tag, + target_commitish: target + }, + method: 'POST', + repository, + serverUrl, + token, + urlPath: '/repos/{owner}/{repo}/releases' + }); +} + +async function updateRelease({ body, draft, id, name, prerelease, repository, serverUrl, tag, target, token }) { + return sendJsonRequest({ + expectedStatuses: [200], + jsonBody: { + body, + draft, + name, + prerelease, + tag_name: tag, + target_commitish: target + }, + method: 'PATCH', + repository, + serverUrl, + token, + urlPath: `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(id))}` + }); +} + +async function listReleaseAssets({ releaseId, repository, serverUrl, token }) { + return sendJsonRequest({ + expectedStatuses: [200], + method: 'GET', + repository, + serverUrl, + token, + urlPath: `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(releaseId))}/assets` + }); +} + +async function listReleases({ draft, limit, repository, serverUrl, token }) { + return sendJsonRequestWithQuery({ + expectedStatuses: [200], + method: 'GET', + query: { + draft, + limit, + page: 1 + }, + repository, + serverUrl, + token, + urlPath: '/repos/{owner}/{repo}/releases' + }); +} + +async function deleteReleaseAsset({ attachmentId, releaseId, repository, serverUrl, token }) { + await sendRequest({ + expectedStatuses: [204], + method: 'DELETE', + token, + url: buildApiUrl( + serverUrl, + repository, + `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(releaseId))}/assets/${encodeURIComponent(String(attachmentId))}` + ) + }); +} + +function uploadReleaseAsset({ filePath, releaseId, repository, serverUrl, token }) { + return new Promise((resolve, reject) => { + const fileName = path.basename(filePath); + const boundary = `----MetoYouRelease${Date.now().toString(16)}`; + const preamble = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="${fileName.replace(/"/g, '')}"\r\nContent-Type: application/octet-stream\r\n\r\n`, + 'utf8' + ); + const closing = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'); + const fileSize = fs.statSync(filePath).size; + const uploadUrl = buildApiUrl( + serverUrl, + repository, + `/repos/{owner}/{repo}/releases/${encodeURIComponent(String(releaseId))}/assets`, + { name: fileName } + ); + const transport = uploadUrl.protocol === 'https:' ? https : http; + const request = transport.request({ + method: 'POST', + hostname: uploadUrl.hostname, + port: uploadUrl.port || undefined, + path: `${uploadUrl.pathname}${uploadUrl.search}`, + headers: { + Accept: 'application/json', + ...buildAuthHeaders(token), + 'Content-Length': preamble.length + fileSize + closing.length, + 'Content-Type': `multipart/form-data; boundary=${boundary}` + } + }, (response) => { + const chunks = []; + + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const statusCode = response.statusCode || 0; + const responseBody = Buffer.concat(chunks).toString('utf8'); + + if (statusCode !== 201) { + reject(new Error(`Failed to upload ${fileName}: ${statusCode} ${responseBody}`)); + return; + } + + resolve(responseBody.trim().length > 0 ? JSON.parse(responseBody) : null); + }); + }); + + request.on('error', reject); + request.write(preamble); + + const stream = fs.createReadStream(filePath); + stream.on('error', (error) => { + request.destroy(error); + reject(error); + }); + stream.on('end', () => request.end(closing)); + stream.pipe(request, { end: false }); + }); +} + +async function downloadFile({ filePath, token, url }) { + const response = await sendRequest({ + expectedStatuses: [200], + method: 'GET', + token, + url + }); + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, response.body); +} + +function writeGitHubOutputs(entries) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + for (const [key, value] of Object.entries(entries)) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`, 'utf8'); + } +} + +function toDownloadUrl(serverUrl, repository, tag) { + return `${serverUrl}/${repository}/releases/download/${encodeURIComponent(tag)}`; +} + +function collectTopLevelFiles(directoryPath) { + if (!fs.existsSync(directoryPath)) { + return []; + } + + return fs.readdirSync(directoryPath, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(directoryPath, entry.name)); +} + +function isDesktopReleaseAsset(filePath) { + const fileName = path.basename(filePath); + const lowerFileName = fileName.toLowerCase(); + + return fileName === 'latest.yml' + || fileName === 'latest-linux.yml' + || fileName === 'latest-mac.yml' + || fileName === 'release-manifest.json' + || lowerFileName.endsWith('.appimage') + || lowerFileName.endsWith('.blockmap') + || lowerFileName.endsWith('.deb') + || lowerFileName.endsWith('.dmg') + || lowerFileName.endsWith('.exe') + || lowerFileName.endsWith('.zip'); +} + +function collectBuiltAssets(args) { + const distElectronDir = path.resolve(process.cwd(), args['dist-electron'] || 'dist-electron'); + const distServerDir = path.resolve(process.cwd(), args['dist-server'] || 'dist-server'); + const files = [ + ...collectTopLevelFiles(distElectronDir).filter(isDesktopReleaseAsset), + ...collectTopLevelFiles(distServerDir) + ]; + + return [...new Set(files)].sort((left, right) => left.localeCompare(right)); +} + +async function ensureDraftReleaseCommand(args) { + const token = resolveToken(args); + const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); + const repository = requireArg(args, 'repository'); + const tag = requireArg(args, 'tag'); + const target = requireArg(args, 'target'); + const name = requireArg(args, 'name'); + const body = typeof args.body === 'string' ? args.body : ''; + const existingRelease = await getReleaseByTag({ repository, serverUrl, tag, token }); + const release = existingRelease + ? await updateRelease({ + body, + draft: Boolean(existingRelease.draft), + id: existingRelease.id, + name, + prerelease: Boolean(existingRelease.prerelease), + repository, + serverUrl, + tag, + target, + token + }) + : await createRelease({ + body, + draft: true, + name, + prerelease: false, + repository, + serverUrl, + tag, + target, + token + }); + const outputs = { + release_download_url: toDownloadUrl(serverUrl, repository, release.tag_name), + release_html_url: release.html_url, + release_id: String(release.id), + release_tag: release.tag_name + }; + + if (args['write-output']) { + writeGitHubOutputs(outputs); + } + + console.log(JSON.stringify({ + ...outputs, + release_name: release.name + }, null, 2)); +} + +async function downloadLatestManifestCommand(args) { + const token = resolveToken(args); + const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); + const repository = requireArg(args, 'repository'); + const outputPath = path.resolve(process.cwd(), requireArg(args, 'output')); + const allowMissing = Boolean(args['allow-missing']); + const releases = await listReleases({ + draft: false, + limit: 20, + repository, + serverUrl, + token + }); + + for (const release of Array.isArray(releases) ? releases : []) { + if (!release || release.draft) { + continue; + } + + const assets = await listReleaseAssets({ + releaseId: release.id, + repository, + serverUrl, + token + }); + const manifestAsset = Array.isArray(assets) + ? assets.find((asset) => asset && asset.name === 'release-manifest.json') + : null; + + if (!manifestAsset) { + continue; + } + + await downloadFile({ + filePath: outputPath, + token, + url: manifestAsset.browser_download_url + }); + + console.log(`[gitea-release] Downloaded ${manifestAsset.name} from ${release.tag_name}`); + return; + } + + if (allowMissing) { + console.log('[gitea-release] No published release manifest was found. Continuing without a previous manifest.'); + return; + } + + throw new Error('No published release-manifest.json asset was found.'); +} + +async function uploadBuiltAssetsCommand(args) { + const token = resolveToken(args); + const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); + const repository = requireArg(args, 'repository'); + const releaseId = requireArg(args, 'release-id'); + const files = collectBuiltAssets(args); + + if (files.length === 0) { + throw new Error('No built assets were found to upload.'); + } + + const existingAssets = await listReleaseAssets({ + releaseId, + repository, + serverUrl, + token + }); + const existingAssetsByName = new Map( + (Array.isArray(existingAssets) ? existingAssets : []).map((asset) => [asset.name, asset]) + ); + + for (const filePath of files) { + const fileName = path.basename(filePath); + const existingAsset = existingAssetsByName.get(fileName); + + if (existingAsset) { + await deleteReleaseAsset({ + attachmentId: existingAsset.id, + releaseId, + repository, + serverUrl, + token + }); + } + + await uploadReleaseAsset({ + filePath, + releaseId, + repository, + serverUrl, + token + }); + + console.log(`[gitea-release] Uploaded ${fileName}`); + } +} + +async function publishReleaseCommand(args) { + const token = resolveToken(args); + const serverUrl = normalizeServerUrl(requireArg(args, 'server-url')); + const repository = requireArg(args, 'repository'); + const tag = requireArg(args, 'tag'); + const release = await getReleaseByTag({ repository, serverUrl, tag, token }); + + if (!release) { + throw new Error(`Release ${tag} was not found.`); + } + + const publishedRelease = release.draft + ? await updateRelease({ + body: release.body || '', + draft: false, + id: release.id, + name: release.name || release.tag_name, + prerelease: Boolean(release.prerelease), + repository, + serverUrl, + tag: release.tag_name, + target: release.target_commitish, + token + }) + : release; + + if (args['write-output']) { + writeGitHubOutputs({ + release_html_url: publishedRelease.html_url, + release_id: String(publishedRelease.id), + release_tag: publishedRelease.tag_name + }); + } + + console.log(JSON.stringify({ + release_html_url: publishedRelease.html_url, + release_id: publishedRelease.id, + release_tag: publishedRelease.tag_name + }, null, 2)); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const command = args._[0]; + + switch (command) { + case 'ensure-draft': + await ensureDraftReleaseCommand(args); + return; + case 'download-latest-manifest': + await downloadLatestManifestCommand(args); + return; + case 'upload-built-assets': + await uploadBuiltAssetsCommand(args); + return; + case 'publish': + await publishReleaseCommand(args); + return; + default: + throw new Error(`Unsupported command: ${command || '(none)'}`); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + + console.error(`[gitea-release] ${message}`); + process.exitCode = 1; +}); diff --git a/tools/package-server-executable.js b/tools/package-server-executable.js new file mode 100644 index 0000000..8724881 --- /dev/null +++ b/tools/package-server-executable.js @@ -0,0 +1,111 @@ +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const rootDir = path.resolve(__dirname, '..'); +const serverPackageJsonPath = path.join(rootDir, 'server', 'package.json'); +const serverEntryPointPath = path.join(rootDir, 'server', 'dist', 'index.js'); +const serverSqlJsBinaryPath = path.join(rootDir, 'server', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'); +const distServerDir = path.join(rootDir, 'dist-server'); + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (!token.startsWith('--')) { + continue; + } + + const key = token.slice(2); + const nextToken = argv[index + 1]; + + if (!nextToken || nextToken.startsWith('--')) { + args[key] = true; + continue; + } + + args[key] = nextToken; + index += 1; + } + + return args; +} + +function resolvePkgBinPath() { + const pkgPackageJsonPath = require.resolve('pkg/package.json'); + + return path.join(path.dirname(pkgPackageJsonPath), 'lib-es5', 'bin.js'); +} + +function copySqlJsBinary() { + if (!fs.existsSync(serverSqlJsBinaryPath)) { + throw new Error(`sql.js wasm binary not found at ${serverSqlJsBinaryPath}. Run npm install --prefix server first.`); + } + + fs.copyFileSync( + serverSqlJsBinaryPath, + path.join(distServerDir, 'sql-wasm.wasm') + ); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const target = typeof args.target === 'string' && args.target.trim().length > 0 + ? args.target.trim() + : null; + const outputName = typeof args.output === 'string' && args.output.trim().length > 0 + ? args.output.trim() + : null; + + if (!target) { + throw new Error('A pkg target is required. Pass it with --target.'); + } + + if (!outputName) { + throw new Error('An output file name is required. Pass it with --output.'); + } + + if (!fs.existsSync(serverEntryPointPath)) { + throw new Error(`Server build output not found at ${serverEntryPointPath}. Run npm run server:build first.`); + } + + fs.mkdirSync(distServerDir, { recursive: true }); + + const outputPath = path.join(distServerDir, outputName); + + if (fs.existsSync(outputPath)) { + fs.rmSync(outputPath, { force: true }); + } + + const pkgBinPath = resolvePkgBinPath(); + const result = spawnSync(process.execPath, [ + pkgBinPath, + serverPackageJsonPath, + '--targets', + target, + '--output', + outputPath + ], { + cwd: rootDir, + stdio: 'inherit' + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + + copySqlJsBinary(); + + console.log(`[server-bundle] Wrote ${outputPath}`); +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`[server-bundle] ${message}`); + process.exitCode = 1; +} diff --git a/tools/resolve-release-version.js b/tools/resolve-release-version.js new file mode 100644 index 0000000..e0d5c3d --- /dev/null +++ b/tools/resolve-release-version.js @@ -0,0 +1,136 @@ +const fs = require('fs'); +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const packageJsonPath = path.join(rootDir, 'package.json'); + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (!token.startsWith('--')) { + continue; + } + + const key = token.slice(2); + const nextToken = argv[index + 1]; + + if (!nextToken || nextToken.startsWith('--')) { + args[key] = true; + continue; + } + + args[key] = nextToken; + index += 1; + } + + return args; +} + +function normalizeVersion(rawValue) { + if (typeof rawValue !== 'string') { + return null; + } + + const trimmedValue = rawValue.trim(); + + if (!trimmedValue) { + return null; + } + + return trimmedValue.replace(/^v/i, '').split('+')[0] || null; +} + +function parseVersion(rawValue) { + const normalized = normalizeVersion(rawValue); + + if (!normalized) { + return null; + } + + const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/); + + if (!match) { + return null; + } + + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + patch: Number.parseInt(match[3], 10) + }; +} + +function resolveBaseVersion() { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const parsedVersion = parseVersion(packageJson.version); + + if (!parsedVersion) { + throw new Error(`package.json version must be a full semver string. Received: ${packageJson.version}`); + } + + return parsedVersion; +} + +function resolveReleaseVersion(args) { + const explicitVersion = normalizeVersion(args.version || process.env.RELEASE_VERSION); + + if (explicitVersion) { + return explicitVersion; + } + + const baseVersion = resolveBaseVersion(); + const rawRunNumber = args['run-number'] || process.env.GITHUB_RUN_NUMBER || process.env.GITEA_RUN_NUMBER; + const parsedRunNumber = Number.parseInt(String(rawRunNumber || '').trim(), 10); + + if (Number.isFinite(parsedRunNumber) && parsedRunNumber > 0) { + return `${baseVersion.major}.${baseVersion.minor}.${baseVersion.patch + parsedRunNumber}`; + } + + return `${baseVersion.major}.${baseVersion.minor}.${baseVersion.patch}`; +} + +function appendOutputLine(outputPath, key, value) { + fs.appendFileSync(outputPath, `${key}=${value}\n`, 'utf8'); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const version = resolveReleaseVersion(args); + const tag = typeof args.tag === 'string' && args.tag.trim().length > 0 + ? args.tag.trim() + : `v${version}`; + const result = { + release_name: `MetoYou ${version}`, + release_tag: tag, + release_version: version + }; + + if (args['write-output']) { + if (!process.env.GITHUB_OUTPUT) { + throw new Error('GITHUB_OUTPUT is not set.'); + } + + appendOutputLine(process.env.GITHUB_OUTPUT, 'release_version', result.release_version); + appendOutputLine(process.env.GITHUB_OUTPUT, 'release_tag', result.release_tag); + appendOutputLine(process.env.GITHUB_OUTPUT, 'release_name', result.release_name); + } + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(result.release_version); +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`[release-version] ${message}`); + process.exitCode = 1; +} diff --git a/tools/set-release-version.js b/tools/set-release-version.js new file mode 100644 index 0000000..f1200a3 --- /dev/null +++ b/tools/set-release-version.js @@ -0,0 +1,83 @@ +const fs = require('fs'); +const path = require('path'); +const { syncServerBuildVersion } = require('./sync-server-build-version.js'); + +const rootDir = path.resolve(__dirname, '..'); +const packageJsonPaths = [ + path.join(rootDir, 'package.json'), + path.join(rootDir, 'server', 'package.json') +]; + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + + if (!token.startsWith('--')) { + continue; + } + + const key = token.slice(2); + const nextToken = argv[index + 1]; + + if (!nextToken || nextToken.startsWith('--')) { + args[key] = true; + continue; + } + + args[key] = nextToken; + index += 1; + } + + return args; +} + +function normalizeVersion(rawValue) { + if (typeof rawValue !== 'string') { + return null; + } + + const trimmedValue = rawValue.trim(); + + if (!trimmedValue) { + return null; + } + + const normalized = trimmedValue.replace(/^v/i, '').split('+')[0] || null; + + return normalized && /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.test(normalized) + ? normalized + : null; +} + +function updatePackageJson(filePath, version) { + const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8')); + packageJson.version = version; + fs.writeFileSync(filePath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const version = normalizeVersion(args.version || process.env.RELEASE_VERSION); + + if (!version) { + throw new Error('A valid semver release version is required. Pass it with --version.'); + } + + for (const packageJsonPath of packageJsonPaths) { + updatePackageJson(packageJsonPath, version); + } + + syncServerBuildVersion(version); + console.log(`[release-version] Updated package versions to ${version}`); +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`[release-version] ${message}`); + process.exitCode = 1; +} diff --git a/tools/sync-server-build-version.js b/tools/sync-server-build-version.js new file mode 100644 index 0000000..72ed6a2 --- /dev/null +++ b/tools/sync-server-build-version.js @@ -0,0 +1,49 @@ +const fs = require('fs'); +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const serverPackageJsonPath = path.join(rootDir, 'server', 'package.json'); +const outputFilePath = path.join(rootDir, 'server', 'src', 'generated', 'build-version.ts'); + +function readServerVersion() { + const packageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, 'utf8')); + + if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) { + return packageJson.version.trim(); + } + + return '0.0.0'; +} + +function syncServerBuildVersion(version = readServerVersion()) { + const nextContents = `export const SERVER_BUILD_VERSION = ${JSON.stringify(version)};\n`; + + fs.mkdirSync(path.dirname(outputFilePath), { recursive: true }); + + if (!fs.existsSync(outputFilePath) || fs.readFileSync(outputFilePath, 'utf8') !== nextContents) { + fs.writeFileSync(outputFilePath, nextContents, 'utf8'); + } + + return version; +} + +function main() { + const version = syncServerBuildVersion(); + + console.log(`[server-build-version] Synced ${outputFilePath} -> ${version}`); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`[server-build-version] ${message}`); + process.exitCode = 1; + } +} + +module.exports = { + syncServerBuildVersion +};