Compare commits

..

7 Commits

Author SHA1 Message Date
Jędrzej Stuczyński f0071f2115 bugfix: make sure to use old values from metrics debug config during v12 migration (#6546) 2026-03-10 16:41:25 +00:00
benedettadavico 7cccf3cfff update changelog 2026-03-10 10:46:12 +01:00
Jędrzej Stuczyński 02eec164f8 bugfix: lp information to have proper snake_case on API endpoints (#6531) 2026-03-09 14:56:31 +00:00
Jędrzej Stuczyński 4f13ab1e0a Merge pull request #6532 from nymtech/chore/reg-metrics
chore: introduce additional prometheus metrics for registration times
2026-03-09 14:56:18 +00:00
benedetta davico a34c7ef19f Merge pull request #6533 from nymtech/bugfix/lp-gateway-probe
bugfix: correctly populate gateway probe LP data
2026-03-09 15:55:00 +01:00
Jędrzej Stuczyński f00b18298c bugfix: correctly populate gateway probe LP data 2026-03-09 14:10:24 +00:00
Jędrzej Stuczyński 0426adc94e chore: introduce additional prometheus metrics for registration times 2026-03-09 13:50:46 +00:00
17 changed files with 192 additions and 299 deletions
@@ -15,9 +15,6 @@ env:
jobs:
publish-dry-run:
runs-on: arc-linux-latest
timeout-minutes: 35
env:
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- name: Checkout repo
uses: actions/checkout@v6
@@ -62,60 +59,20 @@ jobs:
- name: Bump versions (local only)
run: |
cargo workspaces version custom ${{ inputs.version }} \
--allow-branch ${{ github.ref_name }} \
--no-git-commit \
--yes
- name: Preflight publish checks
run: |
python3 tools/internal/check_publish_preflight.py
# Dry run may show cascading dependency errors because packages aren't
# actually uploaded - these are expected and ignored. We check for real
# errors like packaging failures, missing metadata, or invalid Cargo.toml.
- name: Publish (dry run)
run: |
set +e
publish_status=1
max_attempts=2
attempt=1
rm -f /tmp/publish-dry-run.log
output=$(cargo workspaces publish --dry-run --allow-dirty 2>&1) || true
echo "$output"
while [ "$attempt" -le "$max_attempts" ]; do
echo "Dry-run publish attempt ${attempt}/${max_attempts}"
cargo workspaces publish --dry-run --allow-dirty 2>&1 | tee /tmp/publish-dry-run.log
publish_status=${PIPESTATUS[0]}
if [ "$publish_status" -eq 0 ]; then
break
fi
# Retry once for interruption/runner issues.
if [ "$attempt" -lt "$max_attempts" ] && \
{ [ "$publish_status" -eq 130 ] || [ "$publish_status" -eq 137 ]; }; then
echo "Publish dry-run interrupted (exit ${publish_status}), retrying in 10s..."
sleep 10
attempt=$((attempt + 1))
continue
fi
break
done
set -e
if grep -Eiq \
"failed to verify manifest|failed to parse manifest|invalid Cargo.toml|error: package .* has no (description|license|repository)" \
/tmp/publish-dry-run.log; then
echo "Detected real packaging/manifest errors"
exit 1
fi
# In dry-run mode, non-zero publish status is expected due to
# dependency-cascade failures against crates.io index.
if [ "$publish_status" -ne 0 ]; then
echo "Dry-run publish returned non-zero (${publish_status}) but no real manifest blockers were detected."
fi
echo "Only expected dry-run dependency cascade errors detected (if any)."
# Check for real errors (not cascading dependency errors)
# Cascading errors mention "crates.io index", real errors mention "Cargo.toml"
echo "$output" | grep -i "Cargo.toml" && exit 1 || true
# Show the list of packages published
- name: Show package versions
@@ -17,8 +17,6 @@ on:
jobs:
publish:
runs-on: arc-linux-latest
env:
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- name: Checkout repo
uses: actions/checkout@v6
-2
View File
@@ -17,8 +17,6 @@ on:
jobs:
publish:
runs-on: arc-linux-latest
env:
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- name: Checkout repo
uses: actions/checkout@v6
@@ -15,8 +15,6 @@ env:
jobs:
version-bump:
runs-on: arc-linux-latest
env:
RUSTUP_PERMIT_COPY_RENAME: 1
permissions:
contents: write
steps:
@@ -25,10 +25,6 @@ jobs:
- name: Install cargo-workspaces
run: cargo install cargo-workspaces
- name: Preflight publish checks
run: |
python3 tools/internal/check_publish_preflight.py
- name: Publish remaining crates
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+46
View File
@@ -4,6 +4,52 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
## [Unreleased]
## [2026.5-raclette] (2026-03-10)
- bugfix: correctly populate gateway probe LP data ([#6533])
- chore: introduce additional prometheus metrics for registration times ([#6532])
- bugfix: lp information to have proper snake_case on API endpoints ([#6531])
- removed redundant LP states ([#6509])
- chore: removed all matrix notifications from github actions ([#6495])
- feat: Lewes Protocol with PSQv2 ([#6491])
- build(deps): bump minimatch from 3.1.2 to 3.1.4 in /documentation/docs ([#6486])
- build(deps): bump bn.js from 4.12.2 to 4.12.3 in /documentation/docs ([#6484])
- build(deps): bump bn.js from 4.12.2 to 4.12.3 ([#6483])
- build(deps): bump ajv from 8.17.1 to 8.18.0 in /clients/native/examples/js-examples/websocket ([#6478])
- build(deps): bump ajv from 6.12.6 to 6.14.0 in /documentation/docs ([#6477])
- build(deps): bump minimatch and glob in /documentation/scripts/post-process ([#6476])
- build(deps): bump hono from 4.11.9 to 4.12.0 ([#6475])
- build(deps): bump keccak from 0.1.5 to 0.1.6 ([#6472])
- build(deps-dev): bump qs from 6.14.1 to 6.14.2 in /clients/native/examples/js-examples/websocket ([#6466])
- build(deps): bump mikefarah/yq from 4.52.2 to 4.52.4 ([#6465])
- Otel minimal v2 ([#6464])
- build(deps): bump qs and express in /wasm/client/internal-dev ([#6461])
- bugfix: restore 'latest_measurement' field for nym-node /verloc endpoint ([#6452])
- build(deps-dev): bump webpack from 5.77.0 to 5.104.1 in /wasm/node-tester/internal-dev ([#6451])
- Max/mixfetch concurrent test ([#6417])
[#6533]: https://github.com/nymtech/nym/pull/6533
[#6532]: https://github.com/nymtech/nym/pull/6532
[#6531]: https://github.com/nymtech/nym/pull/6531
[#6509]: https://github.com/nymtech/nym/pull/6509
[#6495]: https://github.com/nymtech/nym/pull/6495
[#6491]: https://github.com/nymtech/nym/pull/6491
[#6486]: https://github.com/nymtech/nym/pull/6486
[#6484]: https://github.com/nymtech/nym/pull/6484
[#6483]: https://github.com/nymtech/nym/pull/6483
[#6478]: https://github.com/nymtech/nym/pull/6478
[#6477]: https://github.com/nymtech/nym/pull/6477
[#6476]: https://github.com/nymtech/nym/pull/6476
[#6475]: https://github.com/nymtech/nym/pull/6475
[#6472]: https://github.com/nymtech/nym/pull/6472
[#6466]: https://github.com/nymtech/nym/pull/6466
[#6465]: https://github.com/nymtech/nym/pull/6465
[#6464]: https://github.com/nymtech/nym/pull/6464
[#6461]: https://github.com/nymtech/nym/pull/6461
[#6452]: https://github.com/nymtech/nym/pull/6452
[#6451]: https://github.com/nymtech/nym/pull/6451
[#6417]: https://github.com/nymtech/nym/pull/6417
## [2026.4-quark] (2026-02-24)
- Enhance CI workflow with feature inputs ([#6462])
Generated
+1
View File
@@ -6520,6 +6520,7 @@ dependencies = [
"rand 0.8.5",
"rand 0.9.2",
"reqwest 0.13.1",
"semver 1.0.26",
"serde",
"serde_json",
"time",
-1
View File
@@ -1,6 +1,5 @@
[package]
name = "nym-kkt-ciphersuite"
description = "Nym KKT ciphersuite"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
+6 -2
View File
@@ -1,6 +1,7 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
pub use crate::ip_pool::IpPair;
use crate::ip_pool::allocated_ip_pair;
use crate::{
IpPool,
@@ -23,6 +24,7 @@ use nym_credentials_interface::CredentialSpendingData;
use nym_gateway_requests::models::CredentialSpendingRequest;
use nym_gateway_storage::traits::BandwidthGatewayStorage;
use nym_node_metrics::NymNodeMetrics;
use nym_node_metrics::prometheus_wrapper::{PROMETHEUS_METRICS, PrometheusMetric};
use nym_wireguard_types::{
DEFAULT_IP_CLEANUP_INTERVAL, DEFAULT_IP_STALE_AGE, DEFAULT_PEER_TIMEOUT_CHECK,
};
@@ -35,8 +37,6 @@ use tokio::sync::{RwLock, mpsc};
use tokio_stream::{StreamExt, wrappers::IntervalStream};
use tracing::{debug, error, info, trace};
pub use crate::ip_pool::IpPair;
#[cfg(feature = "mock")]
pub mod mock;
@@ -261,6 +261,10 @@ impl PeerController {
}
async fn handle_add_request(&mut self, peer: &Peer) -> Result<()> {
// observation will get automatically added once dropped
let _metric_timer =
PROMETHEUS_METRICS.start_timer(PrometheusMetric::WireguardDefguardPeerCreation);
nym_metrics::inc!("wg_peer_addition_attempts");
// confirm ip allocation so that it wouldn't be released for as long as the peer exists
@@ -32,6 +32,7 @@ use nym_crypto::asymmetric::x25519;
use nym_gateway_requests::models::CredentialSpendingRequest;
use nym_gateway_storage::models::PersistedBandwidth;
use nym_lp::peer_config::LpReceiverIndex;
use nym_node_metrics::prometheus_wrapper::{PrometheusMetric, PROMETHEUS_METRICS};
use nym_registration_common::dvpn::{
LpDvpnRegistrationFinalisation, LpDvpnRegistrationInitialRequest,
};
@@ -126,6 +127,9 @@ impl PeerRegistrator {
credential: CredentialSpendingData,
client_id: i64,
) -> Result<i64, GatewayWireguardError> {
let _metric_timer = PROMETHEUS_METRICS
.start_timer(PrometheusMetric::PeerRegistrationCredentialVerification);
let bandwidth = self.credential_storage_preparation(client_id).await?;
let client_bandwidth = ClientBandwidth::new(bandwidth.into());
let mut verifier = CredentialVerifier::new(
@@ -232,6 +236,9 @@ impl PeerRegistrator {
request_id: u64,
reply_to: Option<Recipient>,
) -> Result<SerialisedResponse, GatewayWireguardError> {
let _metric_timer = PROMETHEUS_METRICS
.start_timer(PrometheusMetric::DvpnAuthenticatorClientRegistrationMsg1);
let remote_public = init_message.pub_key();
// 1. check if there's any pending registration already in progress,
@@ -269,6 +276,9 @@ impl PeerRegistrator {
request_id: u64,
reply_to: Option<Recipient>,
) -> Result<SerialisedResponse, GatewayWireguardError> {
let _metric_timer = PROMETHEUS_METRICS
.start_timer(PrometheusMetric::DvpnAuthenticatorClientRegistrationMsg2);
let peer = final_message.gateway_client_pub_key();
// 1. check if there's any pending registration associated with this peer
let pending_data = self
@@ -312,6 +322,9 @@ impl PeerRegistrator {
init_msg: LpDvpnRegistrationInitialRequest,
receiver_index: LpReceiverIndex,
) -> Result<LpRegistrationResponse, GatewayWireguardError> {
let _metric_timer =
PROMETHEUS_METRICS.start_timer(PrometheusMetric::DvpnLpClientRegistrationMsg1);
let remote_public = init_msg.wg_public_key;
let psk = Key::new(init_msg.psk);
@@ -343,6 +356,9 @@ impl PeerRegistrator {
final_msg: LpDvpnRegistrationFinalisation,
receiver_index: LpReceiverIndex,
) -> Result<LpRegistrationResponse, GatewayWireguardError> {
let _metric_timer =
PROMETHEUS_METRICS.start_timer(PrometheusMetric::DvpnLpClientRegistrationMsg2);
// 1. check if there's any pending registration associated with this peer
let pending_data = self
.pending_registrations
@@ -296,6 +296,7 @@ fn translate_digests(
ToSchema,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
pub enum LPKEM {
@@ -319,6 +320,7 @@ pub enum LPKEM {
ToSchema,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
pub enum LPHashFunction {
@@ -343,6 +345,7 @@ pub enum LPHashFunction {
EnumString,
ToSchema,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "lowercase")]
#[non_exhaustive]
pub enum LPSignatureScheme {
+1
View File
@@ -26,6 +26,7 @@ rand09.workspace = true
reqwest = { workspace = true, features = ["socks"] }
serde.workspace = true
serde_json.workspace = true
semver.workspace = true
time = { workspace = true }
tokio = { workspace = true, features = [
"process",
+28 -11
View File
@@ -12,6 +12,7 @@ use nym_bin_common::build_information::BinaryBuildInformationOwned;
use nym_http_api_client::UserAgent;
use nym_kkt_ciphersuite::Ciphersuite;
use nym_kkt_ciphersuite::{KEM, KEMKeyDigests};
use nym_lp::packet::version;
use nym_lp::peer::{DHPublicKey, LpRemotePeer};
use nym_network_defaults::DEFAULT_NYM_NODE_HTTP_PORT;
use nym_node_requests::api::client::NymNodeApiClientExt;
@@ -129,16 +130,32 @@ impl DirectoryNode {
.copied()
.ok_or_else(|| anyhow!("no ip address known"))?;
// let lp_data = description.lewes_protocol.as_ref().and_then(|lp_data| {
// Some(TestedNodeLpDetails {
// address: SocketAddr::new(ip_address, lp_data.control_port),
// expected_kem_key_hashes: lp_data.kem_keys().ok()?,
// expected_signing_key_hashes: lp_data.signing_keys().ok()?,
// x25519: lp_data.x25519,
// // \/ TODO: proper derivation from build version
// lp_version: version::CURRENT,
// })
// });
let identity = self.identity();
let lp_data = match description.lewes_protocol.as_ref() {
Some(lp_data) => {
// 1. verify the signature, if it's invalid, completely bail on the node
if !lp_data.verify(&identity) {
warn!("{identity} provided malformed lp data");
bail!("{identity} provided malformed lp data");
}
let version: semver::Version =
description.build_information.build_version.parse()?;
let Some(ciphersuite) = Ciphersuite::from_node_version(version) else {
bail!("failed to identify valid ciphersuite for {identity}");
};
Some(TestedNodeLpDetails {
address: SocketAddr::new(ip_address, lp_data.content.control_port),
expected_kem_key_hashes: lp_data.content.kem_keys()?,
x25519: lp_data.content.x25519,
lp_version: version::CURRENT,
ciphersuite,
})
}
None => None,
};
Ok(TestedNodeDetails {
identity: self.identity(),
@@ -147,7 +164,7 @@ impl DirectoryNode {
authenticator_address,
authenticator_version,
ip_address: Some(ip_address),
lp_data: None,
lp_data,
})
}
}
@@ -24,6 +24,18 @@ const CLIENT_SESSION_DURATION_BUCKETS: &[f64] = &[
259200., // 72h+ (implicitly)
];
const REG_LATENCY_BUCKETS: &[f64] = &[
0.001, // 1ms
0.005, // 5ms
0.01, // 10ms
0.05, // 50ms
0.1, // 100ms
0.25, // 250ms
0.5, // 500ms
1.0, // 1s
2.0, // 2s
];
#[derive(Clone, Debug, EnumIter, Display, EnumProperty, EnumCount, Eq, Hash, PartialEq)]
#[strum(serialize_all = "snake_case", prefix = "nym_node_")]
pub enum PrometheusMetric {
@@ -139,6 +151,34 @@ pub enum PrometheusMetric {
#[strum(props(help = "The current receiving rate of wireguard"))]
WireguardBytesRxRate,
#[strum(props(help = "The distribution of defguard peer creation time"))]
WireguardDefguardPeerCreation,
#[strum(props(
help = "The distribution of time it takes to verify a credential during peer registration"
))]
PeerRegistrationCredentialVerification,
#[strum(props(
help = "The distribution of time for client dvpn registration through the authenticator for msg1"
))]
DvpnAuthenticatorClientRegistrationMsg1,
#[strum(props(
help = "The distribution of time for client dvpn registration through the authenticator for msg2"
))]
DvpnAuthenticatorClientRegistrationMsg2,
#[strum(props(
help = "The distribution of time for client dvpn registration through the lp for msg1"
))]
DvpnLpClientRegistrationMsg1,
#[strum(props(
help = "The distribution of time for client dvpn registration through the lp for msg2"
))]
DvpnLpClientRegistrationMsg2,
// # NETWORK
#[strum(props(help = "The number of active ingress mixnet connections"))]
NetworkActiveIngressMixnetConnections,
@@ -271,6 +311,24 @@ impl PrometheusMetric {
PrometheusMetric::WireguardActivePeers => Metric::new_int_gauge(&name, help),
PrometheusMetric::WireguardBytesTxRate => Metric::new_float_gauge(&name, help),
PrometheusMetric::WireguardBytesRxRate => Metric::new_float_gauge(&name, help),
PrometheusMetric::WireguardDefguardPeerCreation => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::DvpnAuthenticatorClientRegistrationMsg1 => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::DvpnAuthenticatorClientRegistrationMsg2 => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::DvpnLpClientRegistrationMsg1 => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::DvpnLpClientRegistrationMsg2 => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::PeerRegistrationCredentialVerification => {
Metric::new_histogram(&name, help, Some(REG_LATENCY_BUCKETS))
}
PrometheusMetric::NetworkActiveIngressMixnetConnections => {
Metric::new_int_gauge(&name, help)
}
@@ -388,7 +446,7 @@ mod tests {
// a sanity check for anyone adding new metrics. if this test fails,
// make sure any methods on `PrometheusMetric` enum don't need updating
// or require custom Display impl
assert_eq!(39, PrometheusMetric::COUNT)
assert_eq!(45, PrometheusMetric::COUNT)
}
#[test]
@@ -71,6 +71,7 @@ impl LewesProtocol {
EnumString,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum LPKEM {
@@ -112,6 +113,7 @@ impl From<nym_kkt_ciphersuite::KEM> for LPKEM {
EnumIter,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum LPHashFunction {
@@ -158,6 +160,7 @@ impl From<nym_kkt_ciphersuite::HashFunction> for LPHashFunction {
EnumString,
EnumIter,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum LPSignatureScheme {
@@ -980,14 +980,29 @@ pub async fn try_upgrade_config_v12<P: AsRef<Path>>(
},
metrics: MetricsConfig {
debug: metrics::Debug {
log_stats_to_console: false,
aggregator_update_rate: Default::default(),
stale_mixnet_metrics_cleaner_rate: Default::default(),
global_prometheus_counters_update_rate: Default::default(),
pending_egress_packets_update_rate: Default::default(),
clients_sessions_update_rate: Default::default(),
console_logging_update_interval: Default::default(),
legacy_mixing_metrics_update_rate: Default::default(),
log_stats_to_console: old_cfg.metrics.debug.log_stats_to_console,
aggregator_update_rate: old_cfg.metrics.debug.aggregator_update_rate,
stale_mixnet_metrics_cleaner_rate: old_cfg
.metrics
.debug
.stale_mixnet_metrics_cleaner_rate,
global_prometheus_counters_update_rate: old_cfg
.metrics
.debug
.global_prometheus_counters_update_rate,
pending_egress_packets_update_rate: old_cfg
.metrics
.debug
.pending_egress_packets_update_rate,
clients_sessions_update_rate: old_cfg.metrics.debug.clients_sessions_update_rate,
console_logging_update_interval: old_cfg
.metrics
.debug
.console_logging_update_interval,
legacy_mixing_metrics_update_rate: old_cfg
.metrics
.debug
.legacy_mixing_metrics_update_rate,
},
},
logging: LoggingSettings {},
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python3
import json
import pathlib
import subprocess
import sys
from collections import defaultdict
def dependency_section(dep):
kind = dep.get("kind") or "normal"
section = {
"normal": "dependencies",
"dev": "dev-dependencies",
"build": "build-dependencies",
}.get(kind, f"{kind}-dependencies")
target = dep.get("target")
if target:
return f"target.{target}.{section}"
return section
def manifest_member(root, manifest_path):
manifest_parent = pathlib.Path(manifest_path).resolve().parent
try:
return str(manifest_parent.relative_to(root))
except ValueError:
return str(manifest_parent)
def publish_status(pkg):
publish = pkg.get("publish")
if publish is None:
return True, "publishable to crates.io"
if isinstance(publish, list):
if not publish:
return False, "publish disabled (`publish = false`)"
if "crates-io" in publish:
return True, "publishable to crates.io"
registries = ", ".join(publish)
return False, f"publish restricted to non-crates.io registries ({registries})"
return False, f"unrecognized `publish` setting: {publish!r}"
def main():
root = pathlib.Path(".").resolve()
metadata = json.loads(
subprocess.check_output(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
text=True,
)
)
packages_by_id = {pkg["id"]: pkg for pkg in metadata["packages"]}
workspace_ids = set(metadata["workspace_members"])
workspace_packages = [
packages_by_id[pkg_id] for pkg_id in workspace_ids if pkg_id in packages_by_id
]
workspace_by_name = {pkg["name"]: pkg for pkg in workspace_packages}
workspace_dir_to_name = {
str(pathlib.Path(pkg["manifest_path"]).resolve().parent): pkg["name"]
for pkg in workspace_packages
}
package_info = {}
for pkg in workspace_packages:
name = pkg["name"]
member = manifest_member(root, pkg["manifest_path"])
explicitly_publishable, publish_reason = publish_status(pkg)
package_info[name] = {
"pkg": pkg,
"member": member,
"explicitly_publishable": explicitly_publishable,
"publish_reason": publish_reason,
}
direct_issues = defaultdict(set)
workspace_deps = defaultdict(list)
for name, info in package_info.items():
pkg = info["pkg"]
member = info["member"]
explicitly_publishable = info["explicitly_publishable"]
if not explicitly_publishable:
direct_issues[name].add(info["publish_reason"])
continue
for field in ("description", "license", "repository"):
value = pkg.get(field)
if not isinstance(value, str) or not value.strip():
direct_issues[name].add(f"missing required field '{field}'")
for dep in pkg.get("dependencies", []):
section = dependency_section(dep)
dep_name = dep["name"]
dep_source = dep.get("source")
dep_workspace_name = workspace_by_name.get(dep_name, {}).get("name")
dep_path = dep.get("path")
if dep_workspace_name is None and dep_path:
dep_workspace_name = workspace_dir_to_name.get(
str(pathlib.Path(dep_path).resolve())
)
if dep_path and dep.get("req") in ("*", ""):
direct_issues[name].add(
f"{section}: path dependency '{dep_name}' has no explicit version ({dep_path})"
)
if dep_workspace_name:
dep_info = package_info[dep_workspace_name]
if not dep_info["explicitly_publishable"]:
direct_issues[name].add(
f"{section}: depends on non-publishable workspace crate '{dep_workspace_name}' ({dep_info['publish_reason']})"
)
continue
workspace_deps[name].append((dep_workspace_name, section))
continue
if dep_source and not dep_source.startswith("registry+"):
direct_issues[name].add(
f"{section}: non-registry dependency '{dep_name}' from '{dep_source}'"
)
effective_issues = {}
def collect_effective_issues(crate_name, stack):
cached = effective_issues.get(crate_name)
if cached is not None:
return cached
issues = set(direct_issues.get(crate_name, set()))
stack = stack | {crate_name}
for dep_name, dep_section in workspace_deps.get(crate_name, []):
dep_info = package_info[dep_name]
if not dep_info["explicitly_publishable"]:
issues.add(
f"{dep_section}: depends on non-publishable workspace crate '{dep_name}' ({dep_info['publish_reason']})"
)
continue
if dep_name in stack:
continue
dep_issues = collect_effective_issues(dep_name, stack)
if dep_issues:
issues.add(
f"{dep_section}: depends on blocked workspace crate '{dep_name}'"
)
effective_issues[crate_name] = issues
return issues
for crate_name in package_info:
collect_effective_issues(crate_name, set())
publish_targets = sorted(
name for name, info in package_info.items() if info["explicitly_publishable"]
)
root_blockers = sorted(
name
for name in publish_targets
if direct_issues.get(name)
)
transitive_blocked = sorted(
name
for name in publish_targets
if not direct_issues.get(name) and effective_issues.get(name)
)
disabled_by_config = sorted(
name for name, info in package_info.items() if not info["explicitly_publishable"]
)
print("Publishability preflight report:")
print(f"- workspace crates inspected: {len(package_info)}")
print(f"- crates configured for crates.io publish: {len(publish_targets)}")
print(f"- root blockers (direct issues): {len(root_blockers)}")
print(f"- downstream blocked crates (transitive): {len(transitive_blocked)}")
print(f"- crates excluded by config (publish = false / restricted): {len(disabled_by_config)}")
if root_blockers:
print("\nAction required: root blockers")
for crate_name in root_blockers:
info = package_info[crate_name]
print(f"- {crate_name} ({info['member']})")
for issue in sorted(direct_issues[crate_name]):
print(f" - {issue}")
if transitive_blocked:
print("\nDownstream blocked crates")
print("- These crates have no direct issue; they are blocked by dependencies listed below.")
for crate_name in transitive_blocked:
info = package_info[crate_name]
blockers = set()
for dep_name, dep_section in workspace_deps.get(crate_name, []):
dep_info = package_info[dep_name]
if not dep_info["explicitly_publishable"] or effective_issues.get(dep_name):
blockers.add(f"{dep_name} via {dep_section}")
print(f"- {crate_name} ({info['member']})")
for blocker in sorted(blockers):
print(f" - blocked by {blocker}")
if root_blockers or transitive_blocked:
print("\nPreflight checks failed:")
print(f"- {len(root_blockers) + len(transitive_blocked)} crate(s) configured for crates.io publish are blocked.")
sys.exit(1)
print("\nPreflight checks passed.")
if __name__ == "__main__":
main()