rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood93a1ac799bdb934a8f57352922cf4a09cd48b06b
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Updated",
"patch": {
"id": "05db4d7284c69875986a30b8161238b607155f5e",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"title": "node: Allow announcing refs for given public key",
"state": {
"status": "draft",
"conflicts": []
},
"before": "f1c7c9860716e5db88f657c61d39d6081fb5645f",
"after": "93a1ac799bdb934a8f57352922cf4a09cd48b06b",
"commits": [
"93a1ac799bdb934a8f57352922cf4a09cd48b06b"
],
"target": "f1c7c9860716e5db88f657c61d39d6081fb5645f",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "05db4d7284c69875986a30b8161238b607155f5e",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "The \"announce references\" command does only take a repository ID, and\ncarries the implicit assumption that the namespace with the same public\nkey as the Node ID of the receiving `radicle-node` process should be\nannounced, but no other namespace.\n\nIn the spirit of separating user and node identity, relax this, so that\nthe command also carries the public key for which the announcement\nshould be made. This allows to announce arbitrary namespaces out of\nstorage via command.\n\nFor now, this feature is not exposed to the CLI, but rather:\n - `rad sync` always announce for the public key of the active profile.\n - `git-remote-rad` ditto, as it calls the same codepath.",
"base": "f1c7c9860716e5db88f657c61d39d6081fb5645f",
"oid": "b4aa1c43a68995611cdb112eb2caa226d5caec22",
"timestamp": 1759161610
},
{
"id": "101ab959c9494b6891c892bb4724723f0b65df86",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "Minor improvements.",
"base": "f1c7c9860716e5db88f657c61d39d6081fb5645f",
"oid": "93a1ac799bdb934a8f57352922cf4a09cd48b06b",
"timestamp": 1759167857
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "d328550e-b68c-4b75-8a13-5280f3c36e5a"
},
"info_url": "https://cci.rad.levitte.org//d328550e-b68c-4b75-8a13-5280f3c36e5a.html"
}
Started at: 2025-09-29 19:46:37.742497+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/d328550e-b68c-4b75-8a13-5280f3c36e5a/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 123 issues · 18 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 05db4d7284c69875986a30b8161238b607155f5e
✓ Switched to branch patch/05db4d7 at revision 101ab95
✓ Branch patch/05db4d7 setup to track rad/patches/05db4d7284c69875986a30b8161238b607155f5e
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 93a1ac799bdb934a8f57352922cf4a09cd48b06b
HEAD is now at 93a1ac79 node: Allow announcing refs for given public key
Exit code: 0
$ git show 93a1ac799bdb934a8f57352922cf4a09cd48b06b
commit 93a1ac799bdb934a8f57352922cf4a09cd48b06b
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Mon Sep 29 17:22:35 2025 +0200
node: Allow announcing refs for given public key
The "announce references" command does only take a repository ID, and
carries the implicit assumption that the namespace with the same public
key as the Node ID of the receiving `radicle-node` process should be
announced, but no other namespace.
In the spirit of separating user and node identity, relax this, so that
the command also carries the public key for which the announcement
should be made. This allows to announce arbitrary namespaces out of
storage via command.
For now, this feature is not exposed to the CLI, but rather:
- `rad sync` always announce for the public key of the active profile.
- `git-remote-rad` ditto, as it calls the same codepath.
diff --git a/crates/radicle-cli/src/node.rs b/crates/radicle-cli/src/node.rs
index 88173b78..be2aea01 100644
--- a/crates/radicle-cli/src/node.rs
+++ b/crates/radicle-cli/src/node.rs
@@ -199,18 +199,29 @@ where
reporting.completion.clone(),
);
- match node.announce(rid, settings.timeout, announcer, |node, progress| {
- spinner.message(format!(
- "Synced with {}, {} of {} preferred seeds, and {} of at least {} replica(s).",
- term::format::node_id_human_compact(node),
- term::format::secondary(progress.preferred()),
- term::format::secondary(n_preferred_seeds),
- term::format::secondary(progress.synced()),
- // N.b. the number of replicas could exceed the target if we're
- // waiting for preferred seeds
- term::format::secondary(min_replicas.max(progress.synced())),
- ));
- }) {
+ // Note that technically we could command the node to announce refs
+ // for an arbitrary namespace. Here, for backwards compatibility, we
+ // only announce for our own namespace.
+ let ns = radicle::crypto::PublicKey::from(profile.did());
+
+ match node.announce(
+ rid,
+ Some(ns),
+ settings.timeout,
+ announcer,
+ |node, progress| {
+ spinner.message(format!(
+ "Synced with {}, {} of {} preferred seeds, and {} of at least {} replica(s).",
+ term::format::node_id_human_compact(node),
+ term::format::secondary(progress.preferred()),
+ term::format::secondary(n_preferred_seeds),
+ term::format::secondary(progress.synced()),
+ // N.b. the number of replicas could exceed the target if we're
+ // waiting for preferred seeds
+ term::format::secondary(min_replicas.max(progress.synced())),
+ ));
+ },
+ ) {
Ok(result) => {
spinner.message(format!(
"Synced with {} seed(s)",
diff --git a/crates/radicle-cli/tests/commands.rs b/crates/radicle-cli/tests/commands.rs
index ddef19be..36dc37b8 100644
--- a/crates/radicle-cli/tests/commands.rs
+++ b/crates/radicle-cli/tests/commands.rs
@@ -1772,7 +1772,7 @@ fn test_cob_replication() {
// announcement, otherwise Alice will consider it stale.
thread::sleep(time::Duration::from_millis(3));
- bob.handle.announce_refs(rid).unwrap();
+ bob.handle.announce_refs(rid, None).unwrap();
// Wait for Alice to fetch the issue refs.
events
diff --git a/crates/radicle-node/src/control.rs b/crates/radicle-node/src/control.rs
index 62c4651f..98a0e41d 100644
--- a/crates/radicle-node/src/control.rs
+++ b/crates/radicle-node/src/control.rs
@@ -219,8 +219,8 @@ where
return Err(CommandError::Runtime(e));
}
},
- Command::AnnounceRefs { rid } => {
- let refs = handle.announce_refs(rid)?;
+ Command::AnnounceRefs { rid, ns } => {
+ let refs = handle.announce_refs(rid, ns)?;
CommandResult::Okay(refs).to_writer(writer)?;
}
@@ -308,6 +308,7 @@ mod tests {
let socket = tmp.path().join("alice.sock");
let rids = test::arbitrary::set::<RepoId>(1..3);
let listener = Listener::bind(&socket).unwrap();
+ let nid = handle.nid().unwrap();
thread::spawn({
let handle = handle.clone();
@@ -325,7 +326,8 @@ mod tests {
&mut stream,
"{}",
json::to_string(&Command::AnnounceRefs {
- rid: rid.to_owned()
+ rid: rid.to_owned(),
+ ns: None,
})
.unwrap()
)
@@ -345,7 +347,7 @@ mod tests {
}
for rid in &rids {
- assert!(handle.updates.lock().unwrap().contains(rid));
+ assert!(handle.updates.lock().unwrap().contains(&(*rid, nid)));
}
}
diff --git a/crates/radicle-node/src/runtime/handle.rs b/crates/radicle-node/src/runtime/handle.rs
index d071308f..cf51e1c6 100644
--- a/crates/radicle-node/src/runtime/handle.rs
+++ b/crates/radicle-node/src/runtime/handle.rs
@@ -9,6 +9,7 @@ use std::os::unix::net::UnixStream as Stream;
use winpipe::WinStream as Stream;
use crossbeam_channel as chan;
+use radicle::crypto::PublicKey;
use radicle::node::events::{Event, Events};
use radicle::node::policy;
use radicle::node::{Config, NodeId};
@@ -253,9 +254,9 @@ impl radicle::node::Handle for Handle {
receiver.recv().map_err(Error::from)
}
- fn announce_refs(&mut self, id: RepoId) -> Result<RefsAt, Error> {
+ fn announce_refs(&mut self, id: RepoId, ns: Option<PublicKey>) -> Result<RefsAt, Error> {
let (sender, receiver) = chan::bounded(1);
- self.command(service::Command::AnnounceRefs(id, sender))?;
+ self.command(service::Command::AnnounceRefs(id, ns, sender))?;
receiver.recv().map_err(Error::from)
}
diff --git a/crates/radicle-node/src/test/handle.rs b/crates/radicle-node/src/test/handle.rs
index 10ff46f8..526addca 100644
--- a/crates/radicle-node/src/test/handle.rs
+++ b/crates/radicle-node/src/test/handle.rs
@@ -3,6 +3,7 @@ use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time;
+use radicle::crypto::PublicKey;
use radicle::git;
use radicle::storage::refs::RefsAt;
@@ -14,7 +15,7 @@ use radicle::node::NodeId;
#[derive(Default, Clone)]
pub struct Handle {
- pub updates: Arc<Mutex<Vec<RepoId>>>,
+ pub updates: Arc<Mutex<Vec<(RepoId, PublicKey)>>>,
pub seeding: Arc<Mutex<HashSet<RepoId>>>,
pub following: Arc<Mutex<HashSet<NodeId>>>,
}
@@ -91,8 +92,11 @@ impl radicle::node::Handle for Handle {
Ok(self.following.lock().unwrap().remove(&id))
}
- fn announce_refs(&mut self, id: RepoId) -> Result<RefsAt, Self::Error> {
- self.updates.lock().unwrap().push(id);
+ fn announce_refs(&mut self, id: RepoId, ns: Option<PublicKey>) -> Result<RefsAt, Self::Error> {
+ self.updates
+ .lock()
+ .unwrap()
+ .push((id, ns.unwrap_or_else(|| self.nid().unwrap())));
Ok(RefsAt {
remote: self.nid()?,
diff --git a/crates/radicle-node/src/tests/e2e.rs b/crates/radicle-node/src/tests/e2e.rs
index ad549d45..c97ef2b1 100644
--- a/crates/radicle-node/src/tests/e2e.rs
+++ b/crates/radicle-node/src/tests/e2e.rs
@@ -1378,7 +1378,7 @@ fn test_background_foreground_fetch() {
Title::new("Concurrent fetches").unwrap(),
"Concurrent fetches are harshing my vibes",
);
- bob.handle.announce_refs(rid).unwrap();
+ bob.handle.announce_refs(rid, None).unwrap();
alice_events
.wait(
|e| matches!(e, Event::RefsAnnounced { .. }).then_some(()),
@@ -1427,7 +1427,7 @@ fn test_catchup_on_refs_announcements() {
log::debug!(target: "test", "Bob creating his issue..");
bob.issue(acme, Title::new("Bob's issue").unwrap(), "[..]");
- bob.handle.announce_refs(acme).unwrap();
+ bob.handle.announce_refs(acme, None).unwrap();
log::debug!(target: "test", "Waiting for seed to fetch Bob's refs from Bob..");
seed.has_remote_of(&acme, &bob.id); // Seed fetches Bob's refs.
diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs
index 558ab056..4b96697e 100644
--- a/crates/radicle-protocol/src/service.rs
+++ b/crates/radicle-protocol/src/service.rs
@@ -238,8 +238,8 @@ pub type QueryState = dyn Fn(&dyn ServiceState) -> Result<(), CommandError> + Se
/// Commands sent to the service by the operator.
pub enum Command {
- /// Announce repository references for given repository to peers.
- AnnounceRefs(RepoId, chan::Sender<RefsAt>),
+ /// Announce repository references for given repository and namespace to peers.
+ AnnounceRefs(RepoId, Option<PublicKey>, chan::Sender<RefsAt>),
/// Announce local repositories to peers.
AnnounceInventory,
/// Add repository to local inventory.
@@ -271,7 +271,7 @@ pub enum Command {
impl fmt::Debug for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
- Self::AnnounceRefs(id, _) => write!(f, "AnnounceRefs({id})"),
+ Self::AnnounceRefs(id, _, _) => write!(f, "AnnounceRefs({id})"),
Self::AnnounceInventory => write!(f, "AnnounceInventory"),
Self::AddInventory(rid, _) => write!(f, "AddInventory({rid})"),
Self::Connect(id, addr, opts) => write!(f, "Connect({id}, {addr}, {opts:?})"),
@@ -930,7 +930,7 @@ where
.expect("Service::command: error unfollowing node");
resp.send(updated).ok();
}
- Command::AnnounceRefs(id, resp) => {
+ Command::AnnounceRefs(id, ns, resp) => {
let doc = match self.storage.get(id) {
Ok(Some(doc)) => doc,
Ok(None) => {
@@ -943,8 +943,13 @@ where
}
};
- match self.announce_own_refs(id, doc) {
- Ok(refs) => match refs.as_slice() {
+ let result = match ns {
+ Some(ns) => self.announce_refs(id, doc, [ns]),
+ None => self.announce_own_refs(id, doc),
+ };
+
+ match result {
+ Ok((refs, _timestamp)) => match refs.as_slice() {
&[refs] => {
resp.send(refs).ok();
}
@@ -2167,7 +2172,11 @@ where
}
/// Announce our own refs for the given repo.
- fn announce_own_refs(&mut self, rid: RepoId, doc: Doc) -> Result<Vec<RefsAt>, Error> {
+ fn announce_own_refs(
+ &mut self,
+ rid: RepoId,
+ doc: Doc,
+ ) -> Result<(Vec<RefsAt>, Timestamp), Error> {
let (refs, timestamp) = self.announce_refs(rid, doc, [self.node_id()])?;
// Update refs database with our signed refs branches.
@@ -2193,7 +2202,7 @@ where
);
}
}
- Ok(refs)
+ Ok((refs, timestamp))
}
/// Announce local refs for given repo.
diff --git a/crates/radicle/src/node.rs b/crates/radicle/src/node.rs
index 41cd433b..edf9ab9f 100644
--- a/crates/radicle/src/node.rs
+++ b/crates/radicle/src/node.rs
@@ -613,9 +613,28 @@ impl From<Address> for HostName {
#[serde(rename_all = "camelCase", tag = "command")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Command {
- /// Announce repository references for given repository to peers.
+ /// Announce repository references for given repository
+ /// and namespace to peers.
#[serde(rename_all = "camelCase")]
- AnnounceRefs { rid: RepoId },
+ AnnounceRefs {
+ rid: RepoId,
+
+ /// The namespace for which references should be announced.
+ ///
+ /// For backwards compatibility this is optional and
+ /// omission is interpreted by the node as if the Node ID
+ /// of the node itself, i.e., the node that this command is
+ /// received by, was passed. Thus, the node would announce
+ /// "it's own" references. This makes perfect sense when
+ /// the node and the user have the same cryptographic identity
+ /// but not when they are different.
+ #[cfg_attr(
+ feature = "schemars",
+ schemars(with = "Option<crate::schemars_ext::crypto::PublicKey>")
+ )]
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ ns: Option<PublicKey>,
+ },
/// Announce local repositories to peers.
#[serde(rename_all = "camelCase")]
@@ -624,7 +643,7 @@ pub enum Command {
/// Update node's inventory.
AddInventory { rid: RepoId },
- /// Get the current node condiguration.
+ /// Get the current node configuration.
Config,
/// Get the node's listen addresses.
@@ -1113,7 +1132,7 @@ pub trait Handle: Clone + Sync + Send {
/// Unfollow the given peer.
fn unfollow(&mut self, id: NodeId) -> Result<bool, Self::Error>;
/// Notify the service that a project has been updated, and announce local refs.
- fn announce_refs(&mut self, id: RepoId) -> Result<RefsAt, Self::Error>;
+ fn announce_refs(&mut self, id: RepoId, ns: Option<PublicKey>) -> Result<RefsAt, Self::Error>;
/// Announce local inventory.
fn announce_inventory(&mut self) -> Result<(), Self::Error>;
/// Notify the service that our inventory was updated with the given repository.
@@ -1215,12 +1234,13 @@ impl Node {
pub fn announce(
&mut self,
rid: RepoId,
+ ns: Option<PublicKey>,
timeout: time::Duration,
mut announcer: sync::Announcer,
mut report: impl FnMut(&NodeId, sync::announce::Progress),
) -> Result<sync::AnnouncerResult, Error> {
let mut events = self.subscribe(timeout)?;
- let refs = self.announce_refs(rid)?;
+ let refs = self.announce_refs(rid, ns)?;
let started = time::Instant::now();
@@ -1391,9 +1411,9 @@ impl Handle for Node {
Ok(response.updated)
}
- fn announce_refs(&mut self, rid: RepoId) -> Result<RefsAt, Error> {
+ fn announce_refs(&mut self, rid: RepoId, ns: Option<PublicKey>) -> Result<RefsAt, Error> {
let refs: RefsAt = self
- .call(Command::AnnounceRefs { rid }, DEFAULT_TIMEOUT)?
+ .call(Command::AnnounceRefs { rid, ns }, DEFAULT_TIMEOUT)?
.next()
.ok_or(Error::EmptyResponse)??;
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name d328550e-b68c-4b75-8a13-5280f3c36e5a -v /opt/radcis/ci.rad.levitte.org/cci/state/d328550e-b68c-4b75-8a13-5280f3c36e5a/s:/d328550e-b68c-4b75-8a13-5280f3c36e5a/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/d328550e-b68c-4b75-8a13-5280f3c36e5a/w:/d328550e-b68c-4b75-8a13-5280f3c36e5a/w -w /d328550e-b68c-4b75-8a13-5280f3c36e5a/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:bookworm bash /d328550e-b68c-4b75-8a13-5280f3c36e5a/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.88-x86_64-unknown-linux-gnu'
info: latest update on 2025-06-26, rust version 1.88.0 (6b00bc388 2025-06-23)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.88.0 (873a06493 2025-05-10)
+ rustc --version
rustc 1.88.0 (6b00bc388 2025-06-23)
+ cargo fmt --check
Diff in /d328550e-b68c-4b75-8a13-5280f3c36e5a/w/crates/radicle/src/node.rs:620:
rid: RepoId,
/// The namespace for which references should be announced.
- ///
+ ///
/// For backwards compatibility this is optional and
/// omission is interpreted by the node as if the Node ID
/// of the node itself, i.e., the node that this command is
Exit code: 1
{
"response": "finished",
"result": "failure"
}