From b1acb3eb4d4816d35102850f353e97f65cf9419f Mon Sep 17 00:00:00 2001 From: Daniel Norman Date: Thu, 16 Jul 2026 11:19:49 +0200 Subject: [PATCH] Integrate radicle-artifact into explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add release read API to httpd as cargo feature enabled by default - Add Releases and Release views to the UI - Filter out redacted artifacts and mark delegate authors - Degrade releases UI gracefully with older radicle-httpd versions - Hide fully redacted releases - Skip releases in the list endpoint when every artifact was redacted by its author or a delegate, so no empty release entries are shown - Keep them reachable with showRedacted=true and by release id - Add e2e tests for the release routes Co-Authored-By: Rūdolfs Ošiņš --- .buildkite/rust.yml | 4 + .github/workflows/check-rust.yml | 5 + Cargo.lock | 513 ++++++++++++-- crates/radicle-httpd/Cargo.toml | 6 +- crates/radicle-httpd/src/api.rs | 31 +- crates/radicle-httpd/src/api/query.rs | 12 + crates/radicle-httpd/src/api/v1/delegates.rs | 4 + crates/radicle-httpd/src/api/v1/repos.rs | 18 +- .../src/api/v1/repos/releases.rs | 518 +++++++++++++++ http-client/index.ts | 10 + http-client/lib/repo.ts | 40 ++ http-client/lib/repo/release.ts | 50 ++ http-client/tests/release.test.ts | 63 ++ scripts/install-binaries | 16 +- src/App.svelte | 6 + src/components/Icon.svelte | 4 + src/components/Id.svelte | 18 +- src/lib/router.ts | 8 +- src/lib/router/definitions.ts | 4 +- src/views/repos/Header.svelte | 37 +- src/views/repos/Layout.svelte | 20 + src/views/repos/Release.svelte | 623 ++++++++++++++++++ src/views/repos/Release/DelegateBadge.svelte | 44 ++ src/views/repos/Release/DownloadButton.svelte | 161 +++++ src/views/repos/Release/ReleaseTeaser.svelte | 112 ++++ src/views/repos/Releases.svelte | 215 ++++++ src/views/repos/error.ts | 4 + src/views/repos/router.ts | 187 +++++- tests/e2e/repo/patch.spec.ts | 4 + tests/e2e/repo/release.spec.ts | 198 ++++++ tests/e2e/repo/releases.spec.ts | 118 ++++ tests/e2e/router.spec.ts | 9 + tests/support/fixtures.ts | 7 +- tests/support/globalSetup.ts | 23 +- tests/support/radicle-artifact-release | 1 + tests/support/repo.ts | 82 ++- tests/support/support.ts | 14 +- 37 files changed, 3121 insertions(+), 68 deletions(-) create mode 100644 crates/radicle-httpd/src/api/v1/repos/releases.rs create mode 100644 http-client/lib/repo/release.ts create mode 100644 http-client/tests/release.test.ts create mode 100644 src/views/repos/Release.svelte create mode 100644 src/views/repos/Release/DelegateBadge.svelte create mode 100644 src/views/repos/Release/DownloadButton.svelte create mode 100644 src/views/repos/Release/ReleaseTeaser.svelte create mode 100644 src/views/repos/Releases.svelte create mode 100644 tests/e2e/repo/release.spec.ts create mode 100644 tests/e2e/repo/releases.spec.ts create mode 100644 tests/support/radicle-artifact-release diff --git a/.buildkite/rust.yml b/.buildkite/rust.yml index 56a830912..2c70b8953 100644 --- a/.buildkite/rust.yml +++ b/.buildkite/rust.yml @@ -76,4 +76,8 @@ steps: # components. Only this step needs them. - rustup component add rustfmt clippy - RUSTFLAGS="-D warnings" cargo clippy --workspace --all-features --tests + # Run clippy with default features disabled (artifacts) + # TODO: If more features are added explore adding `cargo hack --feature-powerset` + # to cover every feature combination + - RUSTFLAGS="-D warnings" cargo clippy -p radicle-httpd --no-default-features --tests - cargo fmt --all -- --check diff --git a/.github/workflows/check-rust.yml b/.github/workflows/check-rust.yml index 0ecff85f5..a46040e1c 100644 --- a/.github/workflows/check-rust.yml +++ b/.github/workflows/check-rust.yml @@ -54,5 +54,10 @@ jobs: run: cargo clippy --workspace --all-features --tests env: RUSTFLAGS: -D warnings + # Nothing else compiles the artifacts-off path, so it would rot. + - name: Run clippy on httpd without default features + run: cargo clippy -p radicle-httpd --no-default-features --tests + env: + RUSTFLAGS: -D warnings - name: Check formatting run: cargo fmt --all -- --check diff --git a/Cargo.lock b/Cargo.lock index ec8981205..15492d832 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] @@ -184,6 +184,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "ascii" version = "1.1.0" @@ -192,9 +204,9 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -213,6 +225,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -299,6 +320,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base256emoji" version = "1.0.2" @@ -341,7 +368,7 @@ checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" dependencies = [ "blowfish", "pbkdf2", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -350,6 +377,20 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -359,6 +400,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -503,13 +553,26 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "serde", + "serde_bytes", + "unsigned-varint", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", ] @@ -554,6 +617,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -604,6 +676,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.8.0" @@ -655,6 +733,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossterm" version = "0.29.0" @@ -704,6 +788,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "ctr" version = "0.9.2" @@ -722,13 +816,31 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -844,6 +956,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -858,21 +971,31 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] [[package]] -name = "displaydoc" -version = "0.2.6" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -913,7 +1036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature 2.2.0", @@ -940,16 +1063,42 @@ dependencies = [ "signature 2.2.0", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "serdect", + "signature 3.0.0", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519 2.2.3", "serde", - "sha2", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", "subtle", "zeroize", ] @@ -969,9 +1118,9 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -984,6 +1133,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1045,6 +1206,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1170,6 +1337,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -1274,6 +1450,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1291,6 +1476,20 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1312,14 +1511,14 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1360,6 +1559,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1601,6 +1809,7 @@ dependencies = [ "bitflags", "crossterm", "dyn-clone", + "fuzzy-matcher", "tempfile", "unicode-segmentation", "unicode-width", @@ -1612,6 +1821,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek 5.0.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1661,7 +1889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64 0.22.1", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hmac", "js-sys", @@ -1671,7 +1899,7 @@ dependencies = [ "rsa", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "signature 2.2.0", "zeroize", ] @@ -1910,6 +2138,37 @@ dependencies = [ "data-encoding-macro", ] +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "serde", + "unsigned-varint", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "nom" version = "8.0.0" @@ -2030,7 +2289,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2042,7 +2301,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2051,12 +2310,12 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", + "base16ct 0.2.0", "ecdsa", "elliptic-curve", "primeorder", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2088,7 +2347,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2168,6 +2427,19 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2343,6 +2615,64 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "radicle-artifact" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8166746c3d6bf1618d97aebb48d8b3239c31527fc96fc235309f7c47543ba23" +dependencies = [ + "blake3", + "cid", + "clap", + "indexmap", + "indicatif", + "inquire", + "log", + "radicle", + "radicle-artifact-client", + "radicle-artifact-core", + "serde", + "serde_json", + "sqlite", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "radicle-artifact-client" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59abfdeba1a2476c45264014ccc0e9d86cee55eb327e755506e5bc8c0d2b0153" +dependencies = [ + "cid", + "radicle", + "radicle-artifact-core", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "radicle-artifact-core" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6ef833ec05bb0731b61854c8f39467896d593845c69c0f9437e6f6f2d7bf36" +dependencies = [ + "blake3", + "cid", + "dunce", + "iroh-base", + "multibase", + "multihash", + "postcard", + "radicle", + "serde", + "serde_json", + "thiserror 2.0.19", + "url", + "walkdir", +] + [[package]] name = "radicle-cob" version = "0.21.1" @@ -2386,9 +2716,9 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76de6172f3ccae0f489578f4a5da45bc77ff8855ace6352b002d94c0e0783bb5" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "cyphernet", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "multibase", "qcheck", "radicle-git-ref-format", @@ -2449,6 +2779,7 @@ dependencies = [ "nonempty 0.12.0", "pretty_assertions", "radicle", + "radicle-artifact", "radicle-crypto", "radicle-job", "radicle-search", @@ -2751,14 +3082,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", - "digest", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "signature 2.2.0", "spki", "subtle", @@ -2795,9 +3126,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", "ring", @@ -2840,6 +3171,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "1.2.2" @@ -2877,7 +3217,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der", "generic-array", "pkcs8", @@ -2934,6 +3274,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -3002,6 +3352,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3010,7 +3370,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3089,10 +3460,19 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3137,11 +3517,25 @@ dependencies = [ "cypheraddr", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -3222,7 +3616,7 @@ checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" dependencies = [ "base64ct", "pem-rfc7468", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3232,7 +3626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ "bcrypt-pbkdf", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "num-bigint-dig", "p256", "p384", @@ -3240,7 +3634,7 @@ dependencies = [ "rand_core 0.6.4", "rsa", "sec1", - "sha2", + "sha2 0.10.9", "signature 2.2.0", "ssh-cipher", "ssh-encoding", @@ -3493,13 +3887,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3709,6 +4103,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.2" @@ -3721,10 +4121,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + [[package]] name = "untrusted" version = "0.9.0" @@ -3786,6 +4192,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3923,6 +4339,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/crates/radicle-httpd/Cargo.toml b/crates/radicle-httpd/Cargo.toml index 3cedac5c3..9e2805080 100644 --- a/crates/radicle-httpd/Cargo.toml +++ b/crates/radicle-httpd/Cargo.toml @@ -10,7 +10,10 @@ default-run = "radicle-httpd" build = "build.rs" [features] -default = [] +default = ["artifacts"] +artifacts = [ + "dep:radicle-artifact", +] logfmt = [ "tracing-logfmt", ] @@ -33,6 +36,7 @@ lru = { version = "0.16.0" } mime_guess = { version = "2.0.5" } nonempty = { version = "0.12.0", features = ["serialize"] } radicle.workspace = true +radicle-artifact = { version = "0.18", optional = true } radicle-job = { version = "0.6.0" } radicle-search = { path = "../radicle-search" } radicle-surf = { workspace = true, features = ["serde"] } diff --git a/crates/radicle-httpd/src/api.rs b/crates/radicle-httpd/src/api.rs index 385b2b4e6..e93ad6901 100644 --- a/crates/radicle-httpd/src/api.rs +++ b/crates/radicle-httpd/src/api.rs @@ -14,6 +14,8 @@ use radicle::patch::cache::Patches as _; use radicle::storage::git::Repository; use radicle::storage::{ReadRepository, ReadStorage}; use radicle::{git, web, Profile}; +#[cfg(feature = "artifacts")] +use radicle_artifact::Releases; use tokio::sync::RwLock; mod error; @@ -29,7 +31,7 @@ use crate::Options; pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION"); // This version has to be updated on every breaking change to the radicle-httpd API. -pub const API_VERSION: &str = "6.1.0"; +pub const API_VERSION: &str = "6.2.0"; /// Thread-safe wrapper around radicle's web configuration. /// @@ -178,16 +180,18 @@ impl Context { let patches = patches.counts().ok()?; let issues = self.profile.issues(repo).ok()?; let issues = issues.counts().ok()?; + let mut meta = json!({ + "head": head, + "issues": issues, + "patches": patches + }); + add_releases_meta(&mut meta, repo); Some(( id.clone(), json!({ "data": payload, - "meta": { - "head": head, - "issues": issues, - "patches": patches - } + "meta": meta }), )) } else { @@ -238,6 +242,21 @@ impl Context { } } +/// Add the release count to a project payload's `meta` object. +#[cfg(feature = "artifacts")] +fn add_releases_meta(meta: &mut Value, repo: &Repository) { + let releases = Releases::open(repo) + .ok() + .and_then(|releases| releases.count().ok()) + .unwrap_or_default(); + + meta["releases"] = json!(releases); +} + +/// Without artifact support there is no release store, so the key is omitted. +#[cfg(not(feature = "artifacts"))] +fn add_releases_meta(_meta: &mut Value, _repo: &Repository) {} + /// Run a blocking closure on the blocking thread pool. /// /// Repository reads go through libgit2 and the COB cache, which are diff --git a/crates/radicle-httpd/src/api/query.rs b/crates/radicle-httpd/src/api/query.rs index 87202e557..3a1ed8642 100644 --- a/crates/radicle-httpd/src/api/query.rs +++ b/crates/radicle-httpd/src/api/query.rs @@ -53,6 +53,18 @@ pub struct CobsQuery { pub status: Option, } +#[cfg(feature = "artifacts")] +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ReleasesQuery { + pub page: Option, + pub per_page: Option, + /// Include releases and artifacts authored by non-delegates. + pub all_authors: Option, + /// Include artifacts redacted by their author or a delegate. + pub show_redacted: Option, +} + #[derive(Default, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub enum IssueStatus { diff --git a/crates/radicle-httpd/src/api/v1/delegates.rs b/crates/radicle-httpd/src/api/v1/delegates.rs index 83a004c54..1a327e072 100644 --- a/crates/radicle-httpd/src/api/v1/delegates.rs +++ b/crates/radicle-httpd/src/api/v1/delegates.rs @@ -131,6 +131,7 @@ mod routes { "open": 1, "closed": 0, }, + "releases": 0, } } }, @@ -168,6 +169,7 @@ mod routes { "open": 0, "closed": 0, }, + "releases": 0, } } }, @@ -227,6 +229,7 @@ mod routes { "open": 1, "closed": 0, }, + "releases": 0, } } }, @@ -264,6 +267,7 @@ mod routes { "open": 0, "closed": 0, }, + "releases": 0, } } }, diff --git a/crates/radicle-httpd/src/api/v1/repos.rs b/crates/radicle-httpd/src/api/v1/repos.rs index d0f9b5ca1..4fe87377e 100644 --- a/crates/radicle-httpd/src/api/v1/repos.rs +++ b/crates/radicle-httpd/src/api/v1/repos.rs @@ -1,4 +1,6 @@ mod job; +#[cfg(feature = "artifacts")] +mod releases; use std::collections::{BTreeMap, HashMap}; @@ -29,7 +31,7 @@ use crate::axum_extra::{cached_response, immutable_response, Path, Query}; const MAX_BODY_LIMIT: usize = 4_194_304; pub fn router(ctx: Context) -> Router { - Router::new() + let router = Router::new() .route("/repos", get(repo_root_handler)) .route("/repos/search", get(repo_search_handler)) .route("/repos/{rid}", get(repo_handler)) @@ -56,7 +58,14 @@ pub fn router(ctx: Context) -> Router { .route("/repos/{rid}/issues", get(issues_handler)) .route("/repos/{rid}/issues/{id}", get(issue_handler)) .route("/repos/{rid}/patches", get(patches_handler)) - .route("/repos/{rid}/patches/{id}", get(patch_handler)) + .route("/repos/{rid}/patches/{id}", get(patch_handler)); + + #[cfg(feature = "artifacts")] + let router = router + .route("/repos/{rid}/releases", get(releases::list_handler)) + .route("/repos/{rid}/releases/{id}", get(releases::get_handler)); + + router .with_state(ctx) .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT)) } @@ -1391,6 +1400,7 @@ mod routes { "open": 1, "closed": 0, }, + "releases": 0, } } }, @@ -1428,6 +1438,7 @@ mod routes { "open": 0, "closed": 0, }, + "releases": 0, } } }, @@ -1478,6 +1489,7 @@ mod routes { "open": 1, "closed": 0, }, + "releases": 0, } } }, @@ -1515,6 +1527,7 @@ mod routes { "open": 0, "closed": 0, }, + "releases": 0, } } }, @@ -1695,6 +1708,7 @@ mod routes { "open": 1, "closed": 0, }, + "releases": 0, } } }, diff --git a/crates/radicle-httpd/src/api/v1/repos/releases.rs b/crates/radicle-httpd/src/api/v1/repos/releases.rs new file mode 100644 index 000000000..4548312f6 --- /dev/null +++ b/crates/radicle-httpd/src/api/v1/repos/releases.rs @@ -0,0 +1,518 @@ +use axum::extract::State; +use axum::response::IntoResponse; +use axum::Json; +use serde_json::{json, Value}; + +use radicle::git::Oid; +use radicle::identity::doc::Delegates; +use radicle::node::AliasStore; +use radicle::storage::git::Repository; + +use radicle_artifact::display::{CommitTitle, TagName}; +use radicle_artifact::{cache_db_path, Artifact, Cid, Release, ReleaseId, Releases}; + +use crate::api; +use crate::api::error::Error; +use crate::api::json::Author; +use crate::api::query::{ReleasesQuery, MAX_PER_PAGE}; +use crate::api::Context; +use crate::axum_extra::{Path, Query}; + +/// Default number of releases returned per page. +const DEFAULT_PER_PAGE: usize = 30; + +/// Whether an artifact was redacted by its own author or by a delegate. +fn redacted_by_trusted(artifact: &Artifact, delegates: &Delegates) -> bool { + artifact + .redactions() + .keys() + .any(|did| did == artifact.author() || delegates.contains(did)) +} + +/// How far the caller widened the default, delegate-scoped release view. +#[derive(Clone, Copy)] +struct Filter { + /// Include releases and artifacts authored by non-delegates. + all_authors: bool, + /// Include artifacts redacted by their author or a delegate. + show_redacted: bool, +} + +impl Filter { + /// Whether an artifact is shown under this view: authored by a delegate + /// (or `all_authors`), and not redacted by its author or a delegate (or + /// `show_redacted`). + fn show_artifact(&self, artifact: &Artifact, delegates: &Delegates) -> bool { + (self.all_authors || delegates.contains(artifact.author())) + && (self.show_redacted || !redacted_by_trusted(artifact, delegates)) + } + + /// Whether a release is shown under this view: created by a delegate (or + /// `all_authors`), and not left with all of its artifacts redacted by a + /// trusted party (or `show_redacted`). + fn show_release(&self, release: &Release, delegates: &Delegates) -> bool { + (self.all_authors || delegates.contains(release.creator())) + && (self.show_redacted || !release_redacted(release, delegates)) + } +} + +/// Whether every artifact of a release was redacted by a trusted party. A +/// release without artifacts is not redacted; it has nothing to redact. +fn release_redacted(release: &Release, delegates: &Delegates) -> bool { + let artifacts = release.artifacts(); + + !artifacts.is_empty() + && artifacts + .values() + .all(|artifact| redacted_by_trusted(artifact, delegates)) +} + +/// Serialize a single artifact. Locations are flattened across contributors +/// into `{ user, url }` entries; attestations are the attesting nodes; +/// redactions carry the flagging user and its reason. +fn artifact_json(cid: &Cid, artifact: &Artifact, aliases: &impl AliasStore) -> Value { + let locations = artifact + .locations() + .iter() + .flat_map(|(did, urls)| { + urls.iter() + .map(move |url| json!({ "user": Author::new(did).as_json(aliases), "url": url })) + }) + .collect::>(); + let attestations = artifact + .attestations() + .iter() + .map(|did| Author::new(did).as_json(aliases)) + .collect::>(); + let redactions = artifact + .redactions() + .iter() + .map(|(did, reason)| json!({ "user": Author::new(did).as_json(aliases), "reason": reason })) + .collect::>(); + + json!({ + "cid": cid.to_string(), + "name": artifact.name(), + "author": Author::new(artifact.author()).as_json(aliases), + "locations": locations, + "attestations": attestations, + "redactions": redactions, + "metadata": artifact.metadata(), + }) +} + +/// Serialize a release. The COB has no title field: it is resolved from the +/// tag message when a tag is linked, otherwise the commit summary. `filter` +/// omits the artifacts hidden under the current view; `None` keeps all of +/// them, as a release fetched by id does. +fn release_json( + id: ReleaseId, + release: &Release, + repo: &Repository, + aliases: &impl AliasStore, + delegates: &Delegates, + filter: Option, +) -> Value { + let title = release + .tag() + .and_then(|tag| repo.title(tag)) + .or_else(|| repo.title(release.oid())); + let tag_name = release.tag().and_then(|tag| repo.tag_name(tag)); + + let artifacts = release + .artifacts() + .iter() + .filter(|(_, artifact)| { + filter.is_none_or(|filter| filter.show_artifact(artifact, delegates)) + }) + .map(|(cid, artifact)| artifact_json(cid, artifact, aliases)) + .collect::>(); + + json!({ + "id": id.to_string(), + "oid": release.oid(), + "tag": release.tag(), + "tagName": tag_name, + "title": title, + "createdAt": release.timestamp().as_secs(), + "creator": Author::new(release.creator()).as_json(aliases), + "artifacts": artifacts, + }) +} + +/// Get repo releases list, newest first. +/// `GET /repos/:rid/releases` +/// +/// Scoped to releases created by a delegate and artifacts authored by a +/// delegate (hiding those redacted by a trusted party) unless widened with +/// `allAuthors=true` / `showRedacted=true`. A release whose artifacts were all +/// redacted is hidden with them. +pub async fn list_handler( + State(ctx): State, + Path(rid): Path, + Query(qs): Query, +) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; + let releases = api::blocking(move || { + let (repo, doc) = ctx.repo(rid)?; + let delegates = doc.delegates(); + let aliases = ctx.profile.aliases(); + let ReleasesQuery { + page, + per_page, + all_authors, + show_redacted, + } = qs; + let page = page.unwrap_or(0); + let per_page = per_page.unwrap_or(DEFAULT_PER_PAGE).min(MAX_PER_PAGE); + let filter = Filter { + all_authors: all_authors.unwrap_or(false), + show_redacted: show_redacted.unwrap_or(false), + }; + + // Read through the SQLite cache; it self-warms on read and is shared + // with other release reads on this node. + let cache = cache_db_path(ctx.profile.cobs()); + let mut releases: Vec<_> = Releases::open_cached(&repo, cache)? + .all()? + .into_iter() + .filter_map(|r| { + let (id, release) = r.ok()?; + filter + .show_release(&release, delegates) + .then_some((id, release)) + }) + .collect(); + releases.sort_by_key(|(_, release)| std::cmp::Reverse(release.timestamp())); + + Ok::<_, Error>( + releases + .into_iter() + .skip(page * per_page) + .take(per_page) + .map(|(id, release)| { + release_json( + ReleaseId::from(id), + &release, + &repo, + &aliases, + delegates, + Some(filter), + ) + }) + .collect::>(), + ) + }) + .await?; + + Ok::<_, Error>(Json(releases)) +} + +/// Get a single repo release by id, with all its artifacts. +/// `GET /repos/:rid/releases/:id` +pub async fn get_handler( + State(ctx): State, + Path((rid, release_id)): Path<(String, Oid)>, +) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; + let value = api::blocking(move || { + let (repo, doc) = ctx.repo(rid)?; + let delegates = doc.delegates(); + let aliases = ctx.profile.aliases(); + let cache = cache_db_path(ctx.profile.cobs()); + let release = Releases::open_cached(&repo, cache)? + .get(&ReleaseId::from(release_id))? + .ok_or(Error::NotFound)?; + + Ok::<_, Error>(release_json( + ReleaseId::from(release_id), + &release, + &repo, + &aliases, + delegates, + None, + )) + }) + .await?; + + Ok::<_, Error>(Json(value)) +} + +#[cfg(test)] +mod routes { + use std::net::SocketAddr; + use std::str::FromStr; + + use axum::extract::connect_info::MockConnectInfo; + use axum::http::StatusCode; + use axum::Router; + use pretty_assertions::assert_eq; + use serde_json::json; + + use radicle::crypto::{Seed, SigningKey}; + use radicle::git::Oid; + use radicle::identity::RepoId; + use radicle::storage::WriteStorage; + + use radicle_artifact::{Cid, Releases}; + use url::Url; + + use crate::api::Context; + use crate::test::{get, seed, DID, HEAD, RID}; + + /// A valid CIDv1 string; the release COB stores it verbatim. + const CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + /// A second, distinct CIDv1 string, for releases with more than one + /// artifact. + const CID_2: &str = "bafkr4ihjtgc6jaulcccny3jhc3ezxfjdgnp4vvt23nf6qqlrhpk764ufbu"; + const LOCATION: &str = "https://example.com/linux-amd64.tar.gz"; + + /// The delegate signer seeded by `test::seed` (alias "seed"). + const DELEGATE_SEED: [u8; 32] = [0xff; 32]; + /// A signer that is not a delegate of the seeded repo. + const NON_DELEGATE_SEED: [u8; 32] = [0xee; 32]; + + fn app(ctx: Context) -> Router { + super::super::router(ctx).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080)))) + } + + /// Create a release on the seeded repo at `HEAD`, signed by `seed`, with a + /// single artifact and location. Returns the release id string. + fn create_release(ctx: &Context, signer_seed: [u8; 32]) -> String { + let signer = SigningKey::from_seed(Seed::new(signer_seed)); + let rid = RepoId::from_str(RID).unwrap(); + let repo = ctx.profile().storage.repository_mut(rid).unwrap(); + let oid = Oid::from_str(HEAD).unwrap(); + + let mut releases = Releases::open(&repo).unwrap(); + let mut release = releases.create(oid, None, &signer).unwrap(); + let cid = Cid::from_str(CID).unwrap(); + release + .register_artifact(cid, "linux-amd64".to_string(), &signer) + .unwrap(); + release + .add_location(cid, Url::parse(LOCATION).unwrap(), &signer) + .unwrap(); + + release.id().oid().to_string() + } + + #[tokio::test] + async fn test_repos_releases_empty() { + let tmp = tempfile::tempdir().unwrap(); + let app = app(seed(tmp.path())); + let response = get(&app, format!("/repos/{RID}/releases")).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.json().await, json!([])); + } + + #[tokio::test] + async fn test_repos_releases_list() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + let id = create_release(&ctx, DELEGATE_SEED); + let app = app(ctx); + + let response = get(&app, format!("/repos/{RID}/releases")).await; + assert_eq!(response.status(), StatusCode::OK); + + // `createdAt` is stamped at COB-creation time, so drop it before + // comparing against the fixed shape. + let mut body = response.json().await; + assert!(body[0]["createdAt"].as_u64().unwrap() > 0); + body[0]["createdAt"].take(); + + assert_eq!( + body, + json!([{ + "id": id, + "oid": HEAD, + "tag": null, + "tagName": null, + "title": "Add another folder", + "createdAt": null, + "creator": { "id": DID, "alias": "seed" }, + "artifacts": [{ + "cid": CID, + "name": "linux-amd64", + "author": { "id": DID, "alias": "seed" }, + "locations": [ + { "user": { "id": DID, "alias": "seed" }, "url": LOCATION } + ], + "attestations": [], + "redactions": [], + "metadata": {}, + }], + }]) + ); + } + + #[tokio::test] + async fn test_repos_release_by_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + let id = create_release(&ctx, DELEGATE_SEED); + let app = app(ctx); + + let response = get(&app, format!("/repos/{RID}/releases/{id}")).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.json().await; + assert_eq!(body["id"], json!(id)); + assert_eq!(body["oid"], json!(HEAD)); + assert_eq!(body["title"], json!("Add another folder")); + assert_eq!(body["artifacts"][0]["cid"], json!(CID)); + } + + #[tokio::test] + async fn test_repos_release_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let app = app(seed(tmp.path())); + let response = get( + &app, + format!("/repos/{RID}/releases/ffffffffffffffffffffffffffffffffffffffff"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_repos_releases_hides_non_delegate_creator() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + create_release(&ctx, NON_DELEGATE_SEED); + let app = app(ctx); + + // Default view is scoped to delegate creators. + let response = get(&app, format!("/repos/{RID}/releases")).await; + assert_eq!(response.json().await, json!([])); + + // `allAuthors=true` widens it to include non-delegate creators. + let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await; + let body = response.json().await; + assert_eq!(body.as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn test_repos_releases_list_hides_non_delegate_artifact() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + + // The delegate creates a release with two artifacts: its own, and + // one registered by a non-delegate. + { + let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED)); + let other_signer = SigningKey::from_seed(Seed::new(NON_DELEGATE_SEED)); + let rid = RepoId::from_str(RID).unwrap(); + let repo = ctx.profile().storage.repository_mut(rid).unwrap(); + let oid = Oid::from_str(HEAD).unwrap(); + let mut releases = Releases::open(&repo).unwrap(); + let mut release = releases.create(oid, None, &signer).unwrap(); + + let cid = Cid::from_str(CID).unwrap(); + release + .register_artifact(cid, "linux-amd64".to_string(), &signer) + .unwrap(); + + let other_cid = Cid::from_str(CID_2).unwrap(); + release + .register_artifact(other_cid, "eve-build".to_string(), &other_signer) + .unwrap(); + } + let app = app(ctx); + + // The release itself stays visible: its creator is a delegate. Only + // the non-delegate's artifact is dropped from the list view. + let response = get(&app, format!("/repos/{RID}/releases")).await; + let body = response.json().await; + let artifacts = body[0]["artifacts"].as_array().unwrap(); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["cid"], json!(CID)); + + // `allAuthors=true` widens it to include the non-delegate's artifact. + let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await; + let body = response.json().await; + let artifacts = body[0]["artifacts"].as_array().unwrap(); + assert_eq!(artifacts.len(), 2); + } + + #[tokio::test] + async fn test_repos_releases_hides_redacted_artifact() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + + // The delegate creates a release, then redacts its own artifact. + { + let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED)); + let rid = RepoId::from_str(RID).unwrap(); + let repo = ctx.profile().storage.repository_mut(rid).unwrap(); + let oid = Oid::from_str(HEAD).unwrap(); + let mut releases = Releases::open(&repo).unwrap(); + let mut release = releases.create(oid, None, &signer).unwrap(); + let cid = Cid::from_str(CID).unwrap(); + release + .register_artifact(cid, "linux-amd64".to_string(), &signer) + .unwrap(); + release + .redact(cid, "bad build".to_string(), &signer) + .unwrap(); + } + let app = app(ctx); + + // With its only artifact redacted by a trusted party, the release has + // nothing left to show and is hidden too. + let response = get(&app, format!("/repos/{RID}/releases")).await; + assert_eq!(response.json().await, json!([])); + + // `showRedacted=true` surfaces it, carrying the redaction reason. + let response = get(&app, format!("/repos/{RID}/releases?showRedacted=true")).await; + let body = response.json().await; + assert_eq!(body[0]["artifacts"][0]["cid"], json!(CID)); + assert_eq!( + body[0]["artifacts"][0]["redactions"][0]["reason"], + json!("bad build") + ); + } + + #[tokio::test] + async fn test_repos_releases_alias_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let mut ctx = seed(tmp.path()); + ctx.set_repo_aliases(std::collections::HashMap::from_iter([( + "hello".to_string(), + RepoId::from_str(RID).unwrap(), + )])); + let id = create_release(&ctx, DELEGATE_SEED); + let app = app(ctx); + + // Both release routes accept a configured alias in place of the RID. + let response = get(&app, "/repos/hello/releases").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.json().await[0]["id"], json!(id)); + + let response = get(&app, format!("/repos/hello/releases/{id}")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.json().await["id"], json!(id)); + + // An unknown alias is not found. + let response = get(&app, "/repos/nope/releases").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_repos_releases_per_page_is_clamped() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + create_release(&ctx, DELEGATE_SEED); + let app = app(ctx); + + // A caller requesting more than MAX_PER_PAGE must not be able to + // widen the page beyond the cap. + let response = get(&app, format!("/repos/{RID}/releases?perPage=1000")).await; + assert_eq!(response.status(), StatusCode::OK); + let len = response.json().await.as_array().unwrap().len(); + assert!(len <= crate::api::query::MAX_PER_PAGE); + } +} diff --git a/http-client/index.ts b/http-client/index.ts index 33c647300..e663d19ba 100644 --- a/http-client/index.ts +++ b/http-client/index.ts @@ -41,6 +41,12 @@ import type { Revision, Verdict, } from "./lib/repo/patch.js"; +import type { + Artifact, + Location, + Redaction, + Release, +} from "./lib/repo/release.js"; import type { RequestOptions } from "./lib/fetcher.js"; import type { ZodSchema } from "zod"; @@ -57,6 +63,7 @@ import { } from "./lib/shared.js"; export type { + Artifact, Author, BaseUrl, Blob, @@ -79,11 +86,14 @@ export type { IssueState, Job, LifecycleState, + Location, Merge, Patch, PatchState, PeerRefs, Reaction, + Redaction, + Release, Remote, Repo, RepoListQuery, diff --git a/http-client/lib/repo.ts b/http-client/lib/repo.ts index 2feaeb62b..43094f9c7 100644 --- a/http-client/lib/repo.ts +++ b/http-client/lib/repo.ts @@ -4,6 +4,7 @@ import { ResponseError } from "./fetcher.js"; import type { Commit, Commits } from "./repo/commit.js"; import type { Issue } from "./repo/issue.js"; import type { Patch } from "./repo/patch.js"; +import type { Release } from "./repo/release.js"; import type { ZodSchema } from "zod"; import { @@ -28,6 +29,7 @@ import { } from "./repo/commit.js"; import { issueSchema, issuesSchema } from "./repo/issue.js"; import { patchSchema, patchesSchema } from "./repo/patch.js"; +import { releaseSchema, releasesSchema } from "./repo/release.js"; import { authorSchema } from "./shared.js"; export type PeerRefs = { @@ -70,6 +72,8 @@ const repoSchema = object({ open: number(), closed: number(), }), + // Optional: absent on older nodes that don't report release counts. + releases: optional(number()), }), }), }), @@ -575,4 +579,40 @@ export class Client { patchesSchema, ); } + + public async getReleaseById( + rid: string, + releaseId: string, + options?: RequestOptions, + ): Promise { + return this.#fetcher.fetchOk( + { + method: "GET", + path: `repos/${rid}/releases/${releaseId}`, + options, + }, + releaseSchema, + ); + } + + public async getAllReleases( + rid: string, + query?: { + page?: number; + perPage?: number; + allAuthors?: boolean; + showRedacted?: boolean; + }, + options?: RequestOptions, + ): Promise { + return this.#fetcher.fetchOk( + { + method: "GET", + path: `repos/${rid}/releases`, + query, + options, + }, + releasesSchema, + ); + } } diff --git a/http-client/lib/repo/release.ts b/http-client/lib/repo/release.ts new file mode 100644 index 000000000..e64780b86 --- /dev/null +++ b/http-client/lib/repo/release.ts @@ -0,0 +1,50 @@ +import type { ZodSchema } from "zod"; +import * as z from "zod"; + +import { authorSchema } from "../shared.js"; + +export const locationSchema = z.object({ + user: authorSchema, + url: z.string(), +}); + +export type Location = z.infer; + +export const redactionSchema = z.object({ + user: authorSchema, + reason: z.string(), +}); + +export type Redaction = z.infer; + +export const artifactSchema = z.object({ + cid: z.string(), + name: z.string(), + author: authorSchema, + locations: z.array(locationSchema), + attestations: z.array(authorSchema), + redactions: z.array(redactionSchema), + metadata: z.record(z.string(), z.unknown()), +}); + +export type Artifact = z.infer; + +// A release is keyed by a commit (optionally an annotated tag). It has no +// title field of its own; `title`/`tagName` are resolved by the backend from +// the tag or commit message, and are absent when unresolvable. +export const releaseSchema = z.object({ + id: z.string(), + oid: z.string(), + tag: z.string().nullish(), + tagName: z.string().nullish(), + title: z.string().nullish(), + createdAt: z.number(), + creator: authorSchema, + artifacts: z.array(artifactSchema), +}); + +export type Release = z.infer; + +export const releasesSchema = z.array(releaseSchema) satisfies ZodSchema< + Release[] +>; diff --git a/http-client/tests/release.test.ts b/http-client/tests/release.test.ts new file mode 100644 index 000000000..952bb4346 --- /dev/null +++ b/http-client/tests/release.test.ts @@ -0,0 +1,63 @@ +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; +import { describe, expect } from "vitest"; + +import { testFixture } from "@http-client/tests/support/fixtures.js"; +import { createRepo } from "@tests/support/repo.js"; +import { useLocalHttpd } from "@tests/support/support.js"; + +describe("release", () => { + // The release read API only exists in the local httpd build, so skip these + // against the stable pre-built binary the default suite runs. Releases are + // seeded on an isolated peer to avoid perturbing the shared fixtures. + testFixture.skipIf(!useLocalHttpd)( + "#getAllReleases(rid) + #getReleaseById(rid, id)", + async ({ httpd: { api, peer } }) => { + const { rid, repoFolder } = await createRepo(peer, { + name: "releases", + defaultBranch: "main", + }); + const { stdout: head } = await peer.git(["rev-parse", "HEAD"], { + cwd: repoFolder, + }); + + // Register an artifact from a local file so the CID is computed for us. + const artifactPath = Path.join(repoFolder, "artifact.bin"); + await Fs.writeFile(artifactPath, "hello release\n"); + + const { stdout: receipt } = await peer.spawn( + "rad-artifact", + ["--no-announce", "--no-input", "create", head, "--json"], + { cwd: repoFolder }, + ); + const { releaseId } = JSON.parse(receipt); + + await peer.spawn( + "rad-artifact", + [ + "--no-announce", + "--no-input", + "register", + artifactPath, + "--release", + releaseId, + "--name", + "binary", + ], + { cwd: repoFolder }, + ); + + // The sole delegate created the release, so it shows under the default + // delegate-scoped view. + const releases = await api.repo.getAllReleases(rid); + expect(releases.length).toBe(1); + expect(releases[0].id).toBe(releaseId); + expect(releases[0].oid).toBe(head); + expect(releases[0].artifacts[0].name).toBe("binary"); + + const release = await api.repo.getReleaseById(rid, releaseId); + expect(release.id).toBe(releaseId); + expect(release.artifacts.length).toBe(1); + }, + ); +}); diff --git a/scripts/install-binaries b/scripts/install-binaries index 07c189aa8..915a15f37 100755 --- a/scripts/install-binaries +++ b/scripts/install-binaries @@ -4,10 +4,13 @@ set -e REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") RELEASE=$(cat "$REPO_ROOT/tests/support/heartwood-release") HTTPD_RELEASE=$(cat "$REPO_ROOT/tests/support/radicle-httpd-release") +ARTIFACT_RELEASE=$(cat "$REPO_ROOT/tests/support/radicle-artifact-release") BINARY_PATH=$REPO_ROOT/tests/tmp/bin OS=$(uname) install() { + local BINARY="" + if test -d "$BINARY_PATH/$1/$2"; then echo ✅ "Folder $BINARY_PATH/$1/$2 exists already skipping download." else @@ -32,13 +35,23 @@ install() { FETCH_URL="https://files.radicle.dev/releases/radicle-httpd/$2/radicle-httpd-$ARCH.tar.xz" FILENAME="radicle-httpd-$2-$ARCH" ;; + artifact) + # radicle-artifact publishes bare executables instead of archives. + FETCH_URL="https://files.radicle.dev/releases/radicle-artifact/$2/rad-artifact_$2_$ARCH" + BINARY="rad-artifact" + ;; *) echo "No precompiled binary found with the name $1." && exit 1 ;; esac echo Downloading "$1" v"$2" from "$FETCH_URL into /tests/tmp/bin/$1/$2" - curl --fail --silent --location "$FETCH_URL" | tar -xJ --strip-components=2 -C "$BINARY_PATH/$1/$2" "$FILENAME/bin/" || (echo "Download failed" && exit 1) + if test -n "$BINARY"; then + curl --fail --silent --location --output "$BINARY_PATH/$1/$2/$BINARY" "$FETCH_URL" || (echo "Download failed" && exit 1) + chmod +x "$BINARY_PATH/$1/$2/$BINARY" + else + curl --fail --silent --location "$FETCH_URL" | tar -xJ --strip-components=2 -C "$BINARY_PATH/$1/$2" "$FILENAME/bin/" || (echo "Download failed" && exit 1) + fi fi } @@ -65,5 +78,6 @@ done install "heartwood" "$RELEASE" install "httpd" "$HTTPD_RELEASE" +install "artifact" "$ARTIFACT_RELEASE" echo diff --git a/src/App.svelte b/src/App.svelte index c23eefa4f..177494622 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -24,6 +24,8 @@ import NotFound from "@app/views/NotFound.svelte"; import Patch from "@app/views/repos/Patch.svelte"; import Patches from "@app/views/repos/Patches.svelte"; + import Release from "@app/views/repos/Release.svelte"; + import Releases from "@app/views/repos/Releases.svelte"; import Source from "@app/views/repos/Source.svelte"; import Users from "@app/views/users/View.svelte"; @@ -100,6 +102,10 @@ {:else if $activeRouteStore.resource === "repo.patch"} +{:else if $activeRouteStore.resource === "repo.releases"} + +{:else if $activeRouteStore.resource === "repo.release"} + {:else if $activeRouteStore.resource === "error"} {:else if $activeRouteStore.resource === "notFound"} diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 7b89c04cd..d371497a9 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -82,6 +82,7 @@ | "offline" | "online" | "open-external" + | "parcel" | "patch" | "patch-archived" | "patch-draft" @@ -488,6 +489,9 @@ + {:else if name === "parcel"} + {:else if name === "patch"} diff --git a/src/components/Id.svelte b/src/components/Id.svelte index 36c6f7c31..e1a628ce9 100644 --- a/src/components/Id.svelte +++ b/src/components/Id.svelte @@ -12,6 +12,9 @@ export let ariaLabel: string | undefined = undefined; export let styleWidth: string | undefined = undefined; export let title: string | undefined = undefined; + // Keep a long id on one line and end it with an ellipsis instead of + // wrapping, for ids shown in a width-constrained column. + export let truncate: boolean = false; let icon: ComponentProps["name"] = "copy"; const text = "Click to copy"; @@ -43,6 +46,16 @@ position: relative; display: inline-block; } + .container.truncate { + display: block; + min-width: 0; + max-width: 100%; + } + .txt-id.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .popover { position: absolute; left: 1rem; @@ -64,10 +77,10 @@ } -
+
{ setVisible(true); @@ -76,6 +89,7 @@ setVisible(false); }} class="txt-id" + class:truncate style:cursor="copy" aria-label={ariaLabel} on:click|preventDefault|stopPropagation={async () => { diff --git a/src/lib/router.ts b/src/lib/router.ts index 392907389..9dab32fb8 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -296,7 +296,9 @@ function setTitle(loadedRoute: LoadedRoute) { loadedRoute.resource === "repo.issue" || loadedRoute.resource === "repo.issues" || loadedRoute.resource === "repo.patches" || - loadedRoute.resource === "repo.patch" + loadedRoute.resource === "repo.patch" || + loadedRoute.resource === "repo.releases" || + loadedRoute.resource === "repo.release" ) { title.push(...repoTitle(loadedRoute)); } else if (loadedRoute.resource === "nodes") { @@ -555,7 +557,9 @@ export function routeToPath(route: Route): string { route.resource === "repo.issues" || route.resource === "repo.issue" || route.resource === "repo.patches" || - route.resource === "repo.patch" + route.resource === "repo.patch" || + route.resource === "repo.releases" || + route.resource === "repo.release" ) { return repoRouteToPath(route); } else if ( diff --git a/src/lib/router/definitions.ts b/src/lib/router/definitions.ts index 7c30c32f9..82a31b970 100644 --- a/src/lib/router/definitions.ts +++ b/src/lib/router/definitions.ts @@ -114,7 +114,9 @@ export async function loadRoute( route.resource === "repo.issues" || route.resource === "repo.issue" || route.resource === "repo.patches" || - route.resource === "repo.patch" + route.resource === "repo.patch" || + route.resource === "repo.releases" || + route.resource === "repo.release" ) { return await loadRepoRoute(route, previousLoaded); } else { diff --git a/src/views/repos/Header.svelte b/src/views/repos/Header.svelte index 22f4ee185..4a734c861 100644 --- a/src/views/repos/Header.svelte +++ b/src/views/repos/Header.svelte @@ -1,5 +1,6 @@ + + + + + + + + Releases + + + +
{release.id}
+
+ {utils.formatObjectId(release.id)} +
+
+
+ +
+
+ + +
+ + +
+
+ + {#if release.tagName} + {release.tagName} + {/if} + + commit + + + + + + released + + + {utils.formatTimestamp(release.createdAt)} + + +
+ +
+
+
+ + + + + + +
+ {#if redactedCount > 0} + + {/if} +
+ + {#if shownArtifacts.length === 0} +
+ + No artifacts +
+ {/if} + + {#each shownArtifacts as artifact (artifact.cid)} + {@const size = artifactSize(artifact)} + {@const locations = delegatesFirst( + locationsByNode(artifact), + g => g.user.id, + delegateIds, + )} + {@const metadata = otherMetadata(artifact)} + {@const locationCount = artifact.locations.length} + {@const cliOnly = + locationCount > 0 && !artifact.locations.some(l => isWebUrl(l.url))} + {@const redactions = delegatesFirst( + artifact.redactions, + r => r.user.id, + delegateIds, + )} +
+
+ {artifact.name} + {#if redactedByTrusted(artifact, delegateIds)} + + + {redactedByLabel(artifact, delegateIds)} + + {/if} +
+ {#if size} + {size} + {/if} + +
+
+ +
+ {utils.truncateId(artifact.cid)} +
+ +
+ + {#if delegateIds.has(artifact.author.id)} + + {/if} + {#if artifact.attestations.length > 0} + + attested by + {#each delegatesFirst(artifact.attestations, n => n.id, delegateIds) as node, i (i)} + + + {#if delegateIds.has(node.id)} + + {/if}{#if i < artifact.attestations.length - 1},{/if} + + {/each} + + {/if} +
+ + {#if metadata.length > 0} + + {/if} + + {#if locationCount > 0} +
+
+ + + {locationCount} location{locationCount === 1 ? "" : "s"} + + + +
+ {#each locations as group (group.user.id)} +
+
+ + {#if delegateIds.has(group.user.id)} + + {/if} +
+ {#each group.urls as url (url)} +
+ {url} +
+ {/each} +
+ {/each} +
+
+ {/if} + + {#if redactions.length > 0} +
+ + + {redactions.length} redaction{redactions.length === 1 + ? "" + : "s"} + + + +
+ {#each redactions as redaction (redaction.user.id)} +
+ + Redacted by + + {#if delegateIds.has(redaction.user.id)} + + {/if} + + {redaction.reason || "No reason"} + +
+ {/each} +
+
+ {/if} +
+ {/each} +
+
+
+
diff --git a/src/views/repos/Release/DelegateBadge.svelte b/src/views/repos/Release/DelegateBadge.svelte new file mode 100644 index 000000000..aaa463ce6 --- /dev/null +++ b/src/views/repos/Release/DelegateBadge.svelte @@ -0,0 +1,44 @@ + + + + + + + {tooltip} + diff --git a/src/views/repos/Release/DownloadButton.svelte b/src/views/repos/Release/DownloadButton.svelte new file mode 100644 index 000000000..a2bcc6ec0 --- /dev/null +++ b/src/views/repos/Release/DownloadButton.svelte @@ -0,0 +1,161 @@ + + + + + + + +
+
+ + +
+ +
+
+ + {#if activeTab === "cli"} + + + {:else} +
+ + These downloads are not verified. +
+
+ {#each webLocations as location (location.url)} +
+ {#if delegateIds.has(location.user.id)} + + {/if} +
+ + {location.url} + +
+ + + +
+ {/each} +
+ {/if} +
+
diff --git a/src/views/repos/Release/ReleaseTeaser.svelte b/src/views/repos/Release/ReleaseTeaser.svelte new file mode 100644 index 000000000..3c04c8c95 --- /dev/null +++ b/src/views/repos/Release/ReleaseTeaser.svelte @@ -0,0 +1,112 @@ + + + + +
+
+ +
+
+
+ + + + + +
+ + + {release.artifacts.length} + +
+
+
+
+ + released + + + {formatTimestamp(release.createdAt)} + +
+
+
+
diff --git a/src/views/repos/Releases.svelte b/src/views/repos/Releases.svelte new file mode 100644 index 000000000..2cbee2b47 --- /dev/null +++ b/src/views/repos/Releases.svelte @@ -0,0 +1,215 @@ + + + + + + + + + Releases + + +
+ + + + + + +
+ + + + + + {#if error} + + {/if} + + {#if allReleases.length === 0 && !error} +
+ +
+ {/if} + + {#if loading || showMoreButton} +
+ {#if loading} + + {/if} + + {#if showMoreButton} + + {/if} +
+ {/if} +
diff --git a/src/views/repos/error.ts b/src/views/repos/error.ts index ebb105fb6..d41714af3 100644 --- a/src/views/repos/error.ts +++ b/src/views/repos/error.ts @@ -18,6 +18,10 @@ export function handleError( subject = "Issue"; } else if (route.resource === "repo.patch") { subject = "Patch"; + } else if (route.resource === "repo.release") { + subject = "Release"; + } else if (route.resource === "repo.releases") { + subject = "Releases"; } else { subject = "Repository"; } diff --git a/src/views/repos/router.ts b/src/views/repos/router.ts index a49f08596..b6cce9093 100644 --- a/src/views/repos/router.ts +++ b/src/views/repos/router.ts @@ -17,6 +17,7 @@ import type { Patch, PatchState, PeerRefs, + Release, Remote, Repo, Revision, @@ -39,6 +40,7 @@ import { nodePath } from "@app/views/nodes/router"; export const PATCHES_PER_PAGE = 10; export const ISSUES_PER_PAGE = 10; +export const RELEASES_PER_PAGE = 30; export function peerHasBranches(peer: PeerRefs): boolean { return Object.keys(peer.refs).some(name => name.startsWith("refs/heads/")); @@ -91,7 +93,9 @@ export type RepoRoute = | RepoIssuesRoute | RepoIssueRoute | RepoPatchesRoute - | RepoPatchRoute; + | RepoPatchRoute + | RepoReleasesRoute + | RepoReleaseRoute; interface RepoIssuesRoute { resource: "repo.issues"; @@ -100,6 +104,21 @@ interface RepoIssuesRoute { status?: "open" | "closed"; } +interface RepoReleasesRoute { + resource: "repo.releases"; + node: BaseUrl; + repo: string; + allAuthors?: boolean; +} + +interface RepoReleaseRoute { + resource: "repo.release"; + node: BaseUrl; + repo: string; + release: string; + allAuthors?: boolean; +} + interface RepoIssueRoute { resource: "repo.issue"; node: BaseUrl; @@ -254,6 +273,30 @@ export type RepoLoadedRoute = nodeId: string; nodeAvatarUrl: string | undefined; }; + } + | { + resource: "repo.releases"; + params: { + baseUrl: BaseUrl; + repo: Repo; + repoId: string; + releases: Release[]; + allAuthors: boolean; + nodeId: string; + nodeAvatarUrl: string | undefined; + }; + } + | { + resource: "repo.release"; + params: { + baseUrl: BaseUrl; + repo: Repo; + repoId: string; + release: Release; + allAuthors: boolean; + nodeId: string; + nodeAvatarUrl: string | undefined; + }; }; export type BlobResult = @@ -362,6 +405,10 @@ export async function loadRepoRoute( return await loadIssuesView(route); } else if (route.resource === "repo.patches") { return await loadPatchesView(route); + } else if (route.resource === "repo.releases") { + return await loadReleasesView(route); + } else if (route.resource === "repo.release") { + return await loadReleaseView(route); } else { return unreachable(route); } @@ -439,6 +486,101 @@ async function loadIssuesView( }; } +// Older nodes don't report a release count and lack the release API. Show a +// friendly page pointing to another node instead of a raw 404. +function releasesNotSupported(node: BaseUrl): NotFoundRoute { + return { + resource: "notFound", + params: { + title: "Releases are not available on this node", + description: + "This node is running an older version that doesn't support browsing releases yet.\nSelect a different node to continue.", + baseUrl: node, + }, + }; +} + +async function loadReleasesView( + route: RepoReleasesRoute, +): Promise { + const api = new HttpdClient(route.node); + const allAuthors = route.allAuthors || false; + + // Fetch releases alongside the repo so the common path stays parallel; the + // repo's `meta.releases` then tells us whether a failure is an unsupported + // node or a genuine error. + const [repo, releasesResult, node] = await Promise.all([ + api.repo.getByRid(route.repo), + api.repo + .getAllReleases(route.repo, { + allAuthors, + page: 0, + perPage: RELEASES_PER_PAGE, + }) + .then( + releases => ({ releases }), + (error: unknown) => ({ error }), + ), + api.getNode(), + ]); + + if (repo.payloads["xyz.radicle.project"].meta.releases === undefined) { + return releasesNotSupported(route.node); + } + if ("error" in releasesResult) { + throw releasesResult.error; + } + + return { + resource: "repo.releases", + params: { + baseUrl: route.node, + repoId: route.repo, + releases: releasesResult.releases, + allAuthors, + repo, + nodeId: node.id, + nodeAvatarUrl: node.avatarUrl, + }, + }; +} + +async function loadReleaseView( + route: RepoReleaseRoute, +): Promise { + const api = new HttpdClient(route.node); + const allAuthors = route.allAuthors || false; + + const [repo, releaseResult, node] = await Promise.all([ + api.repo.getByRid(route.repo), + api.repo.getReleaseById(route.repo, route.release).then( + release => ({ release }), + (error: unknown) => ({ error }), + ), + api.getNode(), + ]); + + if (repo.payloads["xyz.radicle.project"].meta.releases === undefined) { + return releasesNotSupported(route.node); + } + if ("error" in releaseResult) { + throw releaseResult.error; + } + + return { + resource: "repo.release", + params: { + baseUrl: route.node, + repoId: route.repo, + repo, + release: releaseResult.release, + allAuthors, + nodeId: node.id, + nodeAvatarUrl: node.avatarUrl, + }, + }; +} + async function loadTreeView( route: RepoTreeRoute, previousLoaded: LoadedRoute, @@ -964,6 +1106,27 @@ export function resolveRepoRoute( } } else if (content === "patches") { return resolvePatchesRoute(node, repo, segments, urlSearch); + } else if (content === "releases") { + const release = segments.shift(); + const allAuthors = + new URLSearchParams(sanitizeQueryString(urlSearch)).get("allAuthors") === + "true"; + if (release) { + return { + resource: "repo.release", + node, + repo, + release, + allAuthors, + }; + } else { + return { + resource: "repo.releases", + node, + repo, + allAuthors, + }; + } } else { return null; } @@ -1085,6 +1248,18 @@ export function repoRouteToPath(route: RepoRoute): string { return url; } else if (route.resource === "repo.patch") { return patchRouteToPath(route); + } else if (route.resource === "repo.releases") { + let url = [...pathSegments, "releases"].join("/"); + if (route.allAuthors) { + url += "?allAuthors=true"; + } + return url; + } else if (route.resource === "repo.release") { + let url = [...pathSegments, "releases", route.release].join("/"); + if (route.allAuthors) { + url += "?allAuthors=true"; + } + return url; } else { return unreachable(route); } @@ -1154,6 +1329,16 @@ export function repoTitle(loadedRoute: RepoLoadedRoute) { } else if (loadedRoute.resource === "repo.patches") { title.push(project.data.name); title.push("patches"); + } else if (loadedRoute.resource === "repo.release") { + title.push( + loadedRoute.params.release.title || + loadedRoute.params.release.tagName || + loadedRoute.params.release.id, + ); + title.push("release"); + } else if (loadedRoute.resource === "repo.releases") { + title.push(project.data.name); + title.push("releases"); } else { return unreachable(loadedRoute); } diff --git a/tests/e2e/repo/patch.spec.ts b/tests/e2e/repo/patch.spec.ts index a255031d8..0236acdb2 100644 --- a/tests/e2e/repo/patch.spec.ts +++ b/tests/e2e/repo/patch.spec.ts @@ -4,6 +4,10 @@ test("navigate patch details", async ({ page }) => { await page.goto(`${cobUrl}/patches`); await page.getByText("Add subtitle to README").click(); await expect(page).toHaveURL(/patches\/[a-f0-9]{40}$/); + // The router pushes the URL before the patch view renders. Until it is up, + // the link below resolves to the teaser on the patch listing, and clicking + // that navigates to the patch we are already on. + await page.getByRole("heading", { name: "Add subtitle to README" }).waitFor(); await page.getByRole("link", { name: "Add subtitle to README" }).click(); await expect(page).toHaveURL(/commits\/[a-f0-9]{40}$/); await page.goBack(); diff --git a/tests/e2e/repo/release.spec.ts b/tests/e2e/repo/release.spec.ts new file mode 100644 index 000000000..087571a48 --- /dev/null +++ b/tests/e2e/repo/release.spec.ts @@ -0,0 +1,198 @@ +import { test, expect } from "@tests/support/fixtures.js"; +import { + createContributor, + createRepo, + radArtifact, + registerArtifact, + syncFrom, +} from "@tests/support/repo.js"; +import { releaseApiOnlyLocal, useLocalHttpd } from "@tests/support/support.js"; + +test.describe(() => { + test.skip(!useLocalHttpd, releaseApiOnlyLocal); + // Release fixtures need several rad-artifact commands, which takes longer + // than the default per-test budget. + test.describe.configure({ timeout: 30_000 }); + + test("navigate single release", async ({ page, peer }) => { + const { rid, repoFolder } = await createRepo(peer, { + name: "single-release", + }); + const { releaseId } = await registerArtifact(peer, repoFolder, { + name: "binary", + }); + + await page.goto(`${peer.uiUrl()}/${rid}/releases`); + await page.locator(".release-teaser").getByText("initial commit").click(); + + await expect(page).toHaveURL( + `${peer.uiUrl()}/${rid}/releases/${releaseId}`, + ); + await expect(page.locator(".artifact-name")).toContainText("binary"); + }); + + test("release title and tag name come from the tag", async ({ + page, + peer, + }) => { + const { rid, repoFolder } = await createRepo(peer, { + name: "tagged-release", + }); + await peer.git(["tag", "--annotate", "v1.0", "--message", "Version 1.0"], { + cwd: repoFolder, + }); + // rad-artifact resolves revisions in Radicle storage, where refs are + // namespaced per node, so push the tag and address it by object id. + await peer.git(["push", "rad", "--tags"], { cwd: repoFolder }); + const { stdout: tagOid } = await peer.git(["rev-parse", "v1.0"], { + cwd: repoFolder, + }); + const { releaseId } = await registerArtifact(peer, repoFolder, { + name: "binary", + revision: tagOid, + }); + + await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`); + await expect(page.locator(".title")).toContainText("Version 1.0"); + await expect( + page.getByRole("button", { name: "v1.0", exact: true }), + ).toBeVisible(); + }); + + test("artifact download locations", async ({ page, peer }) => { + const { rid, repoFolder } = await createRepo(peer, { + name: "release-locations", + }); + + // An artifact with a web location is downloadable straight from the + // browser. + const { releaseId, cid } = await registerArtifact(peer, repoFolder, { + name: "web-artifact", + }); + await radArtifact(peer, repoFolder, [ + "location", + "add", + "--release", + releaseId, + "--cid", + cid, + "https://example.com/web-artifact", + ]); + + // An artifact served over the radicle-artifact protocol needs the CLI. + const { cid: cliCid } = await registerArtifact(peer, repoFolder, { + name: "cli-artifact", + release: releaseId, + }); + await radArtifact(peer, repoFolder, [ + "location", + "add", + "--release", + releaseId, + "--cid", + cliCid, + // A bare radiroh location matches any seeding endpoint. + "radiroh://", + ]); + + // An artifact without any location can't be fetched at all. + await registerArtifact(peer, repoFolder, { + name: "unavailable-artifact", + release: releaseId, + }); + + await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`); + + const webArtifact = page + .locator(".artifact") + .filter({ hasText: "web-artifact" }); + await webArtifact.getByRole("button", { name: "Download" }).click(); + await webArtifact.getByRole("button", { name: "Browser" }).click(); + await expect( + webArtifact.getByRole("link", { name: "Download" }), + ).toHaveAttribute("href", "https://example.com/web-artifact"); + + const cliArtifact = page + .locator(".artifact") + .filter({ hasText: "cli-artifact" }); + await cliArtifact.getByRole("button", { name: "Download" }).click(); + await expect( + cliArtifact.getByRole("button", { name: "Browser" }), + ).toBeDisabled(); + await expect(cliArtifact.locator(".cmd")).toContainText( + `rad artifact -r ${rid} download --cid ${cliCid}`, + ); + + await expect( + page + .locator(".artifact") + .filter({ hasText: "unavailable-artifact" }) + .getByRole("button", { name: "Download" }), + ).toBeDisabled(); + }); + + test("show redacted artifacts", async ({ page, peer }) => { + const { rid, repoFolder } = await createRepo(peer, { + name: "redacted-artifact", + }); + const { releaseId } = await registerArtifact(peer, repoFolder, { + name: "good-artifact", + }); + const { cid } = await registerArtifact(peer, repoFolder, { + name: "flagged-artifact", + release: releaseId, + }); + await radArtifact(peer, repoFolder, [ + "redact", + "--release", + releaseId, + "--cid", + cid, + "--reason", + "Built from the wrong toolchain", + ]); + + await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`); + await expect(page.getByText("good-artifact")).toBeVisible(); + await expect(page.getByText("flagged-artifact")).toBeHidden(); + + await page.getByRole("button", { name: "Show redacted 1" }).click(); + await expect(page.getByText("flagged-artifact")).toBeVisible(); + await expect( + page.getByText("Built from the wrong toolchain"), + ).toBeVisible(); + }); + + test("filter artifacts by author", async ({ page, peer, peerManager }) => { + const name = "artifact-authors"; + const { rid, repoFolder } = await createRepo(peer, { name }); + const { releaseId } = await registerArtifact(peer, repoFolder, { + name: "delegate-artifact", + }); + + // Eve isn't a delegate, so her artifact is out of scope by default. + const { contributor, repoFolder: contributorFolder } = + await createContributor(peerManager, peer, { rid, repoName: name }); + await registerArtifact(contributor, contributorFolder, { + name: "eve-artifact", + release: releaseId, + }); + await syncFrom(peer, repoFolder); + + await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`); + await expect(page.getByText("delegate-artifact")).toBeVisible(); + await expect(page.getByText("eve-artifact")).toBeHidden(); + + await page.getByRole("link", { name: "All 2" }).click(); + await expect(page).toHaveURL( + `${peer.uiUrl()}/${rid}/releases/${releaseId}?allAuthors=true`, + ); + await expect(page.getByText("eve-artifact")).toBeVisible(); + + await page.getByRole("link", { name: "Delegates 1" }).click(); + await expect(page).toHaveURL( + `${peer.uiUrl()}/${rid}/releases/${releaseId}`, + ); + await expect(page.getByText("eve-artifact")).toBeHidden(); + }); +}); diff --git a/tests/e2e/repo/releases.spec.ts b/tests/e2e/repo/releases.spec.ts new file mode 100644 index 000000000..a624a8ac5 --- /dev/null +++ b/tests/e2e/repo/releases.spec.ts @@ -0,0 +1,118 @@ +import { test, cobUrl, expect } from "@tests/support/fixtures.js"; +import { + createContributor, + createRepo, + radArtifact, + registerArtifact, + syncFrom, +} from "@tests/support/repo.js"; +import { releaseApiOnlyLocal, useLocalHttpd } from "@tests/support/support.js"; + +test.describe(() => { + test.skip(!useLocalHttpd, releaseApiOnlyLocal); + // Release fixtures need several rad-artifact commands, which takes longer + // than the default per-test budget. + test.describe.configure({ timeout: 30_000 }); + + test("navigate release listing", async ({ page, peer }) => { + const { rid, repoFolder } = await createRepo(peer, { + name: "release-listing", + }); + await registerArtifact(peer, repoFolder, { name: "binary" }); + + await page.goto(`${peer.uiUrl()}/${rid}`); + await page.getByRole("link", { name: "Releases 1" }).click(); + await expect(page).toHaveURL(`${peer.uiUrl()}/${rid}/releases`); + + // The release COB has no title; it falls back to the commit summary. + await expect( + page.locator(".release-teaser").getByText("initial commit"), + ).toBeVisible(); + }); + + test("filter releases by author", async ({ page, peer, peerManager }) => { + const name = "release-authors"; + const { rid, repoFolder } = await createRepo(peer, { name }); + await registerArtifact(peer, repoFolder, { name: "delegate-binary" }); + + // Eve isn't a delegate, so her release is out of scope by default. + const { contributor, repoFolder: contributorFolder } = + await createContributor(peerManager, peer, { rid, repoName: name }); + // A release is keyed by a commit, so Eve needs a commit of her own in + // storage to open one. + await contributor.git( + ["commit", "--allow-empty", "--message", "eve commit"], + { cwd: contributorFolder }, + ); + await contributor.git(["push", "rad", "HEAD:refs/heads/eve"], { + cwd: contributorFolder, + }); + const { stdout: eveHead } = await contributor.git(["rev-parse", "HEAD"], { + cwd: contributorFolder, + }); + await registerArtifact(contributor, contributorFolder, { + name: "eve-binary", + revision: eveHead, + }); + await syncFrom(peer, repoFolder); + + await page.goto(`${peer.uiUrl()}/${rid}/releases`); + await expect(page.locator(".release-teaser")).toHaveCount(1); + await expect(page.getByText("eve commit")).toBeHidden(); + + await page.getByRole("link", { name: "All 2" }).click(); + await expect(page).toHaveURL( + `${peer.uiUrl()}/${rid}/releases?allAuthors=true`, + ); + await expect(page.locator(".release-teaser")).toHaveCount(2); + await expect(page.getByText("eve commit")).toBeVisible(); + + await page.getByRole("link", { name: "Delegates 1" }).click(); + await expect(page).toHaveURL(`${peer.uiUrl()}/${rid}/releases`); + await expect(page.locator(".release-teaser")).toHaveCount(1); + }); + + test("empty release listing", async ({ page, peer }) => { + const { rid } = await createRepo(peer, { name: "no-releases" }); + + await page.goto(`${peer.uiUrl()}/${rid}/releases`); + await expect(page.getByText("No releases")).toBeVisible(); + }); + + test("hide fully redacted releases", async ({ page, peer }) => { + const { rid, repoFolder } = await createRepo(peer, { + name: "redacted-release", + }); + const { releaseId, cid } = await registerArtifact(peer, repoFolder, { + name: "binary", + }); + await radArtifact(peer, repoFolder, [ + "redact", + "--release", + releaseId, + "--cid", + cid, + "--reason", + "Compromised build", + ]); + + await page.goto(`${peer.uiUrl()}/${rid}/releases`); + await expect(page.getByText("No releases")).toBeVisible(); + + // The tab counter comes from the repo metadata, which counts every release + // COB, redacted or not. + await expect(page.getByRole("link", { name: "Releases 1" })).toBeVisible(); + }); +}); + +test("releases are unavailable on older nodes", async ({ page }) => { + test.skip(useLocalHttpd, "older nodes are the pre-built httpd release"); + + await page.goto(cobUrl); + await expect(page.getByRole("link", { name: /^Releases/ })).toBeHidden(); + + await page.goto(`${cobUrl}/releases`); + await expect( + page.getByText("Releases are not available on this node"), + ).toBeVisible(); +}); diff --git a/tests/e2e/router.spec.ts b/tests/e2e/router.spec.ts index f9ecc3ba1..76302e11d 100644 --- a/tests/e2e/router.spec.ts +++ b/tests/e2e/router.spec.ts @@ -109,6 +109,9 @@ test.describe("repo page navigation", () => { await page.getByText(".hidden").click(); await expect(page).toHaveURL(`${repoTreeURL}/.hidden`); + // Wait for the blob, not just the URL: clicking a source tree that is + // about to be re-rendered loses the click. + await expect(page.getByText("I'm a hidden file.")).toBeVisible(); await page.getByText("bin").click(); await page.getByText("true").click(); @@ -154,6 +157,9 @@ test.describe("repo page navigation", () => { await page.getByText(".hidden").click(); await expect(page).toHaveURL(`${repoTreeURL}/tree/.hidden`); + // Wait for the blob, not just the URL: clicking a source tree that is + // about to be re-rendered loses the click. + await expect(page.getByText("I'm a hidden file.")).toBeVisible(); await page.getByText("bin").click(); await page.getByText("true").click(); @@ -178,6 +184,9 @@ test.describe("repo page navigation", () => { await page.getByText(".hidden").click(); await expect(page).toHaveURL(`${repoTreeURL}/.hidden`); + // Wait for the blob, not just the URL: clicking a source tree that is + // about to be re-rendered loses the click. + await expect(page.getByText("I'm a hidden file.")).toBeVisible(); await page.getByText("bin").click(); await page.getByText("true").click(); diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts index c5584c9b4..eba07aa3a 100644 --- a/tests/support/fixtures.ts +++ b/tests/support/fixtures.ts @@ -104,12 +104,17 @@ export const test = base.extend<{ await logFile.close(); }, - peerManager: async ({ stateDir, outputLog }, use) => { + peerManager: async ({ stateDir, outputLog, page }, use) => { const peerManager = await createPeerManager({ dataDir: Path.resolve(Path.join(stateDir, "peers")), outputLog, }); await use(peerManager); + // A test can end with requests still in flight, because the router pushes + // the URL before the route finishes loading. Close the page before the + // nodes go away, so those requests don't fail against a dead httpd and + // trip the page error handler above after the test has already passed. + await page.close(); await peerManager.shutdown(); }, diff --git a/tests/support/globalSetup.ts b/tests/support/globalSetup.ts index 74099527f..97a94d070 100644 --- a/tests/support/globalSetup.ts +++ b/tests/support/globalSetup.ts @@ -3,6 +3,7 @@ import * as Path from "node:path"; import { assertBinariesInstalled, heartwoodRelease, + radicleArtifactRelease, radicleHttpdRelease, removeWorkspace, tmpDir, @@ -25,6 +26,12 @@ const heartwoodBinaryPath = Path.join( "heartwood", heartwoodRelease, ).trim(); +const artifactBinaryPath = Path.join( + tmpDir, + "bin", + "artifact", + radicleArtifactRelease, +).trim(); const httpdBinaryPath = useLocalHttpd ? Path.join(tmpDir, "bin", "httpd", "local").trim() : Path.join(tmpDir, "bin", "httpd", radicleHttpdRelease).trim(); @@ -32,6 +39,7 @@ const httpdBinaryPath = useLocalHttpd process.env.PATH = [ heartwoodBinaryPath, httpdBinaryPath, + artifactBinaryPath, process.env.PATH, ].join(Path.delimiter); @@ -43,15 +51,20 @@ export default async function globalSetup(): Promise<() => void> { useLocalHttpd ? "pre-release" : radicleHttpdRelease, httpdBinaryPath, ); + await assertBinariesInstalled( + "rad-artifact", + radicleArtifactRelease, + artifactBinaryPath, + ); } catch (error) { console.error(error); console.log(""); + console.log("To download the required test binaries, run:"); + console.log(" 👉 ./scripts/install-binaries"); if (useLocalHttpd) { + console.log(""); console.log("To compile local radicle-httpd binary, run:"); console.log(" 👉 ./scripts/compile-local-httpd"); - } else { - console.log("To download the required test binaries, run:"); - console.log(" 👉 ./scripts/install-binaries"); } console.log(""); process.exit(1); @@ -172,6 +185,9 @@ export default async function globalSetup(): Promise<() => void> { "--version", ]); const { stdout: httpdVersion } = await exec("radicle-httpd", ["--version"]); + const { stdout: radArtifactVersion } = await exec("rad-artifact", [ + "--version", + ]); // radicle-httpd outputs logging lines, extract just the version line (last line) const httpdVersionClean = httpdVersion.trim().split("\n").pop() || httpdVersion; @@ -182,6 +198,7 @@ export default async function globalSetup(): Promise<() => void> { console.log( ` radicle-httpd: ${httpdVersionClean}${useLocalHttpd ? " (local)" : ""}`, ); + console.log(` rad-artifact: ${radArtifactVersion.trim()}`); console.log(""); return async () => { diff --git a/tests/support/radicle-artifact-release b/tests/support/radicle-artifact-release new file mode 100644 index 000000000..66333910a --- /dev/null +++ b/tests/support/radicle-artifact-release @@ -0,0 +1 @@ +0.18.0 diff --git a/tests/support/repo.ts b/tests/support/repo.ts index 4b262912e..f1b4312d2 100644 --- a/tests/support/repo.ts +++ b/tests/support/repo.ts @@ -1,8 +1,11 @@ import type { Page } from "@playwright/test"; -import type { RadiclePeer } from "@tests/support/peerManager"; +import type { PeerManager, RadiclePeer } from "@tests/support/peerManager"; +import * as Fs from "node:fs/promises"; import * as Path from "node:path"; +import { defaultConfig, gitOptions } from "@tests/support/fixtures.js"; + export async function changeBranch(peer: string, branch: string, page: Page) { await page.locator('[title="Change branch"]:visible').first().click(); const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer }); @@ -63,6 +66,83 @@ export async function createRepo( return { rid, repoFolder, defaultBranch }; } +// Run a rad-artifact command without prompts or network announcements. +export function radArtifact( + peer: RadiclePeer, + repoFolder: string, + args: string[], +) { + return peer.spawn("rad-artifact", ["--no-announce", "--no-input", ...args], { + cwd: repoFolder, + }); +} + +// Add a peer that has cloned the repo but is not a delegate, so tests can +// create releases and artifacts from a non-delegate author. Its contributions +// live in its own storage; pull them into the delegate's storage, which the UI +// reads, with `syncFrom`. The contributor is always Eve, which is safe because +// `peerManager` is scoped to a single test. +export async function createContributor( + peerManager: PeerManager, + delegate: RadiclePeer, + { rid, repoName }: { rid: string; repoName: string }, +): Promise<{ contributor: RadiclePeer; repoFolder: string }> { + const contributor = await peerManager.createPeer({ + name: "eve", + gitOptions: gitOptions["eve"], + }); + await contributor.startNode({ + node: { ...defaultConfig.node, connect: [delegate.address], alias: "eve" }, + }); + await contributor.rad(["clone", rid], { cwd: contributor.checkoutPath }); + + return { + contributor, + repoFolder: Path.join(contributor.checkoutPath, repoName), + }; +} + +// Fetch what other peers have contributed to the repo. Artifact fixtures are +// registered without announcing, so the delegate's node needs an explicit +// fetch before its httpd serves them. +export async function syncFrom(peer: RadiclePeer, repoFolder: string) { + await peer.rad(["sync", "--fetch"], { cwd: repoFolder }); +} + +// Write a file and register it as a release artifact. The file is named after +// the artifact and the CID is computed from its contents, which default to the +// name, so artifacts in one release need distinct names to get distinct CIDs. +// The release is created when `release` is not given. +export async function registerArtifact( + peer: RadiclePeer, + repoFolder: string, + { + name, + content = `${name}\n`, + revision = "HEAD", + release, + }: { + name: string; + content?: string; + revision?: string; + release?: string; + }, +): Promise<{ releaseId: string; cid: string }> { + const artifactPath = Path.join(repoFolder, name); + await Fs.writeFile(artifactPath, content); + + const { stdout } = await radArtifact(peer, repoFolder, [ + "register", + artifactPath, + ...(release ? ["--release", release] : ["--revision", revision]), + "--name", + name, + "--json", + ]); + + return JSON.parse(stdout); +} + export function extractPatchId(cmdOutput: { stderr: string }) { const match = cmdOutput.stderr.match(/[0-9a-f]{40}/); if (match) { diff --git a/tests/support/support.ts b/tests/support/support.ts index 16abe286e..e864f501d 100644 --- a/tests/support/support.ts +++ b/tests/support/support.ts @@ -33,15 +33,27 @@ export const radicleHttpdRelease = ( await Fs.readFile(`${supportDir}/radicle-httpd-release`, "utf8") ).trim(); +export const radicleArtifactRelease = ( + await Fs.readFile(`${supportDir}/radicle-artifact-release`, "utf8") +).trim(); + export const useLocalHttpd = process.env.USE_LOCAL_HTTPD === "true"; +export const releaseApiOnlyLocal = + "the release read API only exists in the local httpd build"; + // Assert that binaries are installed and are the correct version. export async function assertBinariesInstalled( binary: string, expectedVersion: string, expectedPath: string, ): Promise { - const { stdout: which } = await execa("which", [binary]); + let which: string; + try { + ({ stdout: which } = await execa("which", [binary])); + } catch { + throw new Error(`${binary} is not installed`); + } if (Path.dirname(which) !== expectedPath) { throw new Error( `${binary} path doesn't match used ${binary} binary: ${expectedPath} !== ${which}`,