SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,78 @@
|
||||
// Isolated retained-heap measurements; no QQ session, disk logs or live requests.
|
||||
// node --expose-gc tools/bench-common-memory.mjs logs|colored-logs|cache [source-directory]
|
||||
// The optional directory contains log-sanitize.ts and single-flight-cache.ts,
|
||||
// allowing the same workload to run against sources from a baseline revision.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setImmediate, setTimeout } from 'node:timers/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
if (!global.gc) {
|
||||
throw new Error('Run with node --expose-gc');
|
||||
}
|
||||
|
||||
const mode = process.argv[2] ?? 'logs';
|
||||
const source = (name) =>
|
||||
process.argv[3]
|
||||
? pathToFileURL(resolve(process.argv[3], name))
|
||||
: new URL(`../packages/common/src/${name}`, import.meta.url);
|
||||
|
||||
async function heapUsed() {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
global.gc();
|
||||
return process.memoryUsage().heapUsed;
|
||||
}
|
||||
|
||||
const mib = (bytes) => Number((bytes / 1024 / 1024).toFixed(2));
|
||||
|
||||
if (mode === 'logs' || mode === 'colored-logs') {
|
||||
const { sanitizeLogLine } = await import(source('log-sanitize.ts'));
|
||||
const before = await heapUsed();
|
||||
const records = [];
|
||||
const started = performance.now();
|
||||
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const body = `${i}: ${'0123456789abcdef'.repeat(512)}`;
|
||||
const line = mode === 'colored-logs' ? `\x1b[32m${body}\x1b[0m` : body;
|
||||
records.push(sanitizeLogLine(line));
|
||||
}
|
||||
|
||||
const elapsedMs = Number((performance.now() - started).toFixed(1));
|
||||
const retainedHeapMiB = mib((await heapUsed()) - before);
|
||||
console.log(JSON.stringify({ mode, records: records.length, retainedHeapMiB, elapsedMs }));
|
||||
} else if (mode === 'cache') {
|
||||
const { createSingleFlightCache } = await import(source('single-flight-cache.ts'));
|
||||
const ttlMs = 2000;
|
||||
const cache = createSingleFlightCache({
|
||||
ttlMs,
|
||||
load: async (key) =>
|
||||
Array.from({ length: 1000 }, (_, i) => ({
|
||||
uin: key * 1000 + i,
|
||||
nickname: `member-${key}-${i}`,
|
||||
})),
|
||||
});
|
||||
const before = await heapUsed();
|
||||
|
||||
for (let key = 0; key < 500; key += 1) {
|
||||
await cache.get(key);
|
||||
}
|
||||
|
||||
const populatedHeapMiB = mib((await heapUsed()) - before);
|
||||
await setTimeout(ttlMs + 100);
|
||||
const idleHeapMiB = mib((await heapUsed()) - before);
|
||||
// Keep the cache itself live through both samples, as it is in ContactsApi.
|
||||
cache.clear();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
rosters: 500,
|
||||
membersPerRoster: 1000,
|
||||
ttlMs,
|
||||
populatedHeapMiB,
|
||||
idleHeapMiB,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unknown workload: ${mode}`);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync the `version` field across the monorepo root and every workspace package.
|
||||
// Usage: pnpm bump [version]; without an argument, sync the root version.
|
||||
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseVersion } from '../packages/common/src/semver.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const nextVersion = process.argv[2] ?? JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')).version;
|
||||
if (!parseVersion(nextVersion)) {
|
||||
console.error(`Invalid semver: "${nextVersion}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function updatePkg(file) {
|
||||
const raw = await readFile(file, 'utf-8');
|
||||
const pkg = JSON.parse(raw);
|
||||
const prev = pkg.version;
|
||||
if (prev === nextVersion) {
|
||||
console.log(` = ${path.relative(repoRoot, file)} (already ${nextVersion})`);
|
||||
return;
|
||||
}
|
||||
pkg.version = nextVersion;
|
||||
// Preserve trailing newline if the original file had one.
|
||||
const trailing = raw.endsWith('\n') ? '\n' : '';
|
||||
await writeFile(file, JSON.stringify(pkg, null, 2) + trailing);
|
||||
console.log(` ✓ ${path.relative(repoRoot, file)}: ${prev} → ${nextVersion}`);
|
||||
}
|
||||
|
||||
const files = [path.join(repoRoot, 'package.json')];
|
||||
const packagesDir = path.join(repoRoot, 'packages');
|
||||
for (const entry of await readdir(packagesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const pkgFile = path.join(packagesDir, entry.name, 'package.json');
|
||||
if (existsSync(pkgFile)) files.push(pkgFile);
|
||||
}
|
||||
|
||||
console.log(`Bumping ${files.length} package.json files to ${nextVersion}:`);
|
||||
for (const file of files) await updatePkg(file);
|
||||
console.log('Done.');
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
// Same catalog gate as the CI `test` job. Keep the git invocation and
|
||||
// failure text in lockstep with `.github/workflows/ci.yml`.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function runGit(args, { inherit = false } = {}) {
|
||||
return spawnSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf-8',
|
||||
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
const status = runGit([
|
||||
'status',
|
||||
'--porcelain=v1',
|
||||
'--untracked-files=all',
|
||||
'--',
|
||||
'packages/mcp/src/generated',
|
||||
]);
|
||||
if (status.status !== 0) {
|
||||
process.stderr.write(status.stderr || 'git status failed\n');
|
||||
process.exit(status.status === null ? 1 : status.status);
|
||||
}
|
||||
|
||||
const catalogStatus = (status.stdout || '').trim();
|
||||
if (!catalogStatus) process.exit(0);
|
||||
|
||||
runGit(['diff', '--', 'packages/mcp/src/generated'], { inherit: true });
|
||||
process.stdout.write(`${catalogStatus}\n`);
|
||||
process.stderr.write(
|
||||
'::error::Generated action catalog is stale. Run `pnpm --filter @snowluma/mcp gen` and commit packages/mcp/src/generated/.\n',
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? 'dist');
|
||||
const entryPoint = path.join(root, 'index.mjs');
|
||||
|
||||
if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
||||
throw new Error(`release directory does not exist: ${root}`);
|
||||
}
|
||||
if (!fs.statSync(entryPoint, { throwIfNoEntry: false })?.isFile()) {
|
||||
throw new Error(`release entry point must be at the archive root: ${entryPoint}`);
|
||||
}
|
||||
|
||||
const release = JSON.parse(fs.readFileSync(path.join(root, 'release.json'), 'utf8'));
|
||||
const runtimePackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
if (release.version !== runtimePackage.version) {
|
||||
throw new Error('release version does not match its runtime package');
|
||||
}
|
||||
const actualEntries = fs.readdirSync(root).filter(name => !['node', 'node.exe'].includes(name));
|
||||
if (JSON.stringify([...release.entries].sort()) !== JSON.stringify(actualEntries.sort())) {
|
||||
throw new Error('release file manifest does not match the packaged files');
|
||||
}
|
||||
|
||||
const sqliteRoot = path.join(root, 'native', 'sqlite');
|
||||
const sqlite = JSON.parse(fs.readFileSync(path.join(sqliteRoot, 'manifest.json'), 'utf8'));
|
||||
if (release.target !== sqlite.target) {
|
||||
throw new Error('release target does not match its native artifacts');
|
||||
}
|
||||
if (sqlite.package !== '@appthreat/sqlite3' || sqlite.version !== '9.1.0' || sqlite.nativeInterface !== 1
|
||||
|| !/^(win32|linux|darwin)-(x64|arm64)$/.test(sqlite.target)) {
|
||||
throw new Error('release has an invalid SQLite native manifest');
|
||||
}
|
||||
const expectedSqliteFiles = (sqlite.target.startsWith('linux-')
|
||||
? ['@appthreat+sqlite3.glibc.node', '@appthreat+sqlite3.musl.node']
|
||||
: ['@appthreat+sqlite3.node']).map(file => `prebuilds/${sqlite.target}/${file}`);
|
||||
if (JSON.stringify(sqlite.files) !== JSON.stringify(expectedSqliteFiles)) {
|
||||
throw new Error('release SQLite manifest does not contain the required platform/libc variants');
|
||||
}
|
||||
for (const file of [...expectedSqliteFiles, 'LICENSE.sqlite3']) {
|
||||
if (!fs.statSync(path.join(sqliteRoot, file), { throwIfNoEntry: false })?.isFile()) {
|
||||
throw new Error(`release is missing SQLite runtime input: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
const forbidden = [];
|
||||
const pending = [root];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
if (!directory) continue;
|
||||
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (/snowluma/i.test(entry.name)) {
|
||||
forbidden.push(path.relative(root, absolute));
|
||||
}
|
||||
pending.push(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
if (forbidden.length > 0) {
|
||||
throw new Error(`release contains forbidden directory names:\n${forbidden.join('\n')}`);
|
||||
}
|
||||
|
||||
console.log(`Release layout verified: ${root}`);
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
// Local replica of `.github/workflows/ci.yml`.
|
||||
// Every command and step title below must stay identical to that workflow.
|
||||
// CI jobs run these in parallel after a frozen install; this script runs the
|
||||
// same commands once each, in that job's internal order, and still executes
|
||||
// later steps if an earlier one fails — same as four GitHub jobs reporting
|
||||
// independently.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const checks = [
|
||||
['Install dependencies', 'pnpm install --frozen-lockfile'],
|
||||
['Run all package tests', 'pnpm test'],
|
||||
['Verify generated action catalog is up to date', 'node tools/check-action-catalog.mjs'],
|
||||
['Type check all packages', 'pnpm typecheck'],
|
||||
['Build release inputs', 'pnpm run build:all'],
|
||||
['Lint managed source files', 'pnpm lint'],
|
||||
];
|
||||
|
||||
const failed = [];
|
||||
|
||||
for (const [title, command] of checks) {
|
||||
console.log(`\n▸ ${title}\n$ ${command}\n`);
|
||||
const result = spawnSync(command, {
|
||||
cwd: repoRoot,
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
const code = result.status === 0 ? 0 : (result.status ?? 1);
|
||||
if (code !== 0) {
|
||||
failed.push({ title, command, code });
|
||||
console.error(`\n✗ ${title} failed (exit ${code})`);
|
||||
} else {
|
||||
console.log(`\n✓ ${title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.error('\nCI suite failed:');
|
||||
for (const item of failed) {
|
||||
console.error(` - ${item.title}: \`${item.command}\` (exit ${item.code})`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nCI suite passed (same commands as .github/workflows/ci.yml).');
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const packagesRoot = path.join(repoRoot, 'packages');
|
||||
const coverageRoot = path.join(repoRoot, 'coverage');
|
||||
|
||||
function resolveVitestCli(manifestPath) {
|
||||
const require = createRequire(manifestPath);
|
||||
let directory = path.dirname(require.resolve('vitest'));
|
||||
|
||||
while (true) {
|
||||
const packagePath = path.join(directory, 'package.json');
|
||||
if (existsSync(packagePath)) {
|
||||
const manifest = JSON.parse(readFileSync(packagePath, 'utf8'));
|
||||
if (manifest.name === 'vitest') {
|
||||
const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.vitest;
|
||||
if (typeof bin !== 'string') throw new Error('Vitest does not declare a CLI binary');
|
||||
return path.resolve(directory, bin);
|
||||
}
|
||||
}
|
||||
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) throw new Error(`Could not resolve Vitest CLI from ${manifestPath}`);
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaces = readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const directory = path.join(packagesRoot, entry.name);
|
||||
const manifestPath = path.join(directory, 'package.json');
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
if (typeof manifest.scripts?.test !== 'string') return null;
|
||||
if (typeof manifest.devDependencies?.vitest !== 'string') {
|
||||
throw new Error(`${manifest.name ?? entry.name} declares tests without a package-local Vitest dependency`);
|
||||
}
|
||||
return {
|
||||
directory,
|
||||
reportDirectory: path.join(coverageRoot, entry.name),
|
||||
name: manifest.name ?? entry.name,
|
||||
vitestCli: resolveVitestCli(manifestPath),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
throw new Error('No regular Vitest workspaces found');
|
||||
}
|
||||
|
||||
rmSync(coverageRoot, { recursive: true, force: true });
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
console.log(`\n[coverage] ${workspace.name}`);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
workspace.vitestCli,
|
||||
'run',
|
||||
'--exclude=tests/native/**',
|
||||
'--coverage',
|
||||
'--coverage.provider=v8',
|
||||
`--coverage.reportsDirectory=${workspace.reportDirectory}`,
|
||||
'--coverage.reporter=text-summary',
|
||||
'--coverage.reporter=json-summary',
|
||||
'--coverage.reporter=html',
|
||||
'--coverage.include=src/**/*.{js,mjs,cjs,ts,tsx}',
|
||||
'--coverage.exclude=src/**/generated/**',
|
||||
'--coverage.exclude=src/**/fixtures/**',
|
||||
'--coverage.exclude=src/**/*.d.ts',
|
||||
'--coverage.exclude=src/**/native/**',
|
||||
'--coverage.exclude=dist/**',
|
||||
'--coverage.exclude=**/native/**',
|
||||
],
|
||||
{
|
||||
cwd: workspace.directory,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
console.log(`\n[coverage] Reports written to ${path.relative(repoRoot, coverageRoot)}/`);
|
||||
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env node
|
||||
// One-shot release driver.
|
||||
//
|
||||
// pnpm release <version> # full pipeline: dev → Promote → main → tag
|
||||
// pnpm release <version> --no-wait # bump+push dev only, do main+tag manually
|
||||
// pnpm release <version> --dry-run # print plan, change nothing
|
||||
//
|
||||
// What it does, in order:
|
||||
// 1. Sanity-check: on `dev`, clean tree. Must not be behind origin/dev.
|
||||
// Already being this version's `chore(release):` commit is a resume
|
||||
// (after a dropped push, a failed fetch-while-waiting, or a failed
|
||||
// tag push). Re-run `pnpm release <version>` as many times as needed.
|
||||
// 2. Full CI suite (`node tools/ci-check.mjs`) — the same commands as
|
||||
// `.github/workflows/ci.yml`. Skipped on resume. Fail here instead
|
||||
// of after the bump.
|
||||
// 3. `pnpm bump <version>` (writes every package.json).
|
||||
// 4. Commit `chore(release): vX.Y.Z` — that prefix triggers the
|
||||
// Promote workflow, which points `main` at the dev tip. Skipped
|
||||
// when HEAD is already that commit.
|
||||
// 5. Push to origin/dev (retries transient HTTPS / TLS failures).
|
||||
// 6. (Unless `--no-wait`) poll until `origin/main` equals the dev
|
||||
// tip we just pushed, then fetch and fast-forward `main` to
|
||||
// `origin/main`, create `vX.Y.Z` tag, push it — that triggers
|
||||
// `release.yml`. Fetch / tag push also retry.
|
||||
// 7. Switch back to `dev` so the next `git log` view is back where
|
||||
// you were.
|
||||
//
|
||||
// Re-run the same `pnpm release <version>` after any mid-pipeline
|
||||
// failure (push, wait, tag). Do not amend the release commit.
|
||||
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseVersion } from '../packages/common/src/semver.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
// ───────────── arg parsing ─────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flags = new Set(args.filter(a => a.startsWith('--')));
|
||||
const positional = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
const version = positional[0];
|
||||
const dryRun = flags.has('--dry-run');
|
||||
const noWait = flags.has('--no-wait');
|
||||
|
||||
if (!version) {
|
||||
console.error('Usage: pnpm release <version> [--no-wait] [--dry-run]');
|
||||
console.error('Example: pnpm release 1.8.0');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!parseVersion(version)) {
|
||||
console.error(`Invalid semver: "${version}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tag = `v${version}`;
|
||||
const releaseCommit = `chore(release): ${tag}`;
|
||||
|
||||
// ───────────── shell helpers ─────────────
|
||||
|
||||
function sh(cmd, { capture = false, allowFail = false } = {}) {
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] $ ${cmd}`);
|
||||
return '';
|
||||
}
|
||||
if (capture) {
|
||||
return execSync(cmd, { cwd: repoRoot, encoding: 'utf-8' }).trim();
|
||||
}
|
||||
const result = spawnSync(cmd, { cwd: repoRoot, shell: true, stdio: 'inherit' });
|
||||
if (result.status !== 0 && !allowFail) {
|
||||
throw new Error(`Command failed: ${cmd}`);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function sleepSync(ms) {
|
||||
execSync(`sleep ${Math.max(1, Math.ceil(ms / 1000))}`);
|
||||
}
|
||||
|
||||
function gitOk(cmd) {
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] $ ${cmd}`);
|
||||
return true;
|
||||
}
|
||||
const result = spawnSync(cmd, { cwd: repoRoot, shell: true, stdio: 'inherit' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/** Retry git fetch / push. TLS drops after a successful commit used to abort
|
||||
* the whole pipeline; re-run `pnpm release <version>` to resume. */
|
||||
function shRetry(cmd, { attempts = 8 } = {}) {
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] $ ${cmd}`);
|
||||
return '';
|
||||
}
|
||||
let delayMs = 2000;
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
if (i > 1) info(`retry ${i}/${attempts}: ${cmd}`);
|
||||
if (gitOk(cmd)) return '';
|
||||
if (i === attempts) {
|
||||
throw new Error(
|
||||
`Command failed after ${attempts} attempts: ${cmd}\n` +
|
||||
`Re-run: pnpm release ${version}`,
|
||||
);
|
||||
}
|
||||
warn(`failed, retrying in ${delayMs / 1000}s`);
|
||||
sleepSync(delayMs);
|
||||
delayMs = Math.min(delayMs * 2, 30_000);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function headSubject() {
|
||||
return shCapture('git log -1 --format=%s');
|
||||
}
|
||||
|
||||
function packageVersion() {
|
||||
return JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf-8')).version;
|
||||
}
|
||||
|
||||
function headIsThisRelease() {
|
||||
return headSubject() === releaseCommit && packageVersion() === version;
|
||||
}
|
||||
|
||||
/** The bump commit for this version, even if later tooling commits sit on top. */
|
||||
function thisReleaseSha() {
|
||||
try {
|
||||
const sha = shCapture(`git log -1 --format=%H --grep=${JSON.stringify(`^${releaseCommit}$`)}`);
|
||||
return sha || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shCapture(cmd) {
|
||||
return execSync(cmd, { cwd: repoRoot, encoding: 'utf-8' }).trim();
|
||||
}
|
||||
|
||||
function which(bin) {
|
||||
try {
|
||||
execSync(`command -v ${bin}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function ok(msg) { console.log(`\x1b[32m✓\x1b[0m ${msg}`); }
|
||||
function info(msg) { console.log(`\x1b[36mi\x1b[0m ${msg}`); }
|
||||
function warn(msg) { console.log(`\x1b[33m!\x1b[0m ${msg}`); }
|
||||
|
||||
function printManualFinish() {
|
||||
info(`Re-run the same command to resume: pnpm release ${version}`);
|
||||
}
|
||||
|
||||
function packageVersionAt(sha) {
|
||||
return JSON.parse(shCapture(`git show ${sha}:package.json`)).version;
|
||||
}
|
||||
|
||||
// ───────────── step 1: preflight ─────────────
|
||||
|
||||
function preflightFail(msg) {
|
||||
if (dryRun) { warn(`[dry-run] would have aborted: ${msg}`); return; }
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function preflight() {
|
||||
const branch = shCapture('git rev-parse --abbrev-ref HEAD');
|
||||
if (branch !== 'dev') {
|
||||
preflightFail(`Must be on \`dev\` to start a release. Current: \`${branch}\``);
|
||||
}
|
||||
|
||||
const dirty = shCapture('git status --porcelain');
|
||||
if (dirty) {
|
||||
preflightFail(`Working tree is not clean. Commit or stash first.\n${dirty}`);
|
||||
}
|
||||
|
||||
// Make sure we're not behind origin/dev so the bump commit doesn't
|
||||
// collide with someone else's push. Already being this release commit
|
||||
// (in sync, or ahead by the unpushed bump) is a resume.
|
||||
shRetry('git fetch origin refs/heads/dev:refs/remotes/origin/dev');
|
||||
const local = shCapture('git rev-parse dev');
|
||||
const remote = shCapture('git rev-parse origin/dev');
|
||||
if (local !== remote) {
|
||||
const ahead = shCapture('git rev-list --count origin/dev..dev');
|
||||
const behind = shCapture('git rev-list --count dev..origin/dev');
|
||||
if (behind !== '0') {
|
||||
preflightFail(`dev is behind origin/dev (behind=${behind}). Pull first.`);
|
||||
}
|
||||
if (headIsThisRelease()) {
|
||||
warn(`dev is ahead of origin/dev by ${ahead}; HEAD is ${releaseCommit}, will resume`);
|
||||
} else {
|
||||
preflightFail(`dev is not in sync with origin/dev (ahead=${ahead}, behind=${behind}). Pull / push first.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) ok(`on dev, clean, ready to release ${tag}`);
|
||||
}
|
||||
|
||||
function runCiSuite() {
|
||||
if (!dryRun && headIsThisRelease()) {
|
||||
warn(`HEAD is already ${releaseCommit}; skipping CI suite (resume)`);
|
||||
return;
|
||||
}
|
||||
info('running the full CI suite (same commands as .github/workflows/ci.yml)');
|
||||
sh('node tools/ci-check.mjs');
|
||||
if (!dryRun) ok('CI suite passed');
|
||||
}
|
||||
|
||||
// ───────────── step 2-4: bump + commit + push dev ─────────────
|
||||
|
||||
function bumpAndPushDev() {
|
||||
if (!dryRun && headIsThisRelease()) {
|
||||
warn(`HEAD is already ${releaseCommit}; skipping bump and commit`);
|
||||
} else {
|
||||
sh(`node tools/bump-version.mjs ${version}`);
|
||||
ok(`bumped all package.json files to ${version}`);
|
||||
|
||||
if (dryRun) {
|
||||
info(`would commit "${releaseCommit}" and push origin dev`);
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = shCapture('git status --porcelain');
|
||||
if (!changed) {
|
||||
warn(`no version changes — package.json already at ${version}, skipping commit`);
|
||||
} else {
|
||||
sh('git add package.json packages/*/package.json');
|
||||
sh(`git commit -m "${releaseCommit}"`);
|
||||
ok(`committed ${releaseCommit}`);
|
||||
}
|
||||
}
|
||||
|
||||
const local = shCapture('git rev-parse dev');
|
||||
const remote = shCapture('git rev-parse origin/dev');
|
||||
if (!dryRun && local === remote) {
|
||||
info('origin/dev already has this tip; skipping push');
|
||||
return;
|
||||
}
|
||||
shRetry('git push origin dev');
|
||||
ok(`pushed to origin/dev — Promote workflow should kick off shortly`);
|
||||
}
|
||||
|
||||
// ───────────── step 5: wait for main to match dev, then tag ─────────────
|
||||
|
||||
function promoteRunFailed(expectedSha) {
|
||||
if (!which('gh')) return null;
|
||||
try {
|
||||
const list = shCapture('gh run list --workflow=promote-dev-to-main.yml --limit 8 --json conclusion,headSha,url,status');
|
||||
const runs = JSON.parse(list);
|
||||
return runs.find((run) => run.headSha === expectedSha && run.conclusion === 'failure') ?? null;
|
||||
} catch (error) {
|
||||
warn(`gh run list failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function localTagSha() {
|
||||
const result = spawnSync('git', ['rev-parse', '-q', '--verify', `${tag}^{commit}`], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (result.status !== 0) return null;
|
||||
return (result.stdout || '').trim();
|
||||
}
|
||||
|
||||
function remoteTagSha() {
|
||||
try {
|
||||
const out = shCapture(`git ls-remote --tags origin refs/tags/${tag} refs/tags/${tag}^{}`);
|
||||
if (!out) return null;
|
||||
const lines = out.split('\n').filter(Boolean);
|
||||
const peeled = lines.find((line) => line.includes('^{}'));
|
||||
return (peeled || lines[0]).split(/\s+/)[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function originMainSha() {
|
||||
try {
|
||||
return shCapture('git rev-parse refs/remotes/origin/main');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTag(expectedSha) {
|
||||
const local = localTagSha();
|
||||
if (local && local !== expectedSha) {
|
||||
throw new Error(
|
||||
`Local tag ${tag} points at ${local.slice(0, 12)}, expected ${expectedSha.slice(0, 12)}. ` +
|
||||
`Delete it first if you meant to retarget: git tag -d ${tag}`,
|
||||
);
|
||||
}
|
||||
if (!local) sh(`git tag ${tag} ${expectedSha}`);
|
||||
else info(`tag ${tag} already points at ${expectedSha.slice(0, 12)}`);
|
||||
|
||||
const remote = remoteTagSha();
|
||||
if (remote === expectedSha) {
|
||||
info(`origin already has ${tag} at ${expectedSha.slice(0, 12)}`);
|
||||
return;
|
||||
}
|
||||
if (remote && remote !== expectedSha) {
|
||||
throw new Error(
|
||||
`origin ${tag} points at ${remote.slice(0, 12)}, expected ${expectedSha.slice(0, 12)}. ` +
|
||||
`Not moving a live tag.`,
|
||||
);
|
||||
}
|
||||
shRetry(`git push origin ${tag}`);
|
||||
ok(`tagged and pushed ${tag} — release workflow will pick it up`);
|
||||
}
|
||||
|
||||
async function waitUntilMainIs(expectedSha) {
|
||||
info(`waiting until origin/main equals dev @ ${expectedSha.slice(0, 12)}...`);
|
||||
info('press Ctrl-C if you want to stop; re-run the same command to resume.');
|
||||
|
||||
const startedAt = Date.now();
|
||||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const POLL_MS = 15 * 1000;
|
||||
|
||||
while (Date.now() - startedAt < TIMEOUT_MS) {
|
||||
if (gitOk('git fetch origin refs/heads/main:refs/remotes/origin/main')) {
|
||||
const mainSha = originMainSha();
|
||||
if (mainSha === expectedSha) {
|
||||
ok(`origin/main is dev @ ${expectedSha.slice(0, 12)}`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
warn('fetch origin/main failed; will keep waiting (re-run this command if you abort)');
|
||||
}
|
||||
|
||||
const failed = promoteRunFailed(expectedSha);
|
||||
if (failed) {
|
||||
warn(`a Promote run failed for ${expectedSha.slice(0, 12)}: ${failed.url}`);
|
||||
warn('still waiting in case a later Promote succeeds');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
}
|
||||
|
||||
warn('30-minute timeout reached without origin/main matching dev.');
|
||||
printManualFinish();
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitAndTag() {
|
||||
const expectedSha = dryRun
|
||||
? 'dry-run'
|
||||
: (thisReleaseSha() || shCapture('git rev-parse dev'));
|
||||
|
||||
if (dryRun) {
|
||||
info(`would wait until origin/main equals dev @ ${expectedSha.slice(0, 12)}`);
|
||||
info(`would tag ${tag} and push origin ${tag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyTagged = remoteTagSha() === expectedSha || localTagSha() === expectedSha;
|
||||
if (alreadyTagged && remoteTagSha() === expectedSha) {
|
||||
ok(`origin already has ${tag} at ${expectedSha.slice(0, 12)}; nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
gitOk('git fetch origin refs/heads/main:refs/remotes/origin/main');
|
||||
if (originMainSha() !== expectedSha) {
|
||||
const ready = await waitUntilMainIs(expectedSha);
|
||||
if (!ready) return;
|
||||
} else {
|
||||
ok(`origin/main already equals the release commit @ ${expectedSha.slice(0, 12)}`);
|
||||
}
|
||||
|
||||
const taggedVersion = packageVersionAt(expectedSha);
|
||||
if (taggedVersion !== version) {
|
||||
warn(`release commit package.json is ${taggedVersion} but you asked for ${version}.`);
|
||||
warn('Tagging anyway, but the release artifact name may not match.');
|
||||
}
|
||||
|
||||
ensureTag(expectedSha);
|
||||
}
|
||||
|
||||
// ───────────── go ─────────────
|
||||
|
||||
(async () => {
|
||||
console.log(`Release pipeline → ${tag}${dryRun ? ' (dry-run)' : ''}\n`);
|
||||
preflight();
|
||||
runCiSuite();
|
||||
bumpAndPushDev();
|
||||
|
||||
if (noWait) {
|
||||
info('--no-wait set; not waiting for origin/main to match dev.');
|
||||
printManualFinish();
|
||||
return;
|
||||
}
|
||||
|
||||
await waitAndTag();
|
||||
})().catch(err => {
|
||||
console.error(err.stack || err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
print("Missing dependency: websockets")
|
||||
print("Install with: pip install websockets")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="OneBot WS API smoke test")
|
||||
parser.add_argument("--url", default="ws://127.0.0.1:3001/", help="OneBot WebSocket URL")
|
||||
parser.add_argument("--token", default="", help="Bearer token if OneBot accessToken is set")
|
||||
parser.add_argument("--group-id", type=int, default=1081372778, help="Target group_id for send_group_msg")
|
||||
parser.add_argument("--message", default="hello from snowluma from refactor", help="Message text to send")
|
||||
parser.add_argument("--timeout", type=float, default=20.0, help="Per-request timeout seconds")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def recv_json(ws: Any, timeout: float) -> Dict[str, Any]:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout)
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", errors="replace")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def call_api(ws: Any, action: str, params: Dict[str, Any], timeout: float) -> Dict[str, Any]:
|
||||
echo = f"py-{action}-{uuid.uuid4().hex[:8]}"
|
||||
payload = {
|
||||
"action": action,
|
||||
"params": params,
|
||||
"echo": echo,
|
||||
}
|
||||
await ws.send(json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
while True:
|
||||
resp = await recv_json(ws, timeout)
|
||||
if resp.get("echo") == echo:
|
||||
return resp
|
||||
|
||||
|
||||
def short_json(data: Dict[str, Any], max_len: int = 240) -> str:
|
||||
text = json.dumps(data, ensure_ascii=False)
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[:max_len] + "..."
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
headers = {}
|
||||
if args.token:
|
||||
headers["Authorization"] = f"Bearer {args.token}"
|
||||
|
||||
try:
|
||||
async with websockets.connect(args.url, additional_headers=headers, max_size=20 * 1024 * 1024) as ws:
|
||||
try:
|
||||
first = await recv_json(ws, 1.0)
|
||||
print("[WS] first event:", short_json(first))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status = await call_api(ws, "get_status", {}, args.timeout)
|
||||
print("[API] get_status:", short_json(status))
|
||||
|
||||
groups = await call_api(ws, "get_group_list", {}, args.timeout)
|
||||
group_count = len(groups.get("data", [])) if isinstance(groups.get("data"), list) else -1
|
||||
print(f"[API] get_group_list: group_count={group_count}")
|
||||
|
||||
send_resp = await call_api(
|
||||
ws,
|
||||
"send_group_msg",
|
||||
{
|
||||
"group_id": args.group_id,
|
||||
"message": args.message,
|
||||
},
|
||||
args.timeout,
|
||||
)
|
||||
print("[API] send_group_msg:", short_json(send_resp))
|
||||
|
||||
ok = (
|
||||
status.get("status") == "ok"
|
||||
and groups.get("status") == "ok"
|
||||
and send_resp.get("status") == "ok"
|
||||
)
|
||||
return 0 if ok else 1
|
||||
except Exception as e:
|
||||
print("[ERROR]", str(e))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
Reference in new issue
Block a user