diff --git a/.github/scripts/prepare_deploy_plan.py b/.github/scripts/prepare_deploy_plan.py new file mode 100755 index 00000000..ebf79b92 --- /dev/null +++ b/.github/scripts/prepare_deploy_plan.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import sys +from pathlib import Path + + +def fail(message: str) -> None: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def matchable_name(coin: str) -> str: + marker = "_testnet" + idx = coin.find(marker) + if idx != -1: + return coin[:idx] + "=test" + return coin + "=main" + + +def load_runner_map(vars_map: dict) -> dict: + prefix = "BB_RUNNER_" + mapping = {} + for key, value in vars_map.items(): + if not key.startswith(prefix): + continue + coin = key[len(prefix):].strip() + runner = "" if value is None else str(value).strip() + if coin and runner: + mapping[coin] = runner + return mapping + + +def parse_requested_coins(raw: str, available: dict) -> list[str]: + text = raw.strip() + if not text: + fail("coins input is empty") + + if text.upper() == "ALL": + coins = sorted(available.keys()) + if not coins: + fail("no BB_RUNNER_* variables found") + return coins + + tokens = [part.strip() for part in re.split(r"[\s,]+", text) if part.strip()] + if not tokens: + fail("coins input resolved to an empty list") + if any(token.upper() == "ALL" for token in tokens): + fail("ALL must be used alone") + + seen = set() + result = [] + for coin in tokens: + if coin in seen: + continue + seen.add(coin) + result.append(coin) + return result + + +def main() -> None: + workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() + vars_map = json.loads(os.environ.get("VARS_JSON", "{}")) + coins_input = os.environ.get("COINS_INPUT", "") + + runner_map = load_runner_map(vars_map) + if not runner_map: + fail("no BB_RUNNER_* variables found") + + requested = parse_requested_coins(coins_input, runner_map) + + tests_path = workspace / "tests" / "tests.json" + configs_dir = workspace / "configs" / "coins" + + try: + tests_cfg = json.loads(tests_path.read_text(encoding="utf-8")) + except Exception as exc: + fail(f"cannot read {tests_path}: {exc}") + + deploy_matrix = [] + e2e_names = [] + + for coin in requested: + if coin not in runner_map: + fail(f"missing BB_RUNNER_{coin}") + + coin_cfg_path = configs_dir / f"{coin}.json" + if not coin_cfg_path.exists(): + fail(f"unknown coin '{coin}' (missing {coin_cfg_path})") + + test_cfg = tests_cfg.get(coin) + if not isinstance(test_cfg, dict) or "connectivity" not in test_cfg: + fail(f"coin '{coin}' has no connectivity tests in tests/tests.json") + + deploy_matrix.append({"coin": coin, "runner": runner_map[coin]}) + e2e_names.append(matchable_name(coin)) + + unique_names = sorted(set(e2e_names)) + if not unique_names: + fail("no coins selected after validation") + + escaped = [re.escape(name) for name in unique_names] + e2e_regex = "TestIntegration/(" + "|".join(escaped) + ")/api" + + output_file = os.environ.get("GITHUB_OUTPUT") + if not output_file: + fail("GITHUB_OUTPUT is not set") + + with open(output_file, "a", encoding="utf-8") as out: + out.write(f"deploy_matrix={json.dumps(deploy_matrix, separators=(',', ':'))}\n") + out.write(f"e2e_regex={e2e_regex}\n") + out.write(f"coins_csv={','.join(requested)}\n") + + print("Selected coins:", ", ".join(requested)) + print("E2E regex:", e2e_regex) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..33caa0c2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,79 @@ +name: Deploy + +on: + workflow_dispatch: + inputs: + coins: + description: "Comma-separated coin aliases from configs/coins, or ALL" + required: true + ref: + description: "Git ref to deploy (leave empty for current ref)" + required: false + default: "" + +permissions: + contents: read + +jobs: + prepare: + name: Prepare Plan + runs-on: ubuntu-latest + outputs: + deploy_matrix: ${{ steps.plan.outputs.deploy_matrix }} + e2e_regex: ${{ steps.plan.outputs.e2e_regex }} + coins_csv: ${{ steps.plan.outputs.coins_csv }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - name: Build deploy/e2e plan + id: plan + env: + VARS_JSON: ${{ toJSON(vars) }} + COINS_INPUT: ${{ inputs.coins }} + run: ./.github/scripts/prepare_deploy_plan.py + + deploy: + name: Deploy (${{ matrix.coin }}) + needs: prepare + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.prepare.outputs.deploy_matrix) }} + runs-on: [self-hosted, Linux, X64, "${{ matrix.runner }}"] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - name: Export repository variables + uses: ./.github/actions/export-repository-variables + with: + vars_json: ${{ toJSON(vars) }} + + - name: Deploy blockbook package + run: ./contrib/scripts/deploy-blockbook-local.sh "${{ matrix.coin }}" + + e2e-tests: + name: E2E Tests (post-deploy) + needs: [prepare, deploy] + if: ${{ needs.deploy.result == 'success' }} + runs-on: [self-hosted, Linux, X64] + env: + E2E_REGEX: ${{ needs.prepare.outputs.e2e_regex }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - name: Export repository variables + uses: ./.github/actions/export-repository-variables + with: + vars_json: ${{ toJSON(vars) }} + + - name: Run e2e tests + run: make test-e2e ARGS="-v -run ${E2E_REGEX}" diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b40762a5..e5f59227 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,4 +1,4 @@ -name: CI +name: Testing on: push: @@ -53,21 +53,3 @@ jobs: - name: Run integration tests run: make test-integration ARGS="-v" - - e2e-tests: - name: E2E Tests (Blockbook API) - runs-on: [self-hosted, Linux, X64] - needs: integration-tests - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Export repository variables - uses: ./.github/actions/export-repository-variables - with: - vars_json: ${{ toJSON(vars) }} - - - name: Run e2e tests - run: make test-e2e ARGS="-v" diff --git a/contrib/scripts/deploy-blockbook-local.sh b/contrib/scripts/deploy-blockbook-local.sh new file mode 100755 index 00000000..805d3622 --- /dev/null +++ b/contrib/scripts/deploy-blockbook-local.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $(basename "$0") " >&2 + exit 1 +fi + +coin="$1" +config="configs/coins/${coin}.json" + +if [[ ! -f "$config" ]]; then + echo "error: missing coin config $config" >&2 + exit 1 +fi + +command -v jq >/dev/null 2>&1 || { echo "error: jq is required" >&2; exit 1; } + +package_name="$(jq -r '.blockbook.package_name // empty' "$config")" +if [[ -z "$package_name" ]]; then + echo "error: coin '$coin' does not define blockbook.package_name" >&2 + exit 1 +fi + +rm -f build/${package_name}_*.deb +make "deb-blockbook-${coin}" + +package_file="$(ls -1t build/${package_name}_*.deb 2>/dev/null | head -n1 || true)" +if [[ -z "$package_file" ]]; then + echo "error: built package for '$coin' was not found (pattern build/${package_name}_*.deb)" >&2 + exit 1 +fi + +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --reinstall "./${package_file}" +sudo systemctl restart "${package_name}.service" +sudo systemctl is-active --quiet "${package_name}.service" + +echo "deployed ${coin} via ${package_file}"