chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A release can be yanked, leaving it to read but no one's latest
.changeset/release-yank.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Withdraw a bad release with `knotless release yank` | ||
| ||
`knotless release yank <url> <package> <version>` withdraws a version without breaking anyone who pinned it: its files can still be read by version, but it is no longer the latest, and the releases page marks it Yanked. `--undo` takes that back. The MCP tool is `release_yank`. Logging in again gives an existing OAuth login the permission to do it. | ||
crates/knotless/src/cli.rs+12−1
| @@ -421,6 +421,9 @@ release | ||
--oci adds an image's, from an OCI | ||
image layout, for knotless-serve's /v2/ | ||
release list <url> [<package>] the versions published, highest first | ||
release yank <url> <package> <version> [--undo] | ||
as owner, withdraw a version: it still | ||
downloads, but is no one's latest | ||
release read <url> <package> <version> <file> | ||
one file of a version (latest, for the | ||
highest), as it is, checked | ||
| @@ -2473,9 +2476,17 @@ async fn command( | ||
let files: Vec<&str> = r.files.iter().map(|f| f.name.as_str()).collect(); | ||
let (commit, files) = (commit.unwrap_or("-"), files.join(",")); | ||
let line = format!("{}\t{}\t{}", r.package, r.version, r.created_at); | ||
writeln!(out, "{line}\t{commit}\t{files}")?; | ||
let mark = if r.yanked { "\tyanked" } else { "" }; | ||
writeln!(out, "{line}\t{commit}\t{files}{mark}")?; | ||
} | ||
} | ||
("release", "yank") => { | ||
let url = args.url()?; | ||
let package = args.next("<package>")?; | ||
let version = args.next("<version>")?; | ||
let uri = crate::release::yank(ctx, &url, package, version, undo(&mut args)?).await?; | ||
writeln!(out, "{uri}")?; | ||
} | ||
("release", "download") => { | ||
let url = args.url()?; | ||
let package = args.next("<package>")?; | ||
crates/knotless/src/jetstream.rs+2−0
| @@ -73,6 +73,7 @@ pub fn knotless(collection: &str) -> bool { | ||
nsid::FOLLOW, | ||
nsid::REACTION, | ||
nsid::RELEASE, | ||
nsid::RELEASE_YANK, | ||
nsid::SITE, | ||
nsid::THEME, | ||
nsid::ORG, | ||
| @@ -299,6 +300,7 @@ mod tests { | ||
&nsid::FOLLOW, | ||
&nsid::REACTION, | ||
&nsid::RELEASE, | ||
&nsid::RELEASE_YANK, | ||
&nsid::THEME, | ||
&nsid::ORG, | ||
&nsid::ORG_MEMBER, | ||
crates/knotless/src/manage.rs+1−0
| @@ -1391,6 +1391,7 @@ pub async fn delete(ctx: &Ctx, url: &RepoUrl) -> Result<u64> { | ||
nsid::CLAIM, | ||
nsid::PACK, | ||
nsid::RELEASE, | ||
nsid::RELEASE_YANK, | ||
nsid::RERUN, | ||
nsid::CANCEL, | ||
] { | ||
crates/knotless/src/oauth.rs+1−0
| @@ -64,6 +64,7 @@ fn scope(spelled: bool) -> String { | ||
&nsid::CANCEL, | ||
// Versions of a package, published from a repository. | ||
&nsid::RELEASE, | ||
&nsid::RELEASE_YANK, | ||
// Themes for the pages, published from their settings. | ||
&nsid::THEME, | ||
// A runner's checks and the minutes it counts, written as its | ||
crates/knotless/src/records.rs+18−0
| @@ -75,6 +75,9 @@ pub mod nsid { | ||
/// One version of a package a repository publishes, in its owner's | ||
/// account. | ||
pub const RELEASE: &str = "dev.knotless.release"; | ||
/// That its owner withdrew a version of a package, in their account, | ||
/// keyed as the release is. | ||
pub const RELEASE_YANK: &str = "dev.knotless.releaseYank"; | ||
/// A theme for the pages, in the account that made it. | ||
pub const THEME: &str = "dev.knotless.theme"; | ||
/// A repository's website, in its owner's account, keyed by the | ||
| @@ -978,6 +981,21 @@ pub struct ReleaseRecord { | ||
pub notes: Option<String>, | ||
pub files: Vec<ReleaseFile>, | ||
pub created_at: String, | ||
/// Whether its owner yanked it: read from its | ||
/// [`nsid::RELEASE_YANK`] record, and no part of this one. | ||
#[serde(skip)] | ||
pub yanked: bool, | ||
} | ||
| ||
/// That its owner yanked a release, keyed as the release is, which never | ||
/// changes: deleting this undoes it. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct ReleaseYankRecord { | ||
/// Its key begins with this too, so a reader can do without it. | ||
#[serde(default)] | ||
pub repo: String, | ||
pub created_at: String, | ||
} | ||
| ||
/// A release's file, over blobs in order. | ||
crates/knotless/src/release.rs+64−5
| @@ -5,7 +5,9 @@ | ||
use crate::env::Ctx; | ||
use crate::keys; | ||
use crate::push; | ||
use crate::records::{self, RELEASE_MIME, ReleaseFile, ReleasePart, ReleaseRecord, nsid}; | ||
use crate::records::{ | ||
self, RELEASE_MIME, ReleaseFile, ReleasePart, ReleaseRecord, ReleaseYankRecord, nsid, | ||
}; | ||
use crate::remote::{self, Remote}; | ||
use crate::session; | ||
use crate::url::RepoUrl; | ||
| @@ -246,6 +248,7 @@ impl Target { | ||
notes: made.notes, | ||
files, | ||
created_at: records::now(), | ||
yanked: false, | ||
}; | ||
let typed = records::typed(nsid::RELEASE, &record); | ||
let write = remote::create(nsid::RELEASE, &self.rkey, typed); | ||
| @@ -256,8 +259,47 @@ impl Target { | ||
} | ||
} | ||
| ||
/// Yanks `package`'s `version` in `url`'s repository, as its owner, or with | ||
/// `undo` takes that back: its record is left as it is, and it is still | ||
/// there to read, but it is no longer anyone's latest. Asking for what is | ||
/// so already changes nothing. Returns the release's AT-URI. | ||
pub async fn yank( | ||
ctx: &Ctx, | ||
url: &RepoUrl, | ||
package: &str, | ||
version: &str, | ||
undo: bool, | ||
) -> Result<String> { | ||
let remote = Remote::open(ctx, url).await?; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
let key = repo.value.key; | ||
let rkey = keys::release_rkey(&key, package, version); | ||
let found = remote.get_record::<ReleaseRecord>(nsid::RELEASE, &rkey); | ||
if found.await?.is_none() { | ||
bail!("{url} has published no {package} {version}"); | ||
} | ||
let auth = session::ensure(ctx, &remote.identity).await?.auth(); | ||
let held = remote.get_record::<ReleaseYankRecord>(nsid::RELEASE_YANK, &rkey); | ||
if held.await?.is_some() == undo { | ||
let write = if undo { | ||
remote::delete(nsid::RELEASE_YANK, &rkey) | ||
} else { | ||
let record = ReleaseYankRecord { | ||
repo: key, | ||
created_at: records::now(), | ||
}; | ||
let typed = records::typed(nsid::RELEASE_YANK, &record); | ||
remote::create(nsid::RELEASE_YANK, &rkey, typed) | ||
}; | ||
remote.apply(vec![write], &auth).await?; | ||
} | ||
Ok(format!("at://{}/{}/{rkey}", remote.did(), nsid::RELEASE)) | ||
} | ||
| ||
/// The releases of `url`'s repository, of `package` alone if named, by | ||
/// package and then highest version first. | ||
/// package and then highest version first, each marked if it is yanked. | ||
pub async fn list(ctx: &Ctx, url: &RepoUrl, package: Option<&str>) -> Result<Vec<ReleaseRecord>> { | ||
let remote = Remote::open(ctx, url).await?; | ||
let Some(repo) = remote.repo().await? else { | ||
| @@ -270,7 +312,17 @@ pub async fn list(ctx: &Ctx, url: &RepoUrl, package: Option<&str>) -> Result<Vec | ||
let found = remote | ||
.scan_all::<ReleaseRecord>(nsid::RELEASE, &prefix) | ||
.await?; | ||
let mut releases: Vec<ReleaseRecord> = found.into_iter().map(|s| s.value).collect(); | ||
let yanks = remote | ||
.scan_all::<ReleaseYankRecord>(nsid::RELEASE_YANK, &prefix) | ||
.await?; | ||
let yanked = |rkey: &str| yanks.iter().any(|y| y.rkey == rkey); | ||
let mut releases: Vec<ReleaseRecord> = found | ||
.iter() | ||
.map(|s| ReleaseRecord { | ||
yanked: yanked(&s.rkey), | ||
..s.value.clone() | ||
}) | ||
.collect(); | ||
sort(&mut releases); | ||
Ok(releases) | ||
} | ||
| @@ -287,11 +339,11 @@ pub fn sort(releases: &mut [ReleaseRecord]) { | ||
} | ||
| ||
/// `package`'s latest among `releases`: its highest version without a | ||
/// pre-release part. | ||
/// pre-release part that is not yanked. | ||
pub fn latest<'a>(releases: &'a [ReleaseRecord], package: &str) -> Option<&'a ReleaseRecord> { | ||
releases | ||
.iter() | ||
.filter(|r| r.package == package) | ||
.filter(|r| r.package == package && !r.yanked) | ||
.filter_map(|r| Some((semver::Version::parse(&r.version).ok()?, r))) | ||
.filter(|(v, _)| v.pre.is_empty()) | ||
.max_by(|a, b| a.0.cmp(&b.0)) | ||
| @@ -416,6 +468,7 @@ mod tests { | ||
notes: None, | ||
files: Vec::new(), | ||
created_at: "2026-10-27T00:00:00.000Z".into(), | ||
yanked: false, | ||
} | ||
} | ||
| ||
| @@ -464,6 +517,12 @@ mod tests { | ||
]; | ||
assert_eq!(latest(&all, "knotless").unwrap().version, "0.10.0"); | ||
assert!(latest(&all, "none").is_none()); | ||
// A yanked version never is, and with none left there is no latest. | ||
let mut gone = release("knotless", "0.11.0"); | ||
gone.yanked = true; | ||
let with_yank = [gone.clone(), release("knotless", "0.9.0")]; | ||
assert_eq!(latest(&with_yank, "knotless").unwrap().version, "0.9.0"); | ||
assert!(latest(&[gone], "knotless").is_none()); | ||
sort(&mut all); | ||
let order: Vec<&str> = all.iter().map(|r| r.version.as_str()).collect(); | ||
assert_eq!(order, ["1.0.0-rc.1", "0.10.0", "0.9.0", "not one", "5.0.0"]); | ||
crates/knotless/src/uses.rs+1−1
| @@ -453,7 +453,7 @@ async fn resolve(ctx: &Ctx, used: &Use) -> Result<Locked> { | ||
let pre = used.version.contains('-'); | ||
let best = releases | ||
.iter() | ||
.filter(|r| used.takes(&r.version)) | ||
.filter(|r| !r.yanked && used.takes(&r.version)) | ||
.filter_map(|r| Some((semver::Version::parse(&r.version).ok()?, r))) | ||
.filter(|(v, _)| pre || v.pre.is_empty()) | ||
.max_by(|a, b| a.0.cmp(&b.0)); | ||
crates/knotless/tests/cli.rs+74−0
| @@ -1295,6 +1295,80 @@ fn reading_without_a_clone() { | ||
} | ||
} | ||
| ||
#[test] | ||
fn yanking_a_release() { | ||
let mut s = Sandbox::new(); | ||
let handle = s.account.handle.clone(); | ||
s.set("KNOTLESS_IDENTIFIER", &handle); | ||
let url = s.url("yanked"); | ||
let work = s.work("w", 1); | ||
s.git(&work, &["push", "-q", &url, "main"]); | ||
std::fs::write(s.path("SUMS"), b"sums\n").unwrap(); | ||
let sums = s.path("SUMS").to_string_lossy().into_owned(); | ||
for version in ["1.0.0", "1.1.0"] { | ||
let (code, _) = s.cli(&["release", "publish", &url, "tool", version, &sums], b""); | ||
assert_eq!(code, 0); | ||
} | ||
let latest = |s: &Sandbox| { | ||
let (code, out) = s.cli( | ||
&[ | ||
"release", | ||
"download", | ||
&url, | ||
"tool", | ||
"--file", | ||
"SUMS", | ||
"-o", | ||
&s.path("out").to_string_lossy(), | ||
], | ||
b"", | ||
); | ||
assert_eq!(code, 0, "{out}"); | ||
out.split('\t').nth(1).unwrap().to_string() | ||
}; | ||
assert_eq!(latest(&s), "1.1.0"); | ||
| ||
// Yanked, it says the release it withdrew, and asked twice changes nothing. | ||
let yank = ["release", "yank", &url, "tool", "1.1.0"]; | ||
let (code, out) = s.cli(&yank, b""); | ||
assert_eq!(code, 0, "{out}"); | ||
let uri = out.trim(); | ||
assert!(uri.starts_with(&format!("at://{}/dev.knotless.release/", s.account.did))); | ||
let rkey = uri.rsplit('/').next().unwrap(); | ||
assert_eq!(s.cli(&yank, b"").1.trim(), uri); | ||
let yanks = s.records("dev.knotless.releaseYank"); | ||
assert_eq!(yanks.len(), 1, "{yanks:?}"); | ||
assert!(yanks[0].0.ends_with(rkey), "{yanks:?}"); | ||
| ||
// No longer the latest, marked so, and still there to read by version. | ||
assert_eq!(latest(&s), "1.0.0"); | ||
let (_, listed) = s.cli(&["release", "list", &url], b""); | ||
let lines: Vec<&str> = listed.lines().collect(); | ||
assert!( | ||
lines[0].starts_with("tool\t1.1.0\t") && lines[0].ends_with("\tyanked"), | ||
"{listed}" | ||
); | ||
assert!(!lines[1].ends_with("yanked"), "{listed}"); | ||
let (code, out) = s.cli(&["release", "read", &url, "tool", "1.1.0", "SUMS"], b""); | ||
assert_eq!((code, out.as_str()), (0, "sums")); | ||
let (code, out) = s.cli(&["release", "read", &url, "tool", "latest", "SUMS"], b""); | ||
assert_eq!((code, out.as_str()), (0, "sums")); | ||
| ||
// Undone, it is the latest again; undone twice changes nothing. | ||
let undo = ["release", "yank", &url, "tool", "1.1.0", "--undo"]; | ||
assert_eq!(s.cli(&undo, b"").1.trim(), uri); | ||
assert_eq!(s.cli(&undo, b"").1.trim(), uri); | ||
assert!(s.records("dev.knotless.releaseYank").is_empty()); | ||
assert_eq!(latest(&s), "1.1.0"); | ||
| ||
// What was never published, or is not there, cannot be yanked. | ||
let (ok, _, err) = s.knotless(&["release", "yank", &url, "tool", "9.0.0"], b""); | ||
assert!(!ok && err.contains("has published no tool 9.0.0"), "{err}"); | ||
let gone = s.url("gone"); | ||
let (ok, _, err) = s.knotless(&["release", "yank", &gone, "tool", "1.0.0"], b""); | ||
assert!(!ok && err.contains("no such repository"), "{err}"); | ||
} | ||
| ||
#[test] | ||
fn releases() { | ||
let mut s = Sandbox::new(); | ||
crates/knotless-mcp/src/lib.rs+32−2
| @@ -513,6 +513,18 @@ pub struct Releases { | ||
pub package: Option<String>, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct Yank { | ||
/// `handle/name`, `did/name` or `knotless://handle/name`. | ||
pub repo: String, | ||
pub package: String, | ||
/// The version to withdraw. | ||
pub version: String, | ||
/// Take the yank back. | ||
#[serde(default)] | ||
pub undo: bool, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct ReadRelease { | ||
/// `handle/name`, `did/name` or `knotless://handle/name`. | ||
| @@ -4105,7 +4117,24 @@ impl Server { | ||
} | ||
| ||
#[tool( | ||
description = "A repository's releases, of one package or all: by package, then highest version first, each with its files' names, sizes and SHA-256, the commit it was built from, its notes, and whether it is the package's latest (the highest without a pre-release part)." | ||
description = "Withdraw a version of a package from a repository you own, or with undo take that back: it stays there to read and download, so what pins it keeps working, but it is no longer the package's latest or offered to new users. Answers the release's `uri`." | ||
)] | ||
async fn release_yank( | ||
&self, | ||
Parameters(p): Parameters<Yank>, | ||
) -> Result<CallToolResult, McpError> { | ||
answer( | ||
async { | ||
let url = repo_url(&p.repo)?; | ||
let uri = release::yank(&self.ctx, &url, &p.package, &p.version, p.undo).await?; | ||
Ok(json!({ "uri": uri })) | ||
} | ||
.await, | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "A repository's releases, of one package or all: by package, then highest version first, each with its files' names, sizes and SHA-256, the commit it was built from, its notes, whether it is yanked, and whether it is the package's latest (the highest without a pre-release part that is not yanked)." | ||
)] | ||
async fn release_list( | ||
&self, | ||
| @@ -4127,6 +4156,7 @@ impl Server { | ||
"package": r.package, | ||
"version": r.version, | ||
"latest": latest(r), | ||
"yanked": r.yanked, | ||
"commit": r.commit, | ||
"check": r.check, | ||
"notes": r.notes, | ||
| @@ -4195,7 +4225,7 @@ Collaboration: pull_create (write it from pull_template), pull_list, pull_show, | ||
| ||
Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (runner_list, runner_add, runner_remove, runner_minutes) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull; see secret_set, secret_list and secret_remove), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, run_list says whether they passed, workflow_list which a ref has and what runs each, and run_rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Agents, accounts whose pulls the runners check as code no one vouches for: agent_list, agent_add, agent_remove. | ||
| ||
Moderation: mod_hide, mod_block, mod_blocks and mod_report. Accounts: account_follow, account_follows, account_followers, account_search, account_profile (how you show on knotless), account_email, account_email_add, account_email_remove (which addresses show commits as yours), account_delegate (let a gateway write for you) and account_delete_data. Themes for knotless's pages: theme_check, theme_publish, theme_use, theme_delete and theme_list. What others did that concerns you: notification_list; choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with notification_watch, notification_subscribe, notification_mute and notification_unsubscribe (see notification_subscriptions). Packages: release_publish a version from a repository, see its versions with release_list, and read a file of one with release_read. Organizations: org_add, org_remove, org_join, org_leave, org_members, org_list and org_delete, once you have delegated to a gateway with account_delegate. Operators of a server: admin_takedown, admin_status, admin_reports, admin_stats and admin_logs. status says whether a server is well. An error naming a `knotless` command names a tool here: `knotless org join` is org_join, `knotless pull merge` is pull_merge, and so on, but for login, logout, upgrade, repo mirror, workflow pin and release download, which act on the machine they run on." | ||
Moderation: mod_hide, mod_block, mod_blocks and mod_report. Accounts: account_follow, account_follows, account_followers, account_search, account_profile (how you show on knotless), account_email, account_email_add, account_email_remove (which addresses show commits as yours), account_delegate (let a gateway write for you) and account_delete_data. Themes for knotless's pages: theme_check, theme_publish, theme_use, theme_delete and theme_list. What others did that concerns you: notification_list; choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with notification_watch, notification_subscribe, notification_mute and notification_unsubscribe (see notification_subscriptions). Packages: release_publish a version from a repository, see its versions with release_list, read a file of one with release_read, and withdraw one with release_yank. Organizations: org_add, org_remove, org_join, org_leave, org_members, org_list and org_delete, once you have delegated to a gateway with account_delegate. Operators of a server: admin_takedown, admin_status, admin_reports, admin_stats and admin_logs. status says whether a server is well. An error naming a `knotless` command names a tool here: `knotless org join` is org_join, `knotless pull merge` is pull_merge, and so on, but for login, logout, upgrade, repo mirror, workflow pin and release download, which act on the machine they run on." | ||
)] | ||
impl ServerHandler for Server { | ||
async fn call_tool( | ||