Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
c408b4f
ci: sibling checkouts follow the pull request's base branch, keeping …
jdogresorg Sep 17, 2026
829d2f7
refactor(node): extract migration scan helpers
jdogresorg Sep 17, 2026
90de5a1
fix(migrations): restore default reader wiring
jdogresorg Sep 17, 2026
2aaba8f
refactor(operations): split module operation seams
jdogresorg Sep 17, 2026
bec75a3
refactor(cli): split command modules
jdogresorg Sep 17, 2026
72fb451
refactor(config): split config service responsibilities
jdogresorg Sep 17, 2026
1a04b22
fix(config): separate hub config polling from feed routing
jdogresorg Sep 17, 2026
2b25777
refactor: split validator stake operations
jdogresorg Sep 17, 2026
fcd528f
test: split migration precondition suite
jdogresorg Sep 17, 2026
9310e07
test: split nightly workflow registrations
jdogresorg Sep 17, 2026
1b89fb7
test(node): split indexer migration CLI registrations
jdogresorg Sep 17, 2026
3048e91
Route runtime environment access through config
jdogresorg Sep 19, 2026
d422f85
Merge master into develop after the v0.20.0 train
jdogresorg Sep 19, 2026
2c7b238
fix(ci): add an all-coin dispatch option to the nightly e2e matrix
jdogresorg Sep 19, 2026
acf63fd
fix(module-operations): exec the decoder's clear script at its snake_…
jdogresorg Sep 20, 2026
7b0072e
test(secret-env): hard-fail missing hub pin
jdogresorg Sep 20, 2026
27095de
Land row L019-35 from cx-l019-35
jdogresorg Sep 20, 2026
cb2c8da
Remove obsolete indexer migrate CLI path
jdogresorg Sep 20, 2026
288a687
Land row L002-93 from cx-l002-93
jdogresorg Sep 20, 2026
0aea8e5
Add regtest validator P2P defaults
jdogresorg Sep 21, 2026
f02385e
Land row L002-40 from cx-l002-40
jdogresorg Sep 21, 2026
cb4ebc1
test: isolate registry signer config with fixture
jdogresorg Sep 21, 2026
4c2429c
test(validator): split regtest init into its own describe
jdogresorg Sep 21, 2026
9a12b0c
Land row L011-54 from cx-l011-54
jdogresorg Sep 21, 2026
a747a3b
docs(precheck): restore hub failure debugging context
jdogresorg Sep 21, 2026
7c74e1a
test(hub): prove HUB_PORT_OVERRIDE moves the hub's published and cont…
jdogresorg Sep 21, 2026
88309fa
Merge: docs(precheck): restore hub failure debugging context
jdogresorg Sep 21, 2026
49e658e
Replace service console fallbacks with logger adapters
jdogresorg Sep 21, 2026
22689a9
Merge: test(hub): prove HUB_PORT_OVERRIDE moves the hub's published a…
jdogresorg Sep 21, 2026
0977c71
Merge: Replace service console fallbacks with logger adapters
jdogresorg Sep 21, 2026
5d16f94
docs: clarify buildx package names
jdogresorg Sep 22, 2026
6861234
test(node): isolate reset confirmation dependencies
jdogresorg Sep 22, 2026
f33649e
Merge: test(node): isolate reset confirmation dependencies
jdogresorg Sep 22, 2026
765c0b3
Merge: docs: clarify buildx package names
jdogresorg Sep 22, 2026
5092cc9
fix: preserve branch installs and report update refusals
jdogresorg Sep 22, 2026
0adbe04
Merge: fix: preserve branch installs and report update refusals
jdogresorg Sep 22, 2026
583b5e4
build(ci): grade the fast tier on a push, defer the heavy tiers to th…
jdogresorg Sep 22, 2026
96e6202
test(node): add sql_safety unit coverage
jdogresorg Sep 22, 2026
3105da2
Merge: test(node): add sql_safety unit coverage
jdogresorg Sep 22, 2026
db8815b
fix(node): report a non-zero drain exit instead of calling it a clean…
jdogresorg Sep 23, 2026
5825221
fix(node): agree on the -p port grammar, drop an unreachable return a…
jdogresorg Sep 23, 2026
806bb4e
fix(node): derive each service's SHUTDOWN_TIMEOUT_MS from the node st…
jdogresorg Sep 23, 2026
80a434f
test: allow slow bootstrap proxyquire setup
jdogresorg Sep 23, 2026
35b370a
Merge: test: allow slow bootstrap proxyquire setup
jdogresorg Sep 23, 2026
eb60f94
fix(node): zero the bridge and policy watermark graces on regtest
jdogresorg Sep 23, 2026
449d628
chore(release): v0.20.1
jdogresorg Sep 23, 2026
c26dace
ci(e2e): shard the action suite five ways per coin, one combined run
jdogresorg Sep 23, 2026
c281a53
release: pin the v0.20.1 manifest to the signed sibling tags
jdogresorg Sep 24, 2026
3ce7c9d
Merge pull request #50 from XChain-Platform/cut/v0201-node-manifest
jdogresorg Sep 24, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: XChain-Platform/xchain-hub
ref: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }}
ref: ${{ github.base_ref || (github.ref == 'refs/heads/master' && 'master' || 'develop') }}
ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }}
path: xchain-hub

Expand Down
282 changes: 241 additions & 41 deletions .github/workflows/nightly-e2e.yml

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.20.1] - 2026-09-23

### Fixed
- Preserved branch-based installs, surfaced update and drain failures, and aligned service shutdown timeouts with the node stop budget.


## [0.20.0] - 2026-09-17

### Added
Expand Down
9 changes: 4 additions & 5 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,10 @@ sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
```

`docker-buildx-plugin` is required, not optional: the module images only build
under BuildKit, and `xchain-node install` refuses to build without it. Ubuntu's
own `docker.io` package ships without the plugin, so a Docker installed from the
Ubuntu archive needs `sudo apt install docker-buildx-plugin` before any module
install.
Buildx is required: the module images only build under BuildKit, and
`xchain-node install` refuses to build without it. Install `docker-buildx-plugin`
when using Docker's repository as above, or install `docker-buildx` when using
the Ubuntu archive's `docker.io` package.

### Add your user to the docker group
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# XChain Platform Node

<p align="center">
<img src="https://img.shields.io/badge/version-0.20.0-blue" alt="Version">
<img src="https://img.shields.io/badge/version-0.20.1-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-3%2C387%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/node-%3E%3D22-green" alt="Node">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-blue" alt="License">
Expand Down
11 changes: 7 additions & 4 deletions bin/capture_cli_prints.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*
* The CLI print contract: what this tool exists to prove.
*
* The four user-facing paths below print straight to the console on purpose,
* The user-facing paths below print straight to the console on purpose,
* because their console IS the product a human reads. A restructure that
* moves those files, renames their methods or routes anything through a logger
* must leave every one of those prints saying exactly what it said before. A
Expand Down Expand Up @@ -46,9 +46,9 @@ const { execFileSync } = require('child_process');

const REPO = path.resolve(__dirname, '..');

// The four user-facing paths whose prints go to the console, not the logger.
// The user-facing paths whose prints go to the console, not the logger.
// A path that is a directory contributes every .js file under it.
const PRINT_PATHS = ['src/ui', 'src/cli.js', 'src/operations', 'src/precheck.js'];
const PRINT_PATHS = ['src/ui', 'src/cli.js', 'src/cli', 'src/operations', 'src/precheck.js'];

const CONSOLE_METHOD = /console\s*\.\s*([A-Za-z]+)\s*\(/g;

Expand Down Expand Up @@ -180,7 +180,10 @@ function collectPrints() {
* each in a child process and keep the bytes.
*/
function driveHelp() {
const cliSrc = fs.readFileSync(path.join(REPO, 'src/cli.js'), 'utf8');
const cliSrc = ['src/cli.js', 'src/cli']
.flatMap(expandPath)
.map(rel => fs.readFileSync(path.join(REPO, rel), 'utf8'))
.join('\n');
const names = [...cliSrc.matchAll(/\.command\(\s*'([a-z0-9:-]+)'/g)].map(x => x[1]);
const targets = ['--help', ...[...new Set(names)].sort().map(n => `${n} --help`)];
const chunks = [];
Expand Down
42 changes: 41 additions & 1 deletion bin/ci-full.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,35 @@ SELF="$(pwd)"
SIB="$(cd .. && pwd)"

FAILED=""
# >>> ci-tier (generated block; re-run the tier wirer to update) >>>
# Tier classes. A push grades the FAST tier only: the unit job, the pin and
# drift guards, and the structure and hygiene checks the hook runs before it
# dispatches. The tiers named below (coverage re-runs, perf scenarios) are
# skipped when the gate sets CI_TIER=fast, and each skip is recorded so the
# closing verdict can never claim a green it did not earn. Nothing stops
# being graded: a scheduled sweep re-runs this same script with CI_TIER=full
# on every repo every three hours and before any release or deploy, and a
# red there is tracked down and fixed first. CI_TIER is unset for a hand
# run, so a bare `npm run ci:full` still runs every tier as it always did.
CI_TIER_FULL_ONLY=(
"coverage ratchet (coverage:check)"
)
DEFERRED=""
ci_tier_deferred() {
[ "${CI_TIER:-full}" = "fast" ] || return 1
local t
for t in ${CI_TIER_FULL_ONLY[@]+"${CI_TIER_FULL_ONLY[@]}"}; do
if [ "$t" = "$1" ]; then
DEFERRED="$DEFERRED [$1]"
echo; echo "ci:full ===== $1 DEFERRED (CI_TIER=fast, runs in the full sweep) ====="
return 0
fi
done
return 1
}
# <<< ci-tier <<<
run_tier() {
ci_tier_deferred "$1" && return 0 # ci-tier guard (generated)
local name="$1"; shift
echo; echo "ci:full ===== $name ====="
if "$@"; then
Expand Down Expand Up @@ -110,8 +138,20 @@ run_tier "identity pin (vendored coin bytes)" node bin/pin_identity.js --compare
run_tier "coverage ratchet (coverage:check)" npm run coverage:check

echo
# >>> ci-tier summary (generated) >>>
echo "ci:full: tier class ${CI_TIER:-full}"
if [ -n "${DEFERRED:-}" ]; then
echo "ci:full: DEFERRED to the full sweep:$DEFERRED"
fi
# <<< ci-tier summary <<<
if [ -n "$FAILED" ]; then
echo "ci:full: RED tiers:$FAILED"
exit 1
fi
echo "ci:full: all tiers green (same set GitHub CI runs)"
# >>> ci-tier verdict (generated) >>>
if [ "${CI_TIER:-full}" = "fast" ]; then
echo "ci:full: all FAST tiers green; the DEFERRED tiers above were NOT graded here"
else
echo "ci:full: all tiers green (same set GitHub CI runs)"
fi
# <<< ci-tier verdict <<<
7 changes: 7 additions & 0 deletions bin/pins/suite-title-splits.json
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,13 @@
"test/unit/module_operations.test/17_module_error_paths.test.js",
"test/unit/module_operations.test/18_sync_shared_services_after_install.test.js",
"test/unit/module_operations.test/19_e2e_image_siblings.test.js"
],
"test/unit/migration_precondition_service.test.js": [
"test/unit/migration_precondition_service.test.js",
"test/unit/migration_precondition_service.test/01_migration_metadata.test.js",
"test/unit/migration_precondition_service.test/02_migration_inventory.test.js",
"test/unit/migration_precondition_service.test/03_applied_ledger.test.js",
"test/unit/migration_precondition_service.test/04_guard_outcomes.test.js"
]
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "xchain-node",
"version": "0.20.0",
"version": "0.20.1",
"description": "xchain-node allows users to install, configure and run XChain platform nodes.",
"license": "AGPL-3.0-or-later",
"repository": {
Expand Down
201 changes: 201 additions & 0 deletions scripts/e2e_shard_merge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env node
'use strict'

// Copyright © 2025–2026 Dankest, LLC
// Based on XChain Platform by Dankest, LLC – https://dankest.llc
//
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This file is part of XChain Platform. Licensed under the GNU Affero
// General Public License v3.0 or later; see LICENSE.md. A commercial
// license (without AGPL source-disclosure terms) is available -
// contact legal@dankest.llc.

// Folds one coin's e2e-shard-<coin>-<n> artifacts back into e2e-logs-<coin>,
// refusing unless every shard in the plan ran clean.

// The release matrix judge walks every *.log, identifies a leg by npm banner
// `> xchain-e2e-test@<v> test|test:security|test:perf:budget`, rejects duplicates,
// and takes the LAST passing/pending/failing summary line.

// Artifact structure: <coin>-regtest-action-merged.log with merged summary, security
// and perf logs from shard 1, shards/<n>/* with .log renamed .log.txt, and shard-merge.json.

// Shards run via `e2etest <coin> <pattern>` (npx mocha, no npm banner), so this
// script writes the banner on the merged log after all shards are proven clean.

const fs = require('fs')
const path = require('path')

const LEG_BANNER = /^>\s+xchain-e2e-test@\S+\s+(test|test:security|test:perf:budget)\s*$/m
const LEG_SCRIPT = { security: 'test:security', performance: 'test:perf:budget' }

function stripAnsi(value) {
return value.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
}

// Same rule judge-matrix.js countSummary applies: last line of each kind wins.
function countSummary(text) {
const counts = { passing: null, pending: 0, failing: 0 }
for (const line of stripAnsi(text).split(/\r?\n/)) {
const match = line.match(/^\s*([\d,]+)\s+(passing|pending|failing)\b/)
if (match) counts[match[2]] = Number(match[1].replaceAll(',', ''))
}
return counts
}

function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i]
if (!['--coin', '--plan', '--shards-dir', '--out'].includes(key)) throw new Error(`unknown argument ${key}`)
args[key.slice(2)] = argv[i + 1]
}
for (const key of ['coin', 'plan', 'shards-dir', 'out']) if (!args[key]) throw new Error(`--${key} is required`)
return args
}

function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}

function copyTree(from, to, rename) {
fs.mkdirSync(to, { recursive: true })
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
const source = path.join(from, entry.name)
if (entry.isDirectory()) copyTree(source, path.join(to, entry.name), rename)
else if (entry.isFile()) fs.copyFileSync(source, path.join(to, rename(entry.name)))
}
}

// One shard's verdict. Every reason names the shard, so a red aggregate says
// which runner to open.
function checkShard({ coin, n, entry, dir }) {
const reasons = []
const tag = `shard ${n}`
if (!fs.existsSync(dir)) return { reasons: [`${tag}: no e2e-shard-${coin}-${n} artifact (the shard job did not reach its upload)`] }
const statusFile = path.join(dir, 'shard-status.json')
if (!fs.existsSync(statusFile)) return { reasons: [`${tag}: artifact has no shard-status.json`] }
const status = readJson(statusFile)

if (status.coin !== coin) reasons.push(`${tag}: status names coin ${status.coin}`)
if (Number(status.shard) !== n) reasons.push(`${tag}: status names shard ${status.shard}`)
if (status.pattern !== entry.pattern) reasons.push(`${tag}: ran pattern ${JSON.stringify(status.pattern)}, plan says ${JSON.stringify(entry.pattern)}`)
if (String(status.action_exit) !== '0') reasons.push(`${tag}: action suite exit code ${status.action_exit || 'missing'}`)

const legs = {}
const actionPath = status.action_log ? path.join(dir, status.action_log) : null
if (!actionPath || !fs.existsSync(actionPath)) {
reasons.push(`${tag}: action log ${status.action_log || '(none recorded)'} is missing`)
} else {
const text = fs.readFileSync(actionPath, 'utf8')
const counts = countSummary(text)
if (counts.passing === null) reasons.push(`${tag}: action log has no Mocha passing summary`)
else if (counts.passing + counts.failing === 0) reasons.push(`${tag}: action shard ran no tests`)
if (counts.failing > 0) reasons.push(`${tag}: action shard has ${counts.failing} failing test(s)`)
legs.action = { file: actionPath, text, counts }
}

for (const leg of entry.extras || []) {
const exit = status[`${leg}_exit`]
const file = status[`${leg}_log`]
if (String(exit) !== '0') reasons.push(`${tag}: ${leg} suite exit code ${exit || 'missing'}`)
const full = file ? path.join(dir, file) : null
if (!full || !fs.existsSync(full)) {
reasons.push(`${tag}: ${leg} log ${file || '(none recorded)'} is missing`)
continue
}
const text = fs.readFileSync(full, 'utf8')
const banner = text.match(LEG_BANNER)
if (!banner || banner[1] !== LEG_SCRIPT[leg]) reasons.push(`${tag}: ${leg} log carries no ${LEG_SCRIPT[leg]} banner`)
const counts = countSummary(text)
if (counts.passing === null || counts.passing + counts.failing === 0) reasons.push(`${tag}: ${leg} suite ran no tests`)
if (counts.failing > 0) reasons.push(`${tag}: ${leg} suite has ${counts.failing} failing test(s)`)
legs[leg] = { file: full, counts }
}
return { reasons, legs }
}

function merge({ coin, plan, shardsDir, out }) {
const reasons = []
const shardDir = n => path.join(shardsDir, `e2e-shard-${coin}-${n}`)
const results = []
for (const n of plan.shards) {
const entry = plan.plan[n]
if (!entry) {
reasons.push(`shard ${n}: absent from the plan`)
continue
}
const result = checkShard({ coin, n, entry, dir: shardDir(n) })
reasons.push(...result.reasons)
results.push({ n, entry, legs: result.legs || {} })
}

fs.mkdirSync(out, { recursive: true })
const summary = { coin, shard_count: plan.shard_count, suite: plan.suite, shards: [], reasons }
for (const { n, entry, legs } of results) {
summary.shards.push({ shard: n, groups: entry.groups, action: legs.action ? legs.action.counts : null })
if (fs.existsSync(shardDir(n))) copyTree(shardDir(n), path.join(out, 'shards', String(n)), name => name.replace(/\.log$/, '.log.txt'))
}

if (reasons.length === 0) {
const totals = { passing: 0, pending: 0, failing: 0 }
const body = []
for (const { n, entry, legs } of results) {
for (const key of Object.keys(totals)) totals[key] += legs.action.counts[key]
body.push(`=== shard ${n}/${plan.shard_count}: ${entry.groups.join(' ')} ===`, legs.action.text)
}
// A single-suite dispatch was never a full action pass, and the old job
// gave it no npm banner at all, so the judge must not find one here.
const banner = plan.suite
? `> xchain-e2e-test@${plan.version} test [${plan.suite} only]`
: `> xchain-e2e-test@${plan.version} test`
const lines = [
'',
banner,
`> mocha --timeout 0 --exit --require ./test/initialCheck.test.js 'test/actions/**/*.test.js' (sharded ${plan.shard_count} ways by nightly-e2e.yml; per-shard output follows)`,
'',
...body,
`=== merged summary over ${plan.shard_count} shard(s) ===`,
` ${totals.passing} passing`,
` ${totals.pending} pending`,
` ${totals.failing} failing`,
'',
]
fs.writeFileSync(path.join(out, `${coin}-regtest-action-merged.log`), lines.join('\n'))
summary.action = totals
for (const { legs } of results) {
for (const leg of Object.keys(LEG_SCRIPT)) {
if (legs[leg]) fs.copyFileSync(legs[leg].file, path.join(out, path.basename(legs[leg].file)))
}
}
}
fs.writeFileSync(path.join(out, 'shard-merge.json'), JSON.stringify(summary, null, 2) + '\n')
return summary
}

function main() {
const args = parseArgs(process.argv.slice(2))
const summary = merge({ coin: args.coin, plan: readJson(args.plan), shardsDir: args['shards-dir'], out: args.out })
for (const s of summary.shards) {
const c = s.action
process.stdout.write(`${args.coin} shard ${s.shard}: ${c ? `${c.passing} passing, ${c.pending} pending, ${c.failing} failing` : 'no action result'} (${s.groups.length} groups)\n`)
}
if (summary.reasons.length > 0) {
for (const reason of summary.reasons) process.stdout.write(`::error::${args.coin} ${reason}\n`)
process.exit(1)
}
const t = summary.action
process.stdout.write(`${args.coin} merged action: ${t.passing} passing, ${t.pending} pending, ${t.failing} failing over ${summary.shard_count} shard(s)\n`)
}

if (require.main === module) {
try {
main()
} catch (error) {
process.stdout.write(`::error::e2e_shard_merge: ${error.message}\n`)
process.exit(1)
}
}

module.exports = { merge, countSummary }
Loading
Loading