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
17 changes: 17 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- Thanks for contributing to XChain Platform. -->

## What this changes

<!-- One or two sentences on what changed and why. Link an issue if there is one. -->

## How it was verified

<!-- What you ran, and what it said. "Tests pass" is worth more with the tally. -->

## Contributor License Agreement

- [ ] I have read and agree to the [Contributor License Agreement](https://docs.xchain.io/legal/cla).

The CLA Assistant bot checks this automatically and records your signature
against your GitHub account, once, covering all XChain Platform repositories.
Its `license/cla` check is the record that counts; this box is a reminder.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ on:

jobs:
ci:
uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@e2d578827928e79ec71c9b6afc4595025dc025fe # pin: XChain-Platform/.github @ master 2026-08-13; bump deliberately
uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately
# Override the Node version for a repo if ever needed:
# with:
# node-version: "20"
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/nightly-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ on:
# covering the changed set only.
description: 'Ref to install the stack at (branch, release/vX.Y.Z, or a published vX.Y.Z)'
type: string
default: master
default: develop

permissions:
contents: read
Expand All @@ -86,7 +86,7 @@ jobs:
# 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 || 'master' }}
STACK_REF: ${{ github.event.inputs.ref || 'develop' }}
# 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 All @@ -109,7 +109,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || 'master' }}
ref: ${{ github.event.inputs.ref || 'develop' }}

- uses: actions/setup-node@v4
with:
Expand Down Expand Up @@ -170,7 +170,7 @@ jobs:
git config --global url."https://github.com/".insteadOf "git@github.com:"
fi

- name: Boot the regtest stack (clones every service at ${{ github.event.inputs.ref || 'master' }})
- 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).
# Note: the external-DB connection for the install process itself is
Expand Down
104 changes: 104 additions & 0 deletions .github/workflows/verify-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Train tag gate: every vX.Y.Z tag in this repo must be signed by the XChain
# Platform release key, and must name the version the commit actually carries.
#
# WHY THIS EXISTS. The release-manifest chain starts at the tag: the tag
# signature proves who cut the release, SHA256SUMS.asc proves the asset set is
# theirs, the manifest pins every component, and clone verification proves the
# installed tree is that commit. An unsigned train tag is not a style lapse, it
# is the root of that chain missing, and it cannot be fixed after the fact:
# re-signing means deleting and re-pushing the tag, which branch protection
# refuses and which breaks the sparse-tag invariant. A tag cut unsigned stays
# unsigned, so this gate has to exist before a train is cut, not after.
#
# THIS FILE IS A TWIN. It is byte-identical in every train repo (nothing in it
# is repo-specific) and a platform-side test enforces that. Edit it in one place
# and re-copy; a per-repo edit is how nine gates stop being one gate.
#
# NOT the wallet's keys. The wallet signs its tags with K14 and its release
# manifests with K1, and confusing the three is a named hazard. This gate pins
# the PLATFORM key by fingerprint, from a file in this repo.
name: Verify tag

on:
push:
tags:
- 'v*'

# Never cancel a tag verification in flight: a cancelled run reads as "nothing
# went wrong" and this is the one check that must have said yes out loud.
concurrency:
group: verify-tag-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

jobs:
verify-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

# actions/checkout recreates the TRIGGERING tag as a lightweight ref
# pointing straight at the commit, which discards the annotated tag object
# and with it the signature. Every check below would then fail as "cannot
# verify a non-tag object of type commit": not because the tag is
# unsigned, but because the runner no longer has a tag to verify. Re-fetch
# by force so what gets verified is the object the maintainer signed.
# (Learned the hard way in xchain-wallet's first release run.)
- name: Restore the annotated tag object (checkout flattens it)
run: git fetch --force origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"

- name: Tag must be signed by the XChain Platform release key
run: |
set -euo pipefail
KEY="tools/release/release-signing-key.asc"
FPR_FILE="tools/release/release-signing-fingerprint.txt"

EXPECTED="$(tr -d ' \n' < "$FPR_FILE" || true)"

# A real fingerprint or nothing: a placeholder, an empty file or any
# other malformed value is refused rather than read as "unpinned, so
# allow". A gate that defaults to allow when unconfigured is not a gate.
if ! printf '%s' "$EXPECTED" | grep -qiE '^[0-9A-F]{40}$'; then
echo "::error::the release key is not pinned"
echo " $FPR_FILE reads '${EXPECTED}', not a 40-hex fingerprint."
exit 1
fi

gpg --batch --import "$KEY"
# Trust the pinned key ultimately so verification fails on the
# SIGNATURE rather than on the web of trust.
echo "${EXPECTED}:6:" | gpg --batch --import-ownertrust

if ! git verify-tag --raw "${GITHUB_REF_NAME}" 2>verify.txt; then
echo "::error::tag ${GITHUB_REF_NAME} is not signed by a key we trust"
sed 's/^/ /' verify.txt
exit 1
fi

# `git verify-tag` succeeding is not the verdict: it passes for ANY
# key in the keyring. Bind it to the pinned fingerprint explicitly.
if ! grep -q "VALIDSIG ${EXPECTED}" verify.txt; then
echo "::error::tag ${GITHUB_REF_NAME} is signed, but not by the pinned release key"
echo " expected fingerprint: ${EXPECTED}"
sed 's/^/ /' verify.txt
exit 1
fi

echo "tag ${GITHUB_REF_NAME} verified against ${EXPECTED}"

- name: Tag must match the committed version
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
VERSION="v$(node -p "require('./package.json').version")"
if [ "$TAG" != "$VERSION" ]; then
echo "::error::tag $TAG does not match package.json version $VERSION"
echo " A train tag is cut on the master merge commit that carries the"
echo " version bump; a mismatch means the tag was cut from the wrong SHA."
exit 1
fi
echo "$TAG matches package.json at $(git rev-parse HEAD)"
8 changes: 4 additions & 4 deletions bin/coverage-thresholds.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured on 2026-08-12, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json: keep both in sync.",
"lines": 87.5,
"statements": 87.5,
"comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/coverage-thresholds-sync.test.js. Re-measured 2026-08-15 against a full sibling checkout: 92.44 lines/statements, 87.61 branches, 90.77 functions over 1477 unit tests. The previous floors came from a 2026-08-12 run and sat nearly 5 points under the lines figure, wide enough for a real regression to pass unseen. This repo declares no .ci-siblings; its one cross-repo guard is the xchain-hub coins byte-identity check, which reads files rather than executing src, so the sibling-less CI run measures the same surface.",
"lines": 91,
"statements": 91,
"branches": 86.5,
"functions": 88.5
"functions": 89.5
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"scripts": {
"test": "mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive",
"coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit",
"coverage:check": "c8 --check-coverage --lines 87.5 --statements 87.5 --branches 86.5 --functions 88.5 --reporter=text-summary --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit",
"coverage:check": "c8 --check-coverage --lines 91 --statements 91 --branches 86.5 --functions 89.5 --reporter=text-summary --include 'src/**/*.js' mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit",
"ci": "mocha 'test/unit/**/*.test.js' --timeout 2000 --recursive --exit && npm run ci:security && npm run ci:regression",
"ci:security": "mocha 'test/security/**/*.test.js' --timeout 10000 --recursive --exit",
"ci:regression": "mocha 'test/regression/**/*.test.js' --timeout 30000 --recursive --exit",
Expand Down
11 changes: 11 additions & 0 deletions src/operations/moduleOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const { createDockerNetwork, killContainer, removeContainer, forceRemoveContaine
const { buildDatabaseModule, resetDatabases, clearHubPriceIngestWatermark, getDatabaseContainerId } = require('../services/DatabaseService')
const { getModuleBranch, installModule, uninstallModule } = require('../services/ModuleService')
const { assertHubNotBehind } = require('../services/SkewGuardService')
const { assertRequiredMigrationsApplied } = require('../services/MigrationPreconditionService')
const { statusChanged } = require('../services/StatusService')

// Resolve the operator's single ref slot into an install target and publish it
Expand Down Expand Up @@ -204,6 +205,16 @@ async function updateModulesOnBranch(servicesList, branch = null) {
const { resolveComponentRef } = require('../services/ReleaseManifestService')
const pin = resolveComponentRef(nextModule, moduleBranch)
await assertHubNotBehind(nextModule, pin.ref)
// Migration-precondition guard: a service whose new source asserts a
// GATED (mode=manual) migration at startup is REFUSED when the database
// it will use has not applied that migration, before anything is torn
// down. Without it the only thing that discovers the requirement is the
// recreated container crash-looping - which is exactly how a routine
// indexer deploy took all three mainnet indexers down on 2026-08-09.
// Reads the same PINNED ref as the skew guard above, for the same
// reason: a precondition read from a different ref than the one being
// installed is a check that blessed a version it never saw.
await assertRequiredMigrationsApplied(nextModule, nextCoin, nextNetwork, pin.ref)
// moduleBranch MUST be threaded through: installModule re-clones the
// module on the remoteUpdate path (cloneGit with this `branch`), so a
// null branch here re-clones the default branch and clobbers the branch
Expand Down
12 changes: 12 additions & 0 deletions src/services/DatabaseService.js
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,18 @@ async function buildDatabaseModule(coin, network) {
})
const containerId = stdout.trim()
if (/^[a-f0-9]{64}$/.test(containerId)) {
// No db.insertModuleContainer(DB_MODULE_NAME, ...) here, unlike
// ModuleService.buildAndUp / NodeService, and that is a property of
// the ordering, not an oversight (XC-1473). The `modules` registry
// table lives inside the container we just created: there is no
// xchain_node database, no open pool and no table to insert into
// until later in the install, so the DB module cannot register
// itself in its own registry. Nothing needs it to: every lookup of
// this container goes through getDatabaseContainerId(), which reads
// the id from `docker inspect` on the container NAME for exactly
// that reason, and DiscoveryService.discoverContainers() writes the
// row once a registry exists (DB_MODULE_NAME is in its
// SHARED_MODULES list), which is what puts the database line in `ps`.
await statusChanged()
return containerId
}
Expand Down
Loading
Loading