Add runner ci (test)
This commit is contained in:
30
.gitea/workflows/publish-draft-release.yml
Normal file
30
.gitea/workflows/publish-draft-release.yml
Normal 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 }}"
|
||||
142
.gitea/workflows/release-draft.yml
Normal file
142
.gitea/workflows/release-draft.yml
Normal 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
1
.gitignore
vendored
@@ -51,3 +51,4 @@ Thumbs.db
|
||||
.env
|
||||
.certs/
|
||||
/server/data/variables.json
|
||||
dist-server/*
|
||||
|
||||
24
README.md
24
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
|
||||
|
||||
18
package.json
18
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",
|
||||
|
||||
Binary file not shown.
BIN
server/data/metoyou.sqlite_old
Normal file
BIN
server/data/metoyou.sqlite_old
Normal file
Binary file not shown.
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDataSource } from '../db';
|
||||
import { getDataSource } from '../db/database';
|
||||
import {
|
||||
CommandType,
|
||||
QueryType,
|
||||
|
||||
@@ -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();
|
||||
|
||||
1
server/src/generated/build-version.ts
Normal file
1
server/src/generated/build-version.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SERVER_BUILD_VERSION = "1.0.0";
|
||||
@@ -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');
|
||||
|
||||
|
||||
3
server/src/migrations/index.ts
Normal file
3
server/src/migrations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
|
||||
export const serverMigrations = [InitialSchema1000000000000];
|
||||
@@ -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) => {
|
||||
|
||||
58
server/src/runtime-paths.ts
Normal file
58
server/src/runtime-paths.ts
Normal 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
654
tools/gitea-release.js
Normal 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;
|
||||
});
|
||||
111
tools/package-server-executable.js
Normal file
111
tools/package-server-executable.js
Normal 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;
|
||||
}
|
||||
136
tools/resolve-release-version.js
Normal file
136
tools/resolve-release-version.js
Normal 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;
|
||||
}
|
||||
83
tools/set-release-version.js
Normal file
83
tools/set-release-version.js
Normal 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;
|
||||
}
|
||||
49
tools/sync-server-build-version.js
Normal file
49
tools/sync-server-build-version.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user