From 8ee9bb92ae66660f98d10b88b0d009fd0f6d4d22 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 27 Aug 2026 13:00:49 +0100 Subject: [PATCH 1/5] build: Publish Clio to packages.xrplf.org (#3192) Co-authored-by: Bart --- .github/workflows/release.yml | 22 +++++++ .../workflows/reusable-publish-package.yml | 61 +++++++++++++++++++ README.md | 3 +- cmake/ClioVersion.cmake | 10 ++- cmake/pkg/deb.cmake | 2 +- docs/install-clio.md | 35 +++++++++++ 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/reusable-publish-package.yml create mode 100644 docs/install-clio.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c674ff43..2bffe1896 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,18 @@ concurrency: cancel-in-progress: true jobs: + release-info: + name: Determine release info + runs-on: ubuntu-latest + outputs: + channel: ${{ steps.release_info.outputs.channel }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - id: release_info + uses: XRPLF/actions/release-info@7cc0e4a8d9d0b838f92c48d312856b190341bbba + build-and-test: name: Build and Test @@ -60,6 +72,16 @@ jobs: targets: package analyze_build_time: false + publish_package: + name: Publish debian package + needs: [release-info, package] + uses: ./.github/workflows/reusable-publish-package.yml + with: + channel: ${{ needs.release-info.outputs.channel }} + secrets: + nexus_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + nexus_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} + release: needs: [build-and-test, package] uses: ./.github/workflows/reusable-release.yml diff --git a/.github/workflows/reusable-publish-package.yml b/.github/workflows/reusable-publish-package.yml new file mode 100644 index 000000000..73645a621 --- /dev/null +++ b/.github/workflows/reusable-publish-package.yml @@ -0,0 +1,61 @@ +# See docs/install-clio.md, "Publishing". +name: Reusable publish package + +on: + workflow_call: + inputs: + channel: + description: "Release channel to publish to, selecting the deb- repository" + required: true + type: string + + secrets: + nexus_username: + description: Username of a Nexus account with write access to the repositories + required: false + nexus_password: + description: Password or token for that Nexus account + required: false + +defaults: + run: + shell: bash + +env: + PACKAGE_DIR: packages + +jobs: + publish: + name: Publish debian package + runs-on: heavy + container: + image: ghcr.io/xrplf/xrpld/packaging-debian:sha-b6a8995 + + permissions: + contents: read + + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ${{ env.PACKAGE_DIR }} + pattern: clio_deb_package_* + + # dpkg-deb refuses to read a version CPack should not have produced. + - name: Verify packages + run: | + shopt -s globstar nullglob + for package in "${PACKAGE_DIR}"/**/*.deb; do + dpkg-deb --field "${package}" Package Version Architecture + done + + - name: Publish packages + env: + CHANNEL: ${{ inputs.channel }} + NEXUS_USERNAME: ${{ secrets.nexus_username }} + NEXUS_PASSWORD: ${{ secrets.nexus_password }} + DRY_RUN_OPTION: ${{ (github.repository == 'XRPLF/clio' && github.event_name != 'pull_request') && '' || '--dry-run' }} + run: | + publish_pkg.py \ + --channel "${CHANNEL}" \ + --package-dir "${PACKAGE_DIR}" \ + ${DRY_RUN_OPTION} diff --git a/README.md b/README.md index dbf79e156..3319db2a9 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ Below are some useful docs to learn more about Clio. **For Operators**: -- [How to configure Clio and rippled](./docs/configure-clio.md) +- [How to install Clio](./docs/install-clio.md) +- [How to configure Clio and xrpld](./docs/configure-clio.md) - [How to run Clio](./docs/run-clio.md) - [Troubleshooting guide](./docs/trouble_shooting.md) diff --git a/cmake/ClioVersion.cmake b/cmake/ClioVersion.cmake index 52959474b..ca5e99bef 100644 --- a/cmake/ClioVersion.cmake +++ b/cmake/ClioVersion.cmake @@ -54,8 +54,16 @@ else() string(SUBSTRING ${GIT_COMMIT_HASH} 0 7 GIT_COMMIT_HASH_SHORT) + # Debian package versions may only contain alphanumerics and '.+~-' + string( + REGEX REPLACE "[^a-zA-Z0-9.+~-]" + "." + SANITIZED_BUILD_BRANCH + "${GIT_BUILD_BRANCH}" + ) + set(CLIO_VERSION - "${BUILD_DATE}-${GIT_BUILD_BRANCH}-${GIT_COMMIT_HASH_SHORT}" + "${BUILD_DATE}-${SANITIZED_BUILD_BRANCH}-${GIT_COMMIT_HASH_SHORT}" ) set(DOC_CLIO_VERSION "develop") endif() diff --git a/cmake/pkg/deb.cmake b/cmake/pkg/deb.cmake index 67eabd4c9..dd6b8a793 100644 --- a/cmake/pkg/deb.cmake +++ b/cmake/pkg/deb.cmake @@ -9,4 +9,4 @@ set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA ${CMAKE_SOURCE_DIR}/cmake/pkg/postinst) # We must replace "-" with "~" otherwise dpkg will sort "X.Y.Z-b1" as greater than "X.Y.Z" -string(REPLACE "-" "~" git "${CPACK_PACKAGE_VERSION}") +string(REPLACE "-" "~" CPACK_DEBIAN_PACKAGE_VERSION "${CPACK_PACKAGE_VERSION}") diff --git a/docs/install-clio.md b/docs/install-clio.md new file mode 100644 index 000000000..bdfa1d880 --- /dev/null +++ b/docs/install-clio.md @@ -0,0 +1,35 @@ +# How to install Clio + +Clio is published as a Debian package for 64-bit x86 Linux, in the XRPL Foundation repositories at . To build from source instead, see [How to build Clio](./build-clio.md). + +## Install + +Clio publishes to the same repositories as [`xrpld`](https://github.com/XRPLF/rippled), so configure APT by following [its instructions](https://github.com/XRPLF/rippled/blob/develop/docs/install.md#with-the-apt-package-manager), which also document the [release channels](https://github.com/XRPLF/rippled/blob/develop/docs/install.md#release-channels), and install `clio` in place of `xrpld`: + +```bash +sudo apt -y install clio +``` + +## The clio service + +The package installs `/opt/clio/bin/clio_server` (symlinked into `/usr/bin`), a config at `/opt/clio/etc/config.json`, a log directory at `/var/log/clio`, and a systemd unit, all owned by the `clio` system user it creates. + +The unit is not enabled, as Clio needs a configured database and `xrpld` node before it can start. Edit the config — see [How to configure Clio and xrpld](./configure-clio.md) — then: + +```bash +sudo systemctl enable --now clio +``` + +Upgrades do not restart a running server; `systemctl restart clio` picks up the new binary. + +## Publishing + +Only tags are published. CPack builds the package in the `package` job of [`release.yml`](../.github/workflows/release.yml), and [`reusable-publish-package.yml`](../.github/workflows/reusable-publish-package.yml) uploads it with `publish_pkg.py` from the [`xrpld` packaging image](https://github.com/XRPLF/rippled/blob/develop/package/README.md#publishing-packages), so that Clio and `xrpld` agree on what a channel means. [`XRPLF/actions/release-info`](https://github.com/XRPLF/actions/blob/main/release-info/action.yml) picks the channel: + +| Tag | Package version | Channel | +| ----------- | --------------- | -------- | +| `X.Y.Z` | `X.Y.Z` | `stable` | +| `X.Y.Z-rcN` | `X.Y.Z~rcN` | `rc` | +| `X.Y.Z-bN` | `X.Y.Z~bN` | `beta` | + +[`cmake/pkg/deb.cmake`](../cmake/pkg/deb.cmake) converts the `-` to `~`, which Debian sorts below everything, so `X.Y.Z~rc1` ranks below the `X.Y.Z` it precedes. From bab8789bbb7eaf314bafad1657e9e57fd2edd6b6 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 27 Aug 2026 14:03:04 +0100 Subject: [PATCH 2/5] ci: Fix when dry-run flag is applied (#3193) --- .github/workflows/reusable-publish-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-publish-package.yml b/.github/workflows/reusable-publish-package.yml index 73645a621..8143fb955 100644 --- a/.github/workflows/reusable-publish-package.yml +++ b/.github/workflows/reusable-publish-package.yml @@ -53,7 +53,7 @@ jobs: CHANNEL: ${{ inputs.channel }} NEXUS_USERNAME: ${{ secrets.nexus_username }} NEXUS_PASSWORD: ${{ secrets.nexus_password }} - DRY_RUN_OPTION: ${{ (github.repository == 'XRPLF/clio' && github.event_name != 'pull_request') && '' || '--dry-run' }} + DRY_RUN_OPTION: ${{ (github.event_name == 'pull_request' || github.repository != 'XRPLF/clio') && '--dry-run' || '' }} run: | publish_pkg.py \ --channel "${CHANNEL}" \ From 58d8f12ba32c8192c911b87e856ad65b1e87b517 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 27 Aug 2026 16:40:19 +0100 Subject: [PATCH 3/5] test: Use credentialTypeValidator in LedgerEntry (#3195) --- tests/unit/rpc/handlers/LedgerEntryTests.cpp | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/rpc/handlers/LedgerEntryTests.cpp b/tests/unit/rpc/handlers/LedgerEntryTests.cpp index bece6a52a..8f968fbce 100644 --- a/tests/unit/rpc/handlers/LedgerEntryTests.cpp +++ b/tests/unit/rpc/handlers/LedgerEntryTests.cpp @@ -2025,6 +2025,38 @@ generateTestValuesForParametersTest() .expectedError = "malformedRequest", .expectedErrorMessage = "Malformed request." }, + ParamTestCaseBundle{ + .testName = "CredentialCredentialTypeNotHex", + .testJson = fmt::format( + R"JSON({{ + "credential": {{ + "subject": "{}", + "issuer": "{}", + "credential_type": "hello world" + }} + }})JSON", + kAccount, + kAccount2 + ), + .expectedError = "malformedAuthorizedCredentials", + .expectedErrorMessage = "credential_type NotHexString" + }, + ParamTestCaseBundle{ + .testName = "CredentialCredentialTypeEmpty", + .testJson = fmt::format( + R"JSON({{ + "credential": {{ + "subject": "{}", + "issuer": "{}", + "credential_type": "" + }} + }})JSON", + kAccount, + kAccount2 + ), + .expectedError = "malformedAuthorizedCredentials", + .expectedErrorMessage = "credential_type is empty" + }, ParamTestCaseBundle{ .testName = "InvalidMPTokenAccount", .testJson = fmt::format( From e5f11df558c2222dba913831fa62043e0cd5d4f0 Mon Sep 17 00:00:00 2001 From: Alex Kremer Date: Fri, 28 Aug 2026 16:32:29 +0100 Subject: [PATCH 4/5] fix: Rare race in cluster tests (#3194) --- tests/unit/cluster/BackendTests.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/unit/cluster/BackendTests.cpp b/tests/unit/cluster/BackendTests.cpp index c856de273..64cdbca1c 100644 --- a/tests/unit/cluster/BackendTests.cpp +++ b/tests/unit/cluster/BackendTests.cpp @@ -40,13 +40,13 @@ struct ClusterBackendTest : util::prometheus::WithPrometheus, MockBackendTestStr testing::StrictMock< testing::MockFunction)>> callbackMock; - std::binary_semaphore semaphore{0}; + std::counting_semaphore<> semaphore{0}; class SemaphoreReleaseGuard { - std::binary_semaphore& semaphore_; + std::counting_semaphore<>& semaphore_; public: - SemaphoreReleaseGuard(std::binary_semaphore& s) : semaphore_(s) + SemaphoreReleaseGuard(std::counting_semaphore<>& s) : semaphore_(s) { } ~SemaphoreReleaseGuard() @@ -72,7 +72,7 @@ TEST_F(ClusterBackendTest, SubscribeToNewState) EXPECT_CALL(*backend_, fetchClioNodesData) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(BackendInterface::ClioNodesDataFetchResult{})); - EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AtLeast(1)); + EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AnyNumber()); EXPECT_CALL(writerStateRef, isReadOnly) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(true)); @@ -151,7 +151,7 @@ TEST_F(ClusterBackendTest, FetchClioNodesDataThrowsException) EXPECT_CALL(*backend_, fetchClioNodesData) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Throw(std::runtime_error("Database connection failed"))); - EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AtLeast(1)); + EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AnyNumber()); EXPECT_CALL(writerStateRef, isReadOnly) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(true)); @@ -208,7 +208,7 @@ TEST_F(ClusterBackendTest, FetchClioNodesDataReturnsDataWithOtherNodes) } ) ); - EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AtLeast(1)); + EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AnyNumber()); EXPECT_CALL(writerStateRef, isReadOnly) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(false)); @@ -286,7 +286,7 @@ TEST_F(ClusterBackendTest, FetchClioNodesDataReturnsOnlySelfData) } }; }); - EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AtLeast(1)); + EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AnyNumber()); EXPECT_CALL(writerStateRef, isReadOnly) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(true)); @@ -342,7 +342,7 @@ TEST_F(ClusterBackendTest, FetchClioNodesDataReturnsInvalidJson) } ) ); - EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AtLeast(1)); + EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AnyNumber()); EXPECT_CALL(writerStateRef, isReadOnly) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(true)); @@ -398,7 +398,7 @@ TEST_F(ClusterBackendTest, FetchClioNodesDataReturnsValidJsonButCannotConvertToC } ) ); - EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AtLeast(1)); + EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AnyNumber()); EXPECT_CALL(writerStateRef, isReadOnly) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(true)); @@ -536,7 +536,7 @@ TEST_F(ClusterBackendTest, SubscribeToNewStateReflectsCacheIsCurrentlyLoading) EXPECT_CALL(*backend_, fetchClioNodesData) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(BackendInterface::ClioNodesDataFetchResult{})); - EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AtLeast(1)); + EXPECT_CALL(*backend_, writeNodeMessage).Times(testing::AnyNumber()); EXPECT_CALL(writerStateRef, isReadOnly) .Times(testing::AtLeast(1)) .WillRepeatedly(testing::Return(true)); From 9cd44a06dd3e1f3835ce2ed2975dd61bdcfe4b03 Mon Sep 17 00:00:00 2001 From: Alex Kremer Date: Fri, 28 Aug 2026 16:32:50 +0100 Subject: [PATCH 5/5] fix: Internal error in `account_info` with pseudo account (#3196) --- src/rpc/handlers/AccountInfo.cpp | 2 +- tests/unit/rpc/handlers/AccountInfoTests.cpp | 74 ++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/rpc/handlers/AccountInfo.cpp b/src/rpc/handlers/AccountInfo.cpp index 16d02f484..30db89f95 100644 --- a/src/rpc/handlers/AccountInfo.cpp +++ b/src/rpc/handlers/AccountInfo.cpp @@ -182,7 +182,7 @@ tag_invoke( ASSERT(!name.empty(), "Field name is empty after stripping 'ID'"); } // ValidPseudoAccounts invariant guarantees that only one field can be set - jv.as_object()[JS(pseudo_account)].as_object()[JS(type)] = name; + jv.as_object()[JS(pseudo_account)] = boost::json::object{{JS(type), name}}; break; } } diff --git a/tests/unit/rpc/handlers/AccountInfoTests.cpp b/tests/unit/rpc/handlers/AccountInfoTests.cpp index 481e2c705..85a1b3c34 100644 --- a/tests/unit/rpc/handlers/AccountInfoTests.cpp +++ b/tests/unit/rpc/handlers/AccountInfoTests.cpp @@ -437,6 +437,80 @@ TEST_F(RPCAccountInfoHandlerTest, SignerListsTrueV2) }); } +TEST_F(RPCAccountInfoHandlerTest, PseudoAccountReportsType) +{ + auto const expectedOutput = fmt::format( + R"JSON({{ + "account_data": {{ + "Account": "{}", + "AMMID": "{}", + "Balance": "200", + "Flags": 0, + "LedgerEntryType": "AccountRoot", + "OwnerCount": 2, + "PreviousTxnID": "{}", + "PreviousTxnLgrSeq": 2, + "Sequence": 2, + "TransferRate": 0, + "index": "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8" + }}, + "account_flags": {{ + "defaultRipple": false, + "depositAuth": false, + "disableMasterKey": false, + "disallowIncomingXRP": false, + "globalFreeze": false, + "noFreeze": false, + "passwordSpent": false, + "requireAuthorization": false, + "requireDestinationTag": false + }}, + "pseudo_account": {{ + "type": "AMM" + }}, + "ledger_hash": "{}", + "ledger_index": 30, + "validated": true + }})JSON", + kAccount, + kIndex1, + kIndex1, + kLedgerHash + ); + + auto const ledgerHeader = createLedgerHeader(kLedgerHash, 30); + EXPECT_CALL(*backend_, fetchLedgerBySequence).WillOnce(Return(ledgerHeader)); + + auto const account = getAccountIdWithString(kAccount); + auto const accountKk = xrpl::keylet::account(account).key; + auto const accountRoot = + createAccountRootObject(kAccount, 0, 2, 200, 2, kIndex1, 2, 0, xrpl::uint256{kIndex1}); + ON_CALL(*backend_, doFetchLedgerObject(accountKk, 30, _)) + .WillByDefault(Return(accountRoot.getSerializer().peekData())); + EXPECT_CALL(*mockAmendmentCenterPtr_, isEnabled(_, Amendments::DisallowIncoming, _)) + .WillOnce(Return(false)); + EXPECT_CALL(*mockAmendmentCenterPtr_, isEnabled(_, Amendments::Clawback, _)) + .WillOnce(Return(false)); + EXPECT_CALL(*mockAmendmentCenterPtr_, isEnabled(_, Amendments::TokenEscrow, _)) + .WillOnce(Return(false)); + EXPECT_CALL(*backend_, doFetchLedgerObject).Times(1); + + static auto const kInput = boost::json::parse( + fmt::format( + R"JSON({{ + "account": "{}" + }})JSON", + kAccount + ) + ); + auto const handler = AnyHandler{AccountInfoHandler{backend_, mockAmendmentCenterPtr_}}; + runSpawn([&](auto yield) { + auto const output = handler.process(kInput, Context{.yield = yield, .apiVersion = 2}); + ASSERT_TRUE(output); + EXPECT_EQ(*output.result, boost::json::parse(expectedOutput)); + }); +} + TEST_F(RPCAccountInfoHandlerTest, SignerListsTrueV1) { auto const expectedOutput = fmt::format(