~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A release can be yanked, leaving it to read but no one's latest

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:release/yank
.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(
crates/knotless-mcp/tests/mcp.rs+19−0
crates/knotless-serve/src/index/shared.rs+10−7
crates/knotless-serve/src/web/releases.rs+22−2
crates/knotless-serve/src/web/steps.rs+11−2
crates/knotless-serve/src/index.rs+56−9
crates/knotless-serve/src/lib.rs+9−0
docs/design/packages.md+31−1
docs/reference/actions.md+1−0
docs/reference/api.md+1−1
docs/reference/cli.md+3−2
docs/reference/mcp.md+2−1
lexicons/dev/knotless/defs.json+3−2
lexicons/dev/knotless/release.json+2−2
lexicons/dev/knotless/releaseYank.jsonnew+20−0
web/src/lib/molecules/ReleaseName.svelte+11−5
web/src/app.css+4−0