Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c9716ed
fix(install): let the explorer install on a stack that has no coins yet
jdogresorg Aug 18, 2026
76fb1b0
fix(install): return only once the explorer is actually serving the n…
jdogresorg Aug 18, 2026
cc6e94d
fix(install): tell the shared services about coins the same run just …
jdogresorg Aug 18, 2026
02e3373
ci(e2e): capture the stack's own logs and topology when the job fails
jdogresorg Aug 18, 2026
96010ee
fix(e2e): stage xchain-sync so the consensus hash drift-lock actually…
jdogresorg Aug 18, 2026
45d2eaf
fix(install): fail when the explorer never starts serving coin data
jdogresorg Aug 18, 2026
7aae555
ci(e2e): capture what the hub serves the explorer on a failed run
jdogresorg Aug 18, 2026
067eb24
ci(e2e): keep enough container log to diagnose an early suite
jdogresorg Aug 19, 2026
4885cf4
ci(e2e): capture whole container logs, on every run
jdogresorg Aug 19, 2026
6bdcfab
ci(e2e): allow a single action suite per run
jdogresorg Aug 19, 2026
a45626a
feat(config): give the e2e container the validator pubkey it was told…
jdogresorg Aug 19, 2026
9f174e3
ci(e2e): opt-in validator mode, so the onboarding suite has a hub to …
jdogresorg Aug 19, 2026
58173e8
ci(e2e): raise the job budget so the suites behind the action pass ca…
jdogresorg Aug 19, 2026
e70e22a
feat(explorer): self-synced checkpoint mirror config, and the grants …
jdogresorg Aug 20, 2026
d55e11a
fix(autoheal): never restart a container an operator deliberately sto…
jdogresorg Aug 20, 2026
414726e
fix(node): rotate the hub DB account on recreate and guard the headle…
jdogresorg Aug 21, 2026
58e8fa9
fix(node): refuse a bootstrap source that reports no lag or a negativ…
jdogresorg Aug 21, 2026
8ebac2e
chore(release): v0.10.0
jdogresorg Aug 22, 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
161 changes: 159 additions & 2 deletions .github/workflows/nightly-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,34 @@ on:
type: choice
options: [bitcoin, litecoin, dogecoin]
default: bitcoin
suite:
# ONE suite instead of the whole action set, so fixing a defect costs a boot
# plus that suite rather than a two-hour full pass. Takes the file-name stem
# e2etest already accepts (e.g. "attestation", "order"). Empty runs everything,
# which is what a release gate must do; a subset proves a fix, never the train.
# The security and performance suites are skipped while a subset is running,
# because they grade the whole stack and a partial action pass does not earn them.
description: 'Single action suite to run (file stem, e.g. attestation). Empty = full suite.'
type: string
default: ''
validator:
# Boot the stack's hub as a full VALIDATOR rather than the standalone
# config oracle it runs as by default.
#
# Opt-in, and default false on purpose. Validator mode is not a superset
# of standalone: peerManager exists, so startOracle() proceeds and the hub
# begins finalizing its own price rounds. Several action suites branch on
# exactly that (the fee fixtures compute an output size FROM a pair they
# seed, and skip when the venue publishes its own XCHAIN/USD), so flipping
# this for the whole matrix would silently change what the gate measures.
#
# Turn it on for the validator-onboarding suite, which STAKEs the hub's
# signing pubkey and asserts the indexer admits it - the one suite that
# cannot run against a hub with no identity, and which skips itself when
# VALIDATOR_PUBKEY is absent.
description: 'Run the hub as a validator (needed by the onboardValidator suite; changes the price regime)'
type: boolean
default: false
ref:
# The release ceremony's freeze step needs a driven e2e run against the
# EXACT content a release is being cut from, not against a branch tip
Expand Down Expand Up @@ -79,14 +107,32 @@ permissions:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 120
# A BTC full action suite alone runs ~1h50m of wall clock, and the security
# and performance suites are sequenced AFTER it, so at 120 the two of them
# shared whatever minutes the action suite happened to leave - usually none.
# They had never once executed, and no amount of fixing the action suite
# could have changed that: the budget, not the tests, was the binding
# constraint.
#
# 360 is the platform ceiling for a hosted job, i.e. this sets no limit the
# runner would not impose anyway. That is deliberate: the two suites behind
# the action pass have never run, so there is no measured duration to size
# headroom against, and guessing low would re-create the same invisible
# truncation one tier further along. The cost is that a WEDGED run (a hung
# container, a stalled indexer poll) now takes the full 6 hours to report
# instead of failing earlier - so if that starts happening, add a
# `timeout-minutes` to the individual suite steps rather than clawing this
# number back down; a per-step bound catches a hang without also capping a
# legitimately long pass.
timeout-minutes: 360
env:
COIN: ${{ github.event.inputs.coin || 'bitcoin' }}
# Drives BOTH the xchain-node checkout below and the `install` boot
# argument, so the CLI running the install is the same version as the
# stack it installs. Splitting those two was how "we tested the release"
# could mean "we tested master's installer against the release".
STACK_REF: ${{ github.event.inputs.ref || 'develop' }}
SUITE: ${{ github.event.inputs.suite || '' }}
# Headless DB: point xchain-node at an external MariaDB instead of its
# bundled DB container, so the first install does NOT stop on the
# interactive root-password prompt (see DatabaseService.getExternalDbConfig
Expand Down Expand Up @@ -170,6 +216,38 @@ jobs:
git config --global url."https://github.com/".insteadOf "git@github.com:"
fi

- name: Initialize the validator identity (opt-in; must precede install)
if: github.event.inputs.validator == 'true'
# MUST run before install: ConfigService reads the settings this writes
# when it generates the hub's container env (getValidatorEnv) and the
# e2e container's VALIDATOR_PUBKEY. Initializing afterwards would leave
# both already rendered from a standalone config.
#
# The hub fails loud in validator mode on two vars the standalone path
# never needs, and both are host-env passthroughs rather than anything
# `validator init` writes, so they are exported here for every later step:
# HUB_NETWORK consensus gating (STAKE_WEIGHTED_QUORUM height
# is per network); hub exits 1 when blank/invalid
# ORACLE_MIN_SUBMISSIONS a lone validator can never reach the default
# 2-hub diversity floor, so no round would ever
# finalize and every indexer's price-sync barrier
# would stall
# ORACLE_EPOCH_START is also required, but `validator init` persists it
# into validator.json and getValidatorEnv injects it, so it is passed as
# a flag rather than exported. Its value only has to be shared across a
# federation; this venue is a single hub, so the run's own clock is fine.
run: |
node src/index.js validator init \
--oracle-epoch-start "$(date -u +%s)000" \
--capabilities price,cross_chain,oracle_publish,attestation
{
echo "HUB_NETWORK=regtest"
echo "ORACLE_MIN_SUBMISSIONS=1"
} >> "$GITHUB_ENV"
# Prints the pubkey and never the seed (validator status reads the
# settings file; the 0600 signing.key is not echoed).
node src/index.js validator status

- name: Boot the regtest stack (clones every service at ${{ github.event.inputs.ref || 'develop' }})
# First heavy step - repo clones + docker image builds + coin daemon.
# Watch disk and image-build time here (the 120-min job timeout covers it).
Expand All @@ -185,17 +263,96 @@ jobs:
- name: Run the e2e action suite
# Now exits non-zero on failure (cli.js e2etest propagates the suite's
# exit code), so this step natively gates the job.
run: node src/index.js e2etest "$COIN" --ref "$STACK_REF"
run: node src/index.js e2etest "$COIN" $SUITE --ref "$STACK_REF"

- name: Run the e2e Security suite (test/security)
if: env.SUITE == ''
# Stack-dependent on-chain security suite (VM sandbox-escape / gas-bomb /
# deploy-reject). Driven via the e2etest --script option.
run: node src/index.js e2etest "$COIN" --script test:security --ref "$STACK_REF"

- name: Run the e2e Performance suite (test/perf)
if: env.SUITE == ''
# Live-stack per-service latency budgets (regression ceilings).
run: node src/index.js e2etest "$COIN" --script test:perf:budget --ref "$STACK_REF"

# What the stack itself said, which is the one thing a failure here has never
# carried off the runner. The suite log records that a service answered
# wrong; only the service's own log and the network topology say why, and
# without them a remote diagnosis is guesswork - three wrong hypotheses in a
# row were paid for one 15-minute run at a time.
- name: Capture stack diagnostics
# Runs on every outcome, not just failure. A passing run is the baseline a
# later failure is read against, and there is no second chance to collect it:
# the runner is destroyed with the job.
if: always()
run: |
OUT="$XCHAIN_NODE_DATA_DIR/e2e-logs/diagnostics"
mkdir -p "$OUT"
# Never let a diagnostic failure mask the real one: this step is
# evidence-gathering, and the job is already failing.
docker ps -a > "$OUT/docker-ps.txt" 2>&1 || true
docker network ls > "$OUT/docker-networks.txt" 2>&1 || true
for net in $(docker network ls --filter name=xchain --format '{{.Name}}'); do
echo "=== $net ===" >> "$OUT/network-inspect.txt"
docker network inspect "$net" >> "$OUT/network-inspect.txt" 2>&1 || true
done
for c in $(docker ps -a --format '{{.Names}}'); do
echo "=== $c ===" >> "$OUT/container-inspect.txt"
# Config.Env carries credentials; take only what identifies the
# container and how it is attached.
docker inspect "$c" --format '{{json .NetworkSettings.Networks}}' >> "$OUT/container-inspect.txt" 2>&1 || true
# NO --tail. Any cap is a bet on where the next defect lands, and the
# previous 400 lost an early-suite failure in a 2-hour run. The whole suite
# log for such a run is under a megabyte, so completeness is affordable and
# the artifact is per-run, so nothing here overwrites a prior run's evidence.
docker logs "$c" > "$OUT/log-$c.txt" 2>&1 || true
done

# The explorer answering 503 with zero DB pools is the failure this job
# keeps hitting, and the container log alone cannot say whether the hub
# ever served it coin config. Capture both sides of that exchange.
curl -s -m 10 -X POST -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"ping","params":{},"id":1}' \
http://127.0.0.1:18080/ > "$OUT/explorer-ping.json" 2>&1 || true

# getallconfigs is in the hub's sensitive-read tier: its response carries DB
# credentials. Record the KEY STRUCTURE only, never a value, and keep the raw
# body outside the uploaded directory so it cannot reach the artifact.
RAW="$RUNNER_TEMP/hub-config-raw.json"
curl -s -m 10 -X POST -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"getallconfigs","params":{"since_updated_at":0},"id":1}' \
http://127.0.0.1:10000/ > "$RAW" 2>&1 || true
RAW="$RAW" node -e '
const fs = require("fs");
let parsed;
try { parsed = JSON.parse(fs.readFileSync(process.env.RAW, "utf8")); }
catch (e) { console.log("hub response unparseable or empty"); process.exit(0); }
const result = (parsed && parsed.result) || {};
const tree = result.configs || result;
const shape = {};
for (const coin of Object.keys(tree || {})) {
shape[coin] = {};
for (const net of Object.keys(tree[coin] || {})) {
shape[coin][net] = {};
for (const mod of Object.keys(tree[coin][net] || {}))
shape[coin][net][mod] = Object.keys(tree[coin][net][mod] || {});
}
}
console.log("seq=" + result.seq + " watermark=" + result.watermark);
console.log("coins served: " + (Object.keys(shape).join(", ") || "(none)"));
console.log(JSON.stringify(shape, null, 2));
' > "$OUT/hub-config-shape.txt" 2>&1 || true
rm -f "$RAW"

# Allowlisted so no credential-bearing var can be swept in.
docker inspect xchain-node-xchain-explorer \
--format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
| grep -E '^(HUB_API_HOST|HUB_PORT|HUB_VALIDATORS|NO_HUB|UPDATE_CONFIG_INTERVAL)=' \
> "$OUT/explorer-hub-env.txt" 2>&1 || true

echo "captured $(ls -1 "$OUT" | wc -l) diagnostic files"

- name: Upload e2e logs
if: always()
uses: actions/upload-artifact@v4
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.10.0] - 2026-08-22

### Added
- Pinned installs verify downloaded release artifacts against the pinned signing key.
- The explorer can run a self-synced checkpoint mirror, with the database grants it needs, so a deployment with no colocated hub schema can still serve the checkpoint, proof and cross-chain routes.
- A module update is refused when its source asserts a gated migration that has not been applied.

### Changed
- The release manifest pins every module the installer clones. The 0.10.0 set is twelve; the 0.9.0 manifest carried eight, so a pinned install of that train still cloned four modules at their default branch.

### Fixed
- A bootstrap source that reports no lag, or a negative lag, is refused with a reason instead of being read as healthy.
- Autoheal no longer restarts a container an operator deliberately stopped.
- The hub database account is rotated when the hub is recreated, and the headless config prompt is guarded.
- Uninstall keeps a shared service that is still serving another coin.
- Install returns only once the explorer is actually serving the new coins, and fails when the explorer never starts serving them.
- Install tells the shared services about coins the same run just created.
- The explorer installs on a stack that has no coins yet.
- The hub and explorer are staged at the ref the command named.
- A migration precondition reads its skip flag by name.
- The block-fetch desync record renders field by field instead of as an object placeholder.

### Security
- Raised the brace-expansion and js-yaml dependency floors and the advisory guards that pin them.

## [0.9.0] - 2026-08-14

First release of the XChain Platform release train. Every component in the train
Expand Down
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.9.0",
"version": "0.10.0",
"description": "xchain-node allows users to install, configure and run XChain platform nodes.",
"license": "AGPL-3.0-or-later",
"repository": {
Expand Down
76 changes: 71 additions & 5 deletions scripts/publish-bootstraps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
# XCHAIN_NODE_DATA_DIR / XCHAIN_NODE_TMP_DIR - the defaults live under the
# repo on the small root fs and WILL fill it mid-create otherwise.
# - Transfers each archive + its .sig to the sync host (scp, origin->sync).
# - Prunes old archives locally and on the sync host (keep newest $KEEP).
# - Prunes old archives locally and on the sync host, keeping the newest $KEEP
# by filename plus the newest $KEEP that are SIGNED (see PRUNE_SCRIPT), so a
# prune can never evict the archive the sync host advertises as latest.
# - flock guard so overlapping cron runs cannot collide.
#
# ┌─ DOWNTIME WARNING ──────────────────────────────────────────────────────┐
Expand Down Expand Up @@ -111,6 +113,70 @@ done
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
die() { log "FATAL: $*"; exit 1; }

# ── Retention policy ──────────────────────────────────────────────────────
# One policy body, piped to `sh -s` for BOTH the sync host (over ssh) and the
# local stage, so the two sides cannot drift the way the producer and the server
# already did.
#
# Serving resolves "latest" as the newest archive that HAS a paired .sig,
# ordered by the UTC timestamp in the FILENAME (sync host latest.php). A prune
# keyed on mtime that lets unsigned archives occupy retention slots therefore
# deletes the exact file the server advertises: with KEEP=2, two unsigned
# strays (an --allow-unsigned run, an operator drop) fill both slots and the
# newest SIGNED archive goes, leaving consumers a 404 and a forced full resync
# while a perfectly good archive existed a moment earlier. mtime is the wrong
# key besides, because a backfilled or re-uploaded archive carries upload time,
# not its name's timestamp.
#
# So retain the union of the newest KEEP by filename and the newest KEEP that
# are signed. The union is never smaller than the by-name set alone, so this
# deletes no more than a name-keyed prune, it cannot evict the serve target, and
# it still bounds a directory at 2*KEEP archives. That bias matters because
# deletions here fan out to the public web tier by a downstream rsync
# --delete-after.
#
# Never candidates: latest.tgz / latest.tar.gz (the hand-placed manual-publish
# aliases, which the server lets win on their own route) and *.part uploads in
# flight. An empty retention set aborts the directory rather than deleting
# everything in it.
PRUNE_SCRIPT=$(cat <<'PRUNE_EOF'
dir="$1"; keep="$2"
[ -n "$dir" ] && [ -d "$dir" ] || exit 0
case "$keep" in ''|*[!0-9]*) exit 0 ;; esac
[ "$keep" -ge 1 ] || exit 0
cd "$dir" || exit 0

all=$(ls -1 ./*.tar.gz 2>/dev/null | sed 's#^\./##' | grep -v '^latest\.tar\.gz$' | LC_ALL=C sort -r)
[ -n "$all" ] || exit 0

signed=$(printf '%s\n' "$all" | while IFS= read -r f; do
[ -f "$f.sig" ] && printf '%s\n' "$f"
done)
retain=$({ printf '%s\n' "$all" | head -n "$keep"
[ -n "$signed" ] && printf '%s\n' "$signed" | head -n "$keep"
} | sed '/^$/d' | LC_ALL=C sort -u)

# With candidates present, an empty retention set can only mean the selection
# above failed, and "retain nothing" here means "delete every archive".
[ -n "$retain" ] || { echo " PRUNE ABORTED in $dir: empty retention set, nothing deleted"; exit 0; }

printf '%s\n' "$all" | while IFS= read -r f; do
[ -n "$f" ] || continue
printf '%s\n' "$retain" | grep -q -x -F -- "$f" && continue
rm -f -- "$f" "$f.sig" "$f.sha256"
done

left=$(printf '%s\n' "$retain" | while IFS= read -r f; do
[ -n "$f" ] && [ -f "$f.sig" ] && printf '%s\n' "$f"
done | wc -l | tr -d ' ')
if [ "$left" -gt 0 ]; then
echo " prune: $left signed archive(s) retained in $dir"
else
echo " PRUNE WARNING: no signed archive remains in $dir; the latest endpoint 404s there"
fi
PRUNE_EOF
)

# ── Preconditions ─────────────────────────────────────────────────────────
command -v "$XCHAIN_NODE_BIN" >/dev/null || die "xchain-node CLI not found ($XCHAIN_NODE_BIN)"
if [ ! -f "$SIGNING_KEY" ]; then
Expand Down Expand Up @@ -249,16 +315,16 @@ for c in "${SELECTED[@]}"; do
if ssh -o BatchMode=yes "$SYNC_HOST" "$mv_cmd"; then
log " published $a_base (+sig) to $SYNC_HOST:$dest"
SUMMARY+=("$c: PUBLISHED")
# Prune remote: keep newest $KEEP archives + their sidecars.
ssh -o BatchMode=yes "$SYNC_HOST" "cd '$dest' && ls -t *.tar.gz 2>/dev/null | tail -n +$((KEEP+1)) | while read -r f; do rm -f \"\$f\" \"\$f.sig\" \"\$f.sha256\"; done" || log " (remote prune warning)"
# Prune remote under the shared retention policy (see PRUNE_SCRIPT).
printf '%s\n' "$PRUNE_SCRIPT" | ssh -o BatchMode=yes "$SYNC_HOST" "sh -s -- '$dest' '$KEEP'" || log " (remote prune warning)"
else
cleanup_parts
log " publish rename FAILED"; SUMMARY+=("$c: PUBLISH-FAIL"); fail=1; continue
fi
fi

# Prune local stage: keep newest $KEEP archives + sidecars to bound disk use.
( cd "$out_dir" && ls -t ./*.tar.gz 2>/dev/null | tail -n +$((KEEP+1)) | while read -r f; do rm -f "$f" "$f.sig" "$f.sha256"; done ) || true
# Prune the local stage under the SAME policy, to bound disk use.
printf '%s\n' "$PRUNE_SCRIPT" | sh -s -- "$out_dir" "$KEEP" || true
done

log "── summary ──"
Expand Down
Loading
Loading