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.
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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

# Cross-repo drift guards: this repo vendors the canonical coin registry from
# xchain-hub, and per-repo CI cannot see sibling repos, so a drifted vendored
Expand Down Expand Up @@ -102,6 +102,30 @@ jobs:
steps:
- uses: actions/checkout@v4

# The ratchet re-runs the unit suite, so it needs the same siblings the gate
# gets. Without them the guards that read a sibling skip silently, so the
# ratchet measures a suite the gate never ran: 1248 of 1289 unit tests ran without them.
# Same roster (.ci-siblings) and same layout the shared workflow uses.
- name: Check out declared sibling repositories
env:
SIBLINGS_REF: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }}
run: |
set -euo pipefail
repos=$(sed 's/#.*//' .ci-siblings | tr -d '\r' | awk 'NF')
cd "$GITHUB_WORKSPACE/.."
for repo in $repos; do
rm -rf "$repo"
url="https://github.com/${{ github.repository_owner }}/$repo.git"
git clone --quiet --depth 1 --branch "$SIBLINGS_REF" "$url" "$repo" 2>/dev/null \
|| git clone --quiet --depth 1 "$url" "$repo"
echo "sibling $repo @ $(git -C "$repo" rev-parse --abbrev-ref HEAD) $(git -C "$repo" rev-parse --short HEAD)"
if [ -f "$repo/package.json" ]; then
( cd "$repo" && npm ci --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \
|| ( cd "$repo" && npm install --omit=dev --ignore-scripts --no-audit --no-fund >/dev/null 2>&1 ) \
|| echo "sibling $repo: dependency install failed"
fi
done

- name: Use Node.js 22
uses: actions/setup-node@v4
with:
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)"
10 changes: 5 additions & 5 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": 86,
"statements": 86,
"branches": 84,
"functions": 75
"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-14 WITH the declared siblings checked out, which is what the coverage job now does: 89.30 lines/statements, 86.97 branches, 78.75 functions over 1289 unit tests (1248 without siblings).",
"lines": 87.8,
"statements": 87.8,
"branches": 85.4,
"functions": 77.2
}
12 changes: 6 additions & 6 deletions package-lock.json

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

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"migrate": "node ./src/migrate.js",
"test": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js' --exit",
"coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js' --exit",
"coverage:check": "c8 --check-coverage --lines 86 --statements 86 --branches 84 --functions 75 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js' --exit",
"coverage:check": "c8 --check-coverage --lines 87.8 --statements 87.8 --branches 85.4 --functions 77.2 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js' --exit",
"test:smoke": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/smoke/**/*.smoke.js'",
"test:unit": "mocha --timeout 5000 --require ./test/unit/setup.js 'test/unit/**/*.test.js'",
"ci": "npm run ci:unit && npm run ci:security && npm run ci:smoke && npm run ci:regression && npm run ci:chaos && npm run ci:fuzz",
Expand Down Expand Up @@ -85,7 +85,7 @@
"sinon": "^21.0.3"
},
"overrides": {
"brace-expansion": "^5.0.8",
"brace-expansion": "^5.0.9",
"minimatch": "^10.2.5",
"form-data": "^4.0.6",
"qs": "^6.15.2",
Expand All @@ -102,7 +102,7 @@
"@uphold/request-logger": {
"uuid": "^3.4.0"
},
"js-yaml": "^4.3.0"
"js-yaml": "^4.3.1"
},
"engines": {
"node": ">=22.0.0"
Expand Down
10 changes: 8 additions & 2 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ class Database {
try { await db.release(); } catch(_){}
return false;
}
// One summary line instead of a per-table pair; error paths below still
// name the table, so a failure stays attributable.
console.log('Verifying database and tables...');
let checked = 0;
let created = 0;
try {
for (file of files){
// indexOf returns -1 when '.sql' is absent (e.g. the migrations/ subdirectory).
Expand All @@ -192,7 +197,7 @@ class Database {
var isSql = file.indexOf('.sql');
if(isSql !== -1){
let table = file.substring(0, file.indexOf('.sql'));
console.log('Verifying ' + table + ' table exists...');
checked++;
try {
if(existing.has(table)){
// Existing table: reconcile column drift against the SQL
Expand All @@ -209,6 +214,7 @@ class Database {
} else {
await this.createTable(file, db);
existing.add(table);
created++;
}
} catch(e){
console.log('Error verifying table ' + table + ': ' + e.code);
Expand All @@ -224,6 +230,7 @@ class Database {
// one connection per created table plus this one and exhausts the pool.
try { await db.release(); } catch(_){}
}
console.log('Database and tables verified (' + checked + ' tables, ' + created + ' created).');
return true;
}

Expand Down Expand Up @@ -1089,7 +1096,6 @@ class Database {
// latent fresh-install-only bug.
let queries = this.splitSqlStatements(data);
let query = null;
console.log('Creating ' + table + ' table and indexes...');
try {
for(query of queries){
query = query.trim();
Expand Down
43 changes: 43 additions & 0 deletions test/unit/coverage-thresholds-sync.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');

// The coverage ratchet keeps its floors in two places: bin/coverage-thresholds.json,
// which is what a human reads, and the c8 flags inside the coverage:check npm script,
// which is what CI obeys. Every one of those files says "keep both in sync" and
// nothing enforced it, so a floor could describe a ratchet the job was not running.
// The failure mode is not hypothetical: xchain-dashboard's ci.yml called a
// coverage:check script that did not exist in that repo at all, a job that could only
// ever exit 1, and the missing-script case is asserted here for that reason.
describe('coverage ratchet floors', () => {
const repoRoot = path.join(__dirname, '..', '..');
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const declared = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'bin', 'coverage-thresholds.json'), 'utf8'),
);

it('ships the coverage:check script the CI coverage job invokes', () => {
assert.equal(
typeof (pkg.scripts || {})['coverage:check'],
'string',
'ci.yml runs `npm run coverage:check`; without the script the job can only exit 1',
);
});

it('enforces every declared floor, at the declared value', () => {
const script = pkg.scripts['coverage:check'];
for (const metric of ['lines', 'statements', 'branches', 'functions']) {
const flag = script.match(new RegExp('--' + metric + '\\s+([0-9.]+)'));
assert.ok(flag, `coverage:check does not enforce --${metric}, so that floor is decorative`);
assert.equal(
Number(flag[1]),
declared[metric],
`${metric} floor drifted: thresholds.json says ${declared[metric]}, coverage:check enforces ${flag[1]}`,
);
}
});

it('fails the job on a shortfall rather than only reporting it', () => {
assert.match(pkg.scripts['coverage:check'], /--check-coverage/);
});
});
1 change: 1 addition & 0 deletions tools/release/release-signing-fingerprint.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1DA7C4896F56EA22CF491EDF4361611A82F90B70
53 changes: 53 additions & 0 deletions tools/release/release-signing-key.asc
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----

mQINBGpinzcBEACrk6OdbxTbCX4VeZ3HxDUvQBMHrJ2CGwdZa0jUs6GdxqYiDFvZ
mL05IcwgN/pbBCuTmRvywOufflU+DfVfnUqClF8gyjNoX+cvY2Tqjt3jWYup76pe
JUFQEE1+jlCiq87RlPic2YutYRPNCRhm4TKzcy3Wh2F2joSYUjiRfIq/e4b5Hxcb
Jeg1xVU/pE6NwaTZuE0SzwVSRx64ZkyI9i5XBSncsMvfiPA8DTTR0uzkHEh1xFaH
u3l3sOpVfHCJ3LEaCX7s42mEUWJY1aFSEQW5kvn9fAS72JN+YXfCdaADtT/+J00h
3Dk/0Gy15FC0Tp9c5JpPgM3wzY6SLCLgHRQk32JHmvu9/iA9fJDGrqkMJxu7kDxq
kEnZWRyqpNoqvGtzlY8RwCpj+hpKiCWnLa+v2PyOMxWoQYL+KDWxlgr5w4jBQz7S
9CDYHYiR1SVv5SMt4+Z4W/3M7dBO/Iqs7K4OnF0A+LmRN729/xNz75tP4uDa+igu
sNvQx8sxrqmjm536fscSBq+3mH6rEADSKzKbDF6QsCslfMoT1sqt64b62YPJd55T
WBVqDd2J4zDwrODjY3BcND1jozFQyjsJQIYZHBj76LI7YHrBDVKqxz3z0X+h2bRr
0mlehRCCfRfLbW4aWppT+Gf8T9bpUCm0GOJ/vKTndq3QYSzMXVizxGvGQwARAQAB
tDRYQ2hhaW4gUGxhdGZvcm0gUmVsZWFzZSBTaWduaW5nIDxyZWxlYXNlc0B4Y2hh
aW4uaW8+iQJzBBMBCABdFiEEHafEiW9W6iLPSR7fQ2FhGoL5C3AFAmpinzcbFIAA
AAAABAAObWFudTIsMi41KzEuMTIsMCwzAhsDBQkSzAMABQsJCAcCAiICBhUKCQgL
AgQWAgMBAh4HAheAAAoJEENhYRqC+QtwH88P/iVKdS15rbBWIKYaE56wJdBXcTS4
8yL895Md12oUn8BDv8jToYiGVWJTzwRXanAOeBGM3isTMXOzjOQYYFsbo17CQ40q
woZC6IHXTRmQ4uGtKlmGiFwhrrBDbUut7LbuYCUXy/34f2HxlHbJeWQbRKPiAI5r
R0vzvz0QOdagCe5MzaItA5wp29C0i5UVOp1K502hxPIw04aEsULCtOk1X+kDFjjt
wIk8QMt6cJ/pFX6qJje05Nem4uSLFqkjcRMqjXfGmdNv+XohILl9QEAOVuuBevJ/
kQJEXeGpBeahOFVHVRVd/zXo2YCbaINiCAvN5g/OGlobNxfUCfAB4sVW+7OtgboZ
/PT1mepkRUdurbp84iC8ZQJLpUhlb3s7JRByxvv0Xy1+Hw9SO8+XsQnVJsxrzqjc
trgJz3OCvPgdUwCMALETSvpvnSqFYQSmQleURQ5I/bIYq7hSNaIGpyKjrMCSYPtC
ZPOrxji4alplbcJZPlbfpRJjZM7YAdHsGKyVOoi8BrD9eCngJMv7CWTh2/+HgT8w
EV5sm4vpLV0VykJbmobS+8+rPPgOHypj8itFiwROqZ8yIPgQMoZJkj8jsX59bm4P
vMCkv3lo6yOFVZoZwjRoR8HZl4mgal5g08rzDhyd9qRCBUShsB+nFDvxmbc5n77+
6jZLKNSrP6f0hsbpuQINBGpinzcBEADaUO264nv/LPoep+xTKvM3naE0EkHhkoiG
cB8xte8+CAIhoeYKA7XuX6BTZue/KGWlFsTOVvz8mverulVhoGXSP/aRXAwaNfr1
7EtNzZyEW5bm17Efsw4GVL6ko7Aj7VdO8cVpJCoYJI471ktAzffWcbnqpxyL5iRO
pMs2bvkDLJEIz35JQQ4QD5y2Cjw5WEGan4P3eAOjZZDILdKgmAE298o8WQBegGUG
aRTjt7GOMl6ZE0fdypIowU8LHrSJJMfCoQ6zE8VypPBE+pj3zgVzO5OqSPwPK0SG
2DkWxxalkzPNpLSSBY1G0xAx+crfCK4Mwc7tAljujJe2DW+GvWwT/+4K/ZH8ozJf
n7lNFwFwLrNkdBB+4Ryx312QxyWUC18yDmA+TsyY2UL0fAos8MB+rZwTraq9xipC
VhJ5Y3Zm6m7UUPTWdnTrjJAXqhYgVZe2M92TJL/0k3mLs/lA7Qjmoo6dE3jy2iAs
fPYnFOQilmDCR67AvTUSaXylBxZI41TF8Qi63T53htIa4EW3VEJNVirFR7BDmt7K
Je3/ct4c+5S1xWbsE3wGG+MEmhhA8dHGJuZYPwJ4RH1UuH+CbE6vEDmuD7Fu9VvH
nGL/rGwYkVCDLH/oI9KxRRA3iGuI92hFNnlxS27jYY/DqJKEEQbEIMV3fNCmd7j/
aF+wKtQCwQARAQABiQJYBBgBCABCFiEEHafEiW9W6iLPSR7fQ2FhGoL5C3AFAmpi
nzcbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMCwzAhsMBQkSzAMAAAoJEENhYRqC
+QtwYNgQAJYw1sskrb9Ptyu2b75Ry7JeH26KEgzp4VFC7aQsFfQlAjxcU9vjQ+TU
CLH55xQy9meEc9UrBSD1hodxRqiJ2vUy0a38sB3DNIpfACqbmOqdJ1FjyMv0hNXL
ndNE0nhGXh26nu5UUudnhziWwhj1XUhwMaa0y2mloSOBIxHQ6V7HeDOkZCgKloY5
bBnuYcTmTGB0BxsGLb9uKi3dQcUlWaLziAv0e/pGsi36q11MEXsPfhtS77Ngs8Cz
YKIrh/eQXlqYO/ByCjkuQwhgjRGjDOd8yB6cZ++YBy6yx7a/E2UrgVcjSf4ovGNy
pEW2KLqRtGUdnjHGONvHzmmndp/P5DyFRrn4gOODJMiZqTbjUJV8f9MV8VDfnq5D
DuG2KOWp0fZRLySSNMupQ5qlIKSSfTsm5ZJPImRfb90Lr+hK2mVqmxRy3C0dakYy
3LJscLVK3ayeo58Pd1s1uwmVeFfz9L/FDEh3scfBAkg9uYakosuRsfJhXGosw4+U
z9WcZNLLnSW3vrXmZ6QTRNJQSCX7Ncn/fp7FPJefZXVPtfQSAppFPYpifoaWcDe6
emX0xsw8/kAa1z7MA1f67zHiWGdtVZdiuVNlkULafUd3WdUWKmZi15pGheG5SEob
98rod/yeUUUw9WKdqPK0T2BZpVyFdiHFwr/bBkiVWmm9pipxz9Wt
=rTEm
-----END PGP PUBLIC KEY BLOCK-----
Loading