Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/ci-smoke-preprod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CI Smoke (Preprod)

on:
pull_request:
branches: [main, preprod]
workflow_dispatch:

env:
API_BASE_URL: ${{ secrets.SMOKE_API_BASE_URL }}
SIGNER_MNEMONIC_1: ${{ secrets.SMOKE_SIGNER_MNEMONIC_1 }}
SIGNER_MNEMONIC_2: ${{ secrets.SMOKE_SIGNER_MNEMONIC_2 }}
BOT_MNEMONIC: ${{ secrets.SMOKE_BOT_MNEMONIC }}
BOT_KEY_ID: ${{ secrets.SMOKE_BOT_KEY_ID }}
BOT_SECRET: ${{ secrets.SMOKE_BOT_SECRET }}

jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20

- name: Check secrets configured
id: check-secrets
run: |
if [ -z "$API_BASE_URL" ]; then
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "Smoke test skipped: SMOKE_* secrets not configured."
else
echo "configured=true" >> "$GITHUB_OUTPUT"
fi

- name: Install dependencies
if: steps.check-secrets.outputs.configured == 'true'
run: npm ci

- name: Stage 1 - Bootstrap wallets
if: steps.check-secrets.outputs.configured == 'true'
run: npx tsx scripts/ci-smoke/create-wallets.ts

- name: Stage 2 - Run route chain
if: steps.check-secrets.outputs.configured == 'true'
run: npx tsx scripts/ci-smoke/run-route-chain.ts

- name: Upload smoke artifacts
uses: actions/upload-artifact@v4
if: always() && steps.check-secrets.outputs.configured == 'true'
with:
name: smoke-report
path: ci-artifacts/
retention-days: 14
128 changes: 128 additions & 0 deletions scripts/ci-smoke/create-wallets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env npx tsx
/**
* Stage 1: Bootstrap CI smoke-test wallets.
*
* Reads mnemonic secrets from env vars, derives payment addresses,
* authenticates the bot, creates 3 wallet variants (legacy, hierarchical,
* SDK-based), and writes versioned context JSON for downstream stages.
*
* Required env vars:
* API_BASE_URL - Base URL of the multisig API
* SIGNER_MNEMONIC_1 - Space-separated mnemonic for signer 1
* SIGNER_MNEMONIC_2 - Space-separated mnemonic for signer 2
* BOT_KEY_ID - Bot key ID for authentication
* BOT_SECRET - Bot secret for authentication
* BOT_MNEMONIC - Space-separated mnemonic for bot wallet
*
* Usage:
* npx tsx scripts/ci-smoke/create-wallets.ts
*/
import { derivePaymentAddress, mnemonicFromEnv, requireEnv } from "./lib/keys";
import { writeContext } from "./lib/context";
import { botAuth, createWallet } from "../bot-ref/bot-client";
import type { Context } from "./scenarios/types";

const REQUIRED_ENV_VARS = [
"API_BASE_URL",
"SIGNER_MNEMONIC_1",
"SIGNER_MNEMONIC_2",
"BOT_MNEMONIC",
"BOT_KEY_ID",
"BOT_SECRET",
];

const NETWORK_ID = 0; // preprod

async function main() {
const missing = REQUIRED_ENV_VARS.filter((v) => !process.env[v]);
if (missing.length > 0) {
console.error("Smoke test skipped: missing env vars:", missing.join(", "));
console.error("Configure the SMOKE_* secrets in GitHub repository settings.");
process.exit(0);
}

const baseUrl = requireEnv("API_BASE_URL");

console.log("Deriving signer addresses...");
const signer1Mnemonic = mnemonicFromEnv("SIGNER_MNEMONIC_1");
const signer2Mnemonic = mnemonicFromEnv("SIGNER_MNEMONIC_2");
const botMnemonic = mnemonicFromEnv("BOT_MNEMONIC");

const [signer1Addr, signer2Addr, botAddr] = await Promise.all([
derivePaymentAddress(signer1Mnemonic, NETWORK_ID),
derivePaymentAddress(signer2Mnemonic, NETWORK_ID),
derivePaymentAddress(botMnemonic, NETWORK_ID),
]);

console.log(`Signer 1: ${signer1Addr}`);
console.log(`Signer 2: ${signer2Addr}`);
console.log(`Bot: ${botAddr}`);

console.log("Authenticating bot...");
const botKeyId = requireEnv("BOT_KEY_ID");
const botSecret = requireEnv("BOT_SECRET");
const { token, botId } = await botAuth({
baseUrl,
botKeyId,
secret: botSecret,
paymentAddress: botAddr,
});

console.log("Creating legacy wallet (2 signers, atLeast 1)...");
const legacy = await createWallet(baseUrl, token, {
name: `CI-Legacy-${Date.now()}`,
description: "CI smoke: legacy 2-of-1",
signersAddresses: [signer1Addr, botAddr],
signersDescriptions: ["Signer1", "Bot"],
numRequiredSigners: 1,
scriptType: "atLeast",
network: NETWORK_ID,
});
console.log(` Legacy wallet: ${legacy.walletId} (${legacy.address})`);

console.log("Creating hierarchical wallet (2 signers + stake/DRep, atLeast 2)...");
const hierarchical = await createWallet(baseUrl, token, {
name: `CI-Hierarchical-${Date.now()}`,
description: "CI smoke: hierarchical 2-of-2 with stake+DRep",
signersAddresses: [signer1Addr, signer2Addr],
signersDescriptions: ["Signer1", "Signer2"],
numRequiredSigners: 2,
scriptType: "atLeast",
network: NETWORK_ID,
});
console.log(` Hierarchical wallet: ${hierarchical.walletId} (${hierarchical.address})`);

console.log("Creating SDK wallet (3 signers, atLeast 2)...");
const sdk = await createWallet(baseUrl, token, {
name: `CI-SDK-${Date.now()}`,
description: "CI smoke: SDK 3-of-2",
signersAddresses: [signer1Addr, signer2Addr, botAddr],
signersDescriptions: ["Signer1", "Signer2", "Bot"],
numRequiredSigners: 2,
scriptType: "atLeast",
network: NETWORK_ID,
});
console.log(` SDK wallet: ${sdk.walletId} (${sdk.address})`);

const ctx: Context = {
version: "1",
baseUrl,
botToken: token,
botId,
botAddress: botAddr,
signerAddresses: [signer1Addr, signer2Addr],
wallets: {
legacy: { id: legacy.walletId, address: legacy.address },
hierarchical: { id: hierarchical.walletId, address: hierarchical.address },
sdk: { id: sdk.walletId, address: sdk.address },
},
};

writeContext(ctx);
console.log("\nBootstrap complete. Context written to ci-artifacts/bootstrap-context.json");
}

main().catch((e) => {
console.error("Bootstrap failed:", e);
process.exit(1);
});
27 changes: 27 additions & 0 deletions scripts/ci-smoke/lib/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Read / write the versioned bootstrap context JSON used between CI stages.
*/
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { dirname, join } from "path";
import type { Context } from "../scenarios/types";

const ARTIFACTS_DIR = join(process.cwd(), "ci-artifacts");
const CONTEXT_FILE = join(ARTIFACTS_DIR, "bootstrap-context.json");

const CONTEXT_VERSION = "1";

export function writeContext(ctx: Context): void {
mkdirSync(dirname(CONTEXT_FILE), { recursive: true });
writeFileSync(CONTEXT_FILE, JSON.stringify({ ...ctx, version: CONTEXT_VERSION }, null, 2) + "\n");
}

export function readContext(): Context {
const raw = readFileSync(CONTEXT_FILE, "utf8");
const ctx = JSON.parse(raw) as Context;
if (ctx.version !== CONTEXT_VERSION) {
throw new Error(
`Context version mismatch: expected ${CONTEXT_VERSION}, got ${ctx.version}`,
);
}
return ctx;
}
33 changes: 33 additions & 0 deletions scripts/ci-smoke/lib/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Derive signer payment addresses from mnemonics using MeshWallet.
*/

export async function derivePaymentAddress(
mnemonic: string[],
networkId: 0 | 1,
): Promise<string> {
const { MeshWallet } = await import("@meshsdk/core");
const wallet = new MeshWallet({
networkId,
key: { type: "mnemonic", words: mnemonic },
});
await wallet.init();
return wallet.getChangeAddress();
}

export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}

export function mnemonicFromEnv(envName: string): string[] {
const raw = requireEnv(envName);
const words = raw.trim().split(/\s+/);
if (words.length < 12) {
throw new Error(`${envName} must contain at least 12 mnemonic words`);
}
return words;
}
48 changes: 48 additions & 0 deletions scripts/ci-smoke/lib/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Generate human-readable + JSON reports for the CI smoke run.
*/
import { writeFileSync, mkdirSync } from "fs";
import { dirname, join } from "path";
import type { ScenarioResult } from "../scenarios/types";

const ARTIFACTS_DIR = join(process.cwd(), "ci-artifacts");
const REPORT_FILE = join(ARTIFACTS_DIR, "ci-route-chain-report.json");

export interface RunReport {
timestamp: string;
totalScenarios: number;
passed: number;
failed: number;
aborted: boolean;
durationMs: number;
results: ScenarioResult[];
}

export function writeReport(report: RunReport): void {
mkdirSync(dirname(REPORT_FILE), { recursive: true });
writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2) + "\n");
}

export function printSummary(report: RunReport): void {
console.log("\n=== CI Smoke Test Report ===");
console.log(`Timestamp: ${report.timestamp}`);
console.log(`Total: ${report.totalScenarios}`);
console.log(`Passed: ${report.passed}`);
console.log(`Failed: ${report.failed}`);
console.log(`Aborted: ${report.aborted}`);
console.log(`Duration: ${report.durationMs}ms`);
console.log("");

for (const r of report.results) {
const icon = r.passed ? "PASS" : "FAIL";
const crit = r.critical ? " [CRITICAL]" : "";
console.log(` ${icon} ${r.name}${crit} (${r.durationMs}ms) - ${r.message}`);
}

console.log("");
if (report.failed > 0) {
console.log("SMOKE TEST FAILED");
} else {
console.log("SMOKE TEST PASSED");
}
}
76 changes: 76 additions & 0 deletions scripts/ci-smoke/run-route-chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env npx tsx
/**
* Stage 2: Execute the scenario chain against the bootstrapped wallets.
*
* Reads context from ci-artifacts/bootstrap-context.json, runs each scenario
* sequentially, and produces a report at ci-artifacts/ci-route-chain-report.json.
*
* Critical scenario failures abort the chain. Non-critical failures are logged
* but execution continues.
*
* Usage:
* npx tsx scripts/ci-smoke/run-route-chain.ts
*/
import { readContext } from "./lib/context";
import { writeReport, printSummary, type RunReport } from "./lib/report";
import { scenarios } from "./scenarios/manifest";
import type { ScenarioResult } from "./scenarios/types";

async function main() {
const ctx = readContext();
console.log(`Loaded context v${ctx.version} with ${Object.keys(ctx.wallets).length} wallets`);

const results: ScenarioResult[] = [];
let aborted = false;
const startTime = Date.now();

for (const scenario of scenarios) {
let result: ScenarioResult;
try {
result = await scenario(ctx);
} catch (err) {
result = {
name: scenario.name || "unknown",
passed: false,
critical: true,
message: `Unhandled error: ${err instanceof Error ? err.message : String(err)}`,
durationMs: 0,
};
}

results.push(result);

const icon = result.passed ? "PASS" : "FAIL";
const crit = result.critical ? " [CRITICAL]" : "";
console.log(`${icon} ${result.name}${crit} (${result.durationMs}ms)`);

if (!result.passed && result.critical) {
console.error(`Critical failure in "${result.name}": ${result.message}`);
console.error("Aborting scenario chain.");
aborted = true;
break;
}
}

const report: RunReport = {
timestamp: new Date().toISOString(),
totalScenarios: scenarios.length,
passed: results.filter((r) => r.passed).length,
failed: results.filter((r) => !r.passed).length,
aborted,
durationMs: Date.now() - startTime,
results,
};

writeReport(report);
printSummary(report);

if (report.failed > 0) {
process.exit(1);
}
}

main().catch((e) => {
console.error("Route chain failed:", e);
process.exit(1);
});
Loading
Loading