Add runner ci (test)

This commit is contained in:
2026-03-10 23:56:53 +01:00
parent c3fbd7d4fe
commit f5bf18b739
21 changed files with 1372 additions and 39 deletions

View File

@@ -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 }}"

View File

@@ -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

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ Thumbs.db
.env
.certs/
/server/data/variables.json
dist-server/*

View File

@@ -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

View File

@@ -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",

Binary file not shown.

Binary file not shown.

View File

@@ -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"
]
}
}

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
import { getDataSource } from '../db';
import { getDataSource } from '../db/database';
import {
CommandType,
QueryType,

View File

@@ -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<void> {
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();

View File

@@ -0,0 +1 @@
export const SERVER_BUILD_VERSION = "1.0.0";

View File

@@ -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<typeof createApp>) {
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');

View File

@@ -0,0 +1,3 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
export const serverMigrations = [InitialSchema1000000000000];

View File

@@ -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) => {

View File

@@ -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');
}

654
tools/gitea-release.js Normal file
View File

@@ -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;
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
};