chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The pages list the shared steps repositories publish
crates/knotless-serve/src/web/steps.rsnew+294−0
| @@ -0,0 +1,294 @@ | ||
//! Shared steps, as the index keeps what repositories share | ||
//! ([`crate::shared`]): `dev.knotless.listSteps`, the directory, and | ||
//! `dev.knotless.getStep`, one step with its versions and who uses it. | ||
| ||
use super::repo::invalid; | ||
use super::*; | ||
use crate::index::{STEP, Shared, Version}; | ||
use knotless::records::ReleaseRecord; | ||
use serde_json::Value; | ||
| ||
/// Steps a page of the directory shows unless asked for more or fewer. | ||
const PAGE: u32 = 50; | ||
| ||
/// The most repositories using a step that its page names. | ||
const USED_BY: u32 = 30; | ||
| ||
/// `dev.knotless.defs#stepView`. | ||
#[derive(Serialize)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct StepView { | ||
uri: String, | ||
name: String, | ||
repo: RepoViewBasic, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
description: Option<String>, | ||
version: String, | ||
secret_count: usize, | ||
use_count: u64, | ||
created_at: String, | ||
} | ||
| ||
/// `dev.knotless.defs#stepViewDetailed`. | ||
#[derive(Serialize)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct StepViewDetailed { | ||
uri: String, | ||
name: String, | ||
repo: RepoViewBasic, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
description: Option<String>, | ||
version: String, | ||
commit: String, | ||
path: String, | ||
uses: String, | ||
inputs: Vec<Value>, | ||
needs: Vec<Named>, | ||
secrets: Vec<Named>, | ||
versions: Vec<StepVersionView>, | ||
use_count: u64, | ||
used_by: Vec<RepoViewBasic>, | ||
created_at: String, | ||
} | ||
| ||
/// `dev.knotless.defs#stepCommandView` and `#stepSecretView`. | ||
#[derive(Serialize)] | ||
pub struct Named { | ||
name: String, | ||
} | ||
| ||
/// `dev.knotless.defs#stepVersionView`. | ||
#[derive(Serialize)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct StepVersionView { | ||
uri: String, | ||
version: String, | ||
commit: String, | ||
created_at: String, | ||
} | ||
| ||
/// A step's versions, the highest first; a version that is not one | ||
/// last. | ||
fn highest_first(versions: &mut [Version]) { | ||
let order = |r: &ReleaseRecord| std::cmp::Reverse(knotless::release::version(&r.version).ok()); | ||
versions.sort_by_key(|a| order(&a.1)); | ||
} | ||
| ||
/// The version shown as a step's: its highest with no pre-release part, | ||
/// or its highest when each has one. `versions` are the highest first. | ||
fn latest(versions: &[Version]) -> Option<&Version> { | ||
let release = | ||
|v: &&Version| knotless::release::version(&v.1.version).is_ok_and(|s| s.pre.is_empty()); | ||
versions.iter().find(release).or(versions.first()) | ||
} | ||
| ||
/// What a workflow's `uses` names `name` of `owner`'s repository `repo` | ||
/// by to take `version`'s major version: before 1.0.0, its minor one. | ||
fn uses(owner: &str, repo: &str, name: &str, version: &str) -> String { | ||
let spec = match knotless::release::version(version) { | ||
Ok(v) if v.major == 0 => format!("0.{}", v.minor), | ||
Ok(v) => v.major.to_string(), | ||
Err(_) => version.to_string(), | ||
}; | ||
format!("{owner}/{repo}/{name}@{spec}") | ||
} | ||
| ||
/// What a step file says, as [`crate::shared`] kept it, by key. | ||
fn said(value: &Value, key: &str) -> Vec<String> { | ||
let names = value[key].as_array().into_iter().flatten(); | ||
names | ||
.filter_map(|n| n.as_str().map(str::to_string)) | ||
.collect() | ||
} | ||
| ||
/// The index, or why there are no steps without one. | ||
fn indexed(app: &App) -> Result<&Index> { | ||
let why = || Shown(StatusCode::NOT_FOUND, "this server keeps no index".into()); | ||
app.index.as_deref().ok_or_else(why) | ||
} | ||
| ||
/// The owner and key of the repository `uri` names, its | ||
/// `dev.knotless.repo` record by handle or DID. | ||
async fn repo_of(app: &App, index: &Index, uri: &str) -> Result<(String, String)> { | ||
let rest = uri.strip_prefix("at://").unwrap_or_default(); | ||
let parts: Vec<&str> = rest.split('/').collect(); | ||
let [authority, nsid::REPO, name] = parts[..] else { | ||
let why = format!("{uri} is not at://<authority>/{}/<name>", nsid::REPO); | ||
return Err(invalid(why)); | ||
}; | ||
let id = knotless::identity::resolve(&app.ctx, authority).await; | ||
let did = id.map_err(not_found)?.did; | ||
let key = index.key_named(&did, name).ok().flatten(); | ||
let key = key.ok_or_else(|| not_found(anyhow::anyhow!("no repository {uri}")))?; | ||
Ok((did, key)) | ||
} | ||
| ||
/// `thing`'s repository, as it is shown: none when the index lacks it. | ||
async fn repo_view(app: &App, index: &Index, thing: &Shared) -> Option<RepoViewBasic> { | ||
let name = index.name(&thing.did, &thing.key).ok().flatten()?; | ||
Some(RepoViewBasic { | ||
did: thing.did.clone(), | ||
handle: handle(app, &thing.did).await, | ||
name, | ||
}) | ||
} | ||
| ||
/// `thing`, at its latest version, as the directory shows it: none when | ||
/// it has no version that is one. | ||
async fn view(app: &App, index: &Index, thing: Shared) -> Option<StepView> { | ||
let mut versions = index.shared_versions(&thing).ok()?; | ||
highest_first(&mut versions); | ||
let (uri, record, value) = latest(&versions)?; | ||
let (use_count, _) = index.users(&thing, 0).ok()?; | ||
Some(StepView { | ||
uri: uri.clone(), | ||
description: value["description"].as_str().map(str::to_string), | ||
version: record.version.clone(), | ||
secret_count: said(value, "secrets").len(), | ||
use_count, | ||
created_at: record.created_at.clone(), | ||
repo: repo_view(app, index, &thing).await?, | ||
name: thing.name, | ||
}) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
pub struct ListParams { | ||
repo: Option<String>, | ||
q: Option<String>, | ||
limit: Option<u32>, | ||
cursor: Option<String>, | ||
} | ||
| ||
#[derive(Serialize)] | ||
pub struct Steps { | ||
steps: Vec<StepView>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
cursor: Option<String>, | ||
} | ||
| ||
/// `dev.knotless.listSteps`. | ||
pub(super) async fn list_steps( | ||
State(app): State<Arc<App>>, | ||
Query(p): Query<ListParams>, | ||
) -> Result<Response> { | ||
let index = indexed(&app)?; | ||
let repo = match &p.repo { | ||
Some(uri) => Some(repo_of(&app, index, uri).await?), | ||
None => None, | ||
}; | ||
let limit = p.limit.unwrap_or(PAGE).clamp(1, 100); | ||
let prefix = p.q.as_deref().unwrap_or_default().trim(); | ||
let of = repo.as_ref().map(|(did, key)| (did.as_str(), key.as_str())); | ||
let page = index.shared(STEP, of, prefix, limit, p.cursor.as_deref()); | ||
let (found, cursor) = page | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let each = found.into_iter().map(|thing| view(&app, index, thing)); | ||
let steps = futures::future::join_all(each).await; | ||
Ok(answer(Steps { | ||
steps: steps.into_iter().flatten().collect(), | ||
cursor, | ||
})) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
pub struct GetParams { | ||
repo: String, | ||
name: String, | ||
} | ||
| ||
/// `dev.knotless.getStep`. | ||
pub(super) async fn get_step( | ||
State(app): State<Arc<App>>, | ||
Query(p): Query<GetParams>, | ||
) -> Result<Response> { | ||
let index = indexed(&app)?; | ||
let (did, key) = repo_of(&app, index, &p.repo).await?; | ||
let thing = Shared { | ||
kind: STEP.into(), | ||
did, | ||
key, | ||
name: p.name, | ||
}; | ||
let none = || not_found(anyhow::anyhow!("{} has no step {}", p.repo, thing.name)); | ||
let mut versions = index.shared_versions(&thing).unwrap_or_default(); | ||
highest_first(&mut versions); | ||
let (uri, record, value) = latest(&versions).ok_or_else(none)?.clone(); | ||
let repo = repo_view(&app, index, &thing).await.ok_or_else(none)?; | ||
let (use_count, users) = index.users(&thing, USED_BY).unwrap_or_default(); | ||
let app = &app; | ||
let each = users.into_iter().map(|(did, name)| async move { | ||
RepoViewBasic { | ||
handle: handle(app, &did).await, | ||
did, | ||
name, | ||
} | ||
}); | ||
let used_by = futures::future::join_all(each).await; | ||
let named = |key: &str| said(&value, key).into_iter().map(|name| Named { name }); | ||
let versions = versions.into_iter().map(|(uri, r, _)| StepVersionView { | ||
uri, | ||
version: r.version, | ||
commit: r.commit.unwrap_or_default(), | ||
created_at: r.created_at, | ||
}); | ||
Ok(answer(StepViewDetailed { | ||
uri, | ||
uses: uses(&repo.handle, &repo.name, &thing.name, &record.version), | ||
path: format!("{}/{}.yml", knotless::uses::STEPS, thing.name), | ||
description: value["description"].as_str().map(str::to_string), | ||
version: record.version, | ||
commit: record.commit.unwrap_or_default(), | ||
inputs: value["inputs"].as_array().cloned().unwrap_or_default(), | ||
needs: named("needs").collect(), | ||
secrets: named("secrets").collect(), | ||
versions: versions.collect(), | ||
use_count, | ||
used_by, | ||
created_at: record.created_at, | ||
repo, | ||
name: thing.name, | ||
})) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
fn version(v: &str) -> Version { | ||
let record = ReleaseRecord { | ||
repo: "k".into(), | ||
package: "@step/s".into(), | ||
version: v.into(), | ||
commit: None, | ||
check: None, | ||
notes: None, | ||
files: Vec::new(), | ||
created_at: String::new(), | ||
}; | ||
(v.into(), record, Value::Null) | ||
} | ||
| ||
#[test] | ||
fn the_latest_is_the_highest_released_or_else_the_highest() { | ||
let mut versions: Vec<Version> = ["1.0.0", "1.10.0", "2.0.0-rc.1", "1.2.0", "x"] | ||
.into_iter() | ||
.map(version) | ||
.collect(); | ||
highest_first(&mut versions); | ||
let order: Vec<&str> = versions.iter().map(|v| v.0.as_str()).collect(); | ||
assert_eq!(order, ["2.0.0-rc.1", "1.10.0", "1.2.0", "1.0.0", "x"]); | ||
assert_eq!(latest(&versions).unwrap().0, "1.10.0"); | ||
let pre = [version("0.1.0-rc.1")]; | ||
assert_eq!(latest(&pre).unwrap().0, "0.1.0-rc.1"); | ||
assert!(latest(&[]).is_none()); | ||
} | ||
| ||
#[test] | ||
fn uses_names_the_major_version() { | ||
assert_eq!(uses("a.test", "ci", "wait", "1.2.0"), "a.test/ci/wait@1"); | ||
assert_eq!(uses("a.test", "ci", "wait", "0.3.1"), "a.test/ci/wait@0.3"); | ||
assert_eq!(uses("a.test", "ci", "wait", "x"), "a.test/ci/wait@x"); | ||
} | ||
} | ||
crates/knotless-serve/src/index.rs+41−5
| @@ -27,6 +27,9 @@ use std::sync::{Arc, Mutex}; | ||
use std::time::{Duration, Instant}; | ||
use tokio::sync::{broadcast, mpsc}; | ||
| ||
mod shared; | ||
pub use shared::{STEP, Shared, Version, kind_of}; | ||
| ||
/// The kinds of preference kept, in `pref`: how the pages are drawn, and | ||
/// which notifications an account hears of, which the queries for them | ||
/// read by name. | ||
| @@ -55,6 +58,10 @@ pub struct Index { | ||
recount: mpsc::UnboundedSender<(String, String)>, | ||
/// What counts them takes this, once ([`crate::languages::follow`]). | ||
counting: Mutex<Option<mpsc::UnboundedReceiver<(String, String)>>>, | ||
/// The releases of things shared whose files are to be read, by URI; | ||
/// what reads them takes the other end, once ([`crate::shared::follow`]). | ||
read: mpsc::UnboundedSender<String>, | ||
reading: Mutex<Option<mpsc::UnboundedReceiver<String>>>, | ||
/// When what each repository's pulls and issues are made of last | ||
/// changed, for [`Index::assembled`] to know what it kept still holds. | ||
stamps: Stamps, | ||
| @@ -366,6 +373,12 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
let collaborated = db | ||
.prepare("SELECT 1 FROM sqlite_master WHERE name = 'collaborator'")? | ||
.exists([])?; | ||
let shared = db | ||
.prepare("SELECT 1 FROM sqlite_master WHERE name = 'shared'")? | ||
.exists([])?; | ||
let used = db | ||
.prepare("SELECT 1 FROM sqlite_master WHERE name = 'shared_use'")? | ||
.exists([])?; | ||
// A repository is known by its owner and key; its name, and the | ||
// URIs others wrote naming it, can change. | ||
db.execute_batch(&format!( | ||
| @@ -509,7 +522,9 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
CREATE INDEX IF NOT EXISTS label_uri ON label (uri); | ||
DROP VIEW IF EXISTS temp.down; | ||
CREATE TABLE IF NOT EXISTS down (subject TEXT PRIMARY KEY); | ||
{shared} | ||
{triggers}", | ||
shared = shared::TABLES, | ||
triggers = down_triggers(), | ||
))?; | ||
// What is down, from what makes it so, as it stands now: the | ||
| @@ -556,6 +571,17 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
if !collaborated { | ||
let _ = db.execute(COLLABORATORS, [None::<&str>]); | ||
} | ||
// The releases of what is shared, from the releases, once: an index | ||
// from before had them only there. | ||
if !shared { | ||
let _ = db.execute(shared::UNREAD, []); | ||
} | ||
// What each repository uses is read as what it is written in is | ||
// counted: an index from before counted them without, so each is | ||
// counted again as a list next shows it. | ||
if !used { | ||
let _ = db.execute("DELETE FROM language", []); | ||
} | ||
Ok((db, rebuilt)) | ||
} | ||
| ||
| @@ -594,7 +620,7 @@ const CACHE: &str = "DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; | ||
DROP TABLE IF EXISTS reaction; DROP TABLE IF EXISTS release; DROP TABLE IF EXISTS mention; | ||
DROP TABLE IF EXISTS reviewer; | ||
DROP TABLE IF EXISTS org; DROP TABLE IF EXISTS org_member; DROP TABLE IF EXISTS membership; | ||
DROP TABLE IF EXISTS collaborator;"; | ||
DROP TABLE IF EXISTS collaborator; DROP TABLE IF EXISTS shared;"; | ||
| ||
/// A label that has not lapsed. | ||
const LIVE: &str = "(exp IS NULL OR exp > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"; | ||
| @@ -663,6 +689,8 @@ const FORGET_REPO: &[&str] = &[ | ||
"DELETE FROM forge WHERE repo_did = ?1 AND repo_key = ?2", | ||
"DELETE FROM check_run WHERE repo_did = ?1 AND repo_key = ?2", | ||
"DELETE FROM release WHERE did = ?1 AND repo_key = ?2", | ||
"DELETE FROM shared WHERE did = ?1 AND repo_key = ?2", | ||
"DELETE FROM shared_use WHERE did = ?1 AND key = ?2", | ||
"DELETE FROM star WHERE repo_did = ?1 AND repo_key = ?2", | ||
"DELETE FROM push WHERE did = ?1 AND key = ?2", | ||
"DELETE FROM language WHERE did = ?1 AND key = ?2", | ||
| @@ -694,6 +722,8 @@ const PURGE: &[&str] = &[ | ||
"DELETE FROM follow WHERE did = ?1", | ||
"DELETE FROM reaction WHERE did = ?1 -- scans: reaction, once, as an account is forgotten", | ||
"DELETE FROM release WHERE did = ?1", | ||
"DELETE FROM shared WHERE did = ?1", | ||
"DELETE FROM shared_use WHERE did = ?1", | ||
"DELETE FROM theme WHERE did = ?1", | ||
"DELETE FROM org WHERE did = ?1 OR substr(space, 1, length(?2)) = ?2", | ||
"DELETE FROM org_member WHERE org = ?1 OR substr(space, 1, length(?2)) = ?2", | ||
| @@ -731,6 +761,7 @@ impl Index { | ||
let (db, rebuilt) = connect(path)?; | ||
crate::telemetry::watch(&db); | ||
let (recount, counting) = mpsc::unbounded_channel(); | ||
let (read, reading) = mpsc::unbounded_channel(); | ||
Ok(Index { | ||
db: Mutex::new(db), | ||
rebuilt, | ||
| @@ -738,6 +769,8 @@ impl Index { | ||
asked: Mutex::default(), | ||
recount, | ||
counting: Mutex::new(Some(counting)), | ||
read, | ||
reading: Mutex::new(Some(reading)), | ||
stamps: Stamps::new(STAMPS_MAX), | ||
kept: Mutex::new(Kept::new(KEPT_ENTRIES)), | ||
}) | ||
| @@ -928,10 +961,12 @@ impl Index { | ||
&& knotless::release::version(&r.version).is_ok() | ||
}); | ||
match release { | ||
Some(release) => db.execute( | ||
"INSERT OR REPLACE INTO release VALUES (?1, ?2, ?3, ?4, ?5)", | ||
params![uri, did, release.repo, release.created_at, text], | ||
), | ||
Some(release) => db | ||
.execute( | ||
"INSERT OR REPLACE INTO release VALUES (?1, ?2, ?3, ?4, ?5)", | ||
params![uri, did, release.repo, release.created_at, text], | ||
) | ||
.and_then(|n| self.share(&db, &uri, did, &release).map(|_| n)), | ||
None => Ok(0), | ||
} | ||
} else if let Some(repo) = named("repo") { | ||
| @@ -1142,6 +1177,7 @@ impl Index { | ||
db.execute("DELETE FROM minutes WHERE uri = ?1", [&uri])?; | ||
db.execute("DELETE FROM runner_key WHERE uri = ?1", [&uri])?; | ||
db.execute("DELETE FROM release WHERE uri = ?1", [&uri])?; | ||
db.execute("DELETE FROM shared WHERE uri = ?1", [&uri])?; | ||
db.execute( | ||
"DELETE FROM profile_record WHERE did = ?1 AND collection = ?2 AND rkey = ?3", | ||
[did, collection, rkey], | ||
crates/knotless-serve/src/languages.rs+15−4
| @@ -10,6 +10,8 @@ | ||
//! | ||
//! What a public repository is mostly written in, which lists of them | ||
//! show, is kept in the index ([`follow`]), so a list reads no repository. | ||
//! The shared steps its lock pins are read from the same tree as it is | ||
//! counted ([`crate::shared`]). | ||
| ||
use crate::App; | ||
use crate::objects::{self, Objects}; | ||
| @@ -260,15 +262,24 @@ pub async fn follow(app: Arc<App>) { | ||
} | ||
} | ||
| ||
/// Counts `did`'s repository with `key` again, and keeps the answer. | ||
/// Counts `did`'s repository with `key` again, and keeps the answer; and | ||
/// what shared steps its lock pins, read from the same tree | ||
/// ([`crate::shared::read_uses`]). | ||
async fn recount(app: &App, did: &str, key: &str) -> Option<()> { | ||
let index = app.index.as_ref()?; | ||
index.answered(did, key); | ||
let name = index.name(did, key).ok().flatten()?; | ||
let git = app.mirror(did, &name).await.ok()?; | ||
let objects = objects::open(&git.dir).ok()?; | ||
let value = serde_json::to_string(&main_of(&objects)).ok()?; | ||
index.set_language(did, key, &value).ok() | ||
// Read, and let go of, before what is awaited: a reader belongs to | ||
// one thread. | ||
let (value, lock) = { | ||
let objects = objects::open(&git.dir).ok()?; | ||
let value = serde_json::to_string(&main_of(&objects)).ok()?; | ||
(value, objects.blob("HEAD", knotless::uses::LOCK).ok()) | ||
}; | ||
index.set_language(did, key, &value).ok()?; | ||
crate::shared::read_uses(app, index, did, key, lock.as_deref()).await; | ||
Some(()) | ||
} | ||
| ||
#[cfg(test)] | ||
crates/knotless-serve/src/lib.rs+2−0
| @@ -36,6 +36,7 @@ pub mod plans; | ||
pub mod private; | ||
pub mod releases; | ||
mod secrets; | ||
pub mod shared; | ||
mod smart; | ||
pub mod spaces; | ||
pub mod status; | ||
| @@ -1172,6 +1173,7 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
} | ||
tokio::spawn(spaces::follow(app.clone(), SPACES)); | ||
tokio::spawn(languages::follow(app.clone())); | ||
tokio::spawn(shared::follow(app.clone())); | ||
tokio::spawn(telemetry::summaries(app.clone(), SUMMARY)); | ||
let listener = tokio::net::TcpListener::bind(&opts.listen).await?; | ||
let addr = listener.local_addr()?; | ||
crates/knotless-serve/src/plans.rs+3−1
| @@ -32,7 +32,7 @@ pub const ENV: &str = "KNOTLESS_PLANS"; | ||
/// The tables that grow with what people do, as many rows as prod might | ||
/// hold: a scan of one is a scan of the network. The rest are said to be | ||
/// small, as a scan of them is. | ||
pub const GROWING: [&str; 14] = [ | ||
pub const GROWING: [&str; 16] = [ | ||
"forge", | ||
"check_run", | ||
"comment", | ||
| @@ -47,6 +47,8 @@ pub const GROWING: [&str; 14] = [ | ||
"release", | ||
"reviewer", | ||
"theme", | ||
"shared", | ||
"shared_use", | ||
]; | ||
| ||
/// The rows a growing table is said to hold, and a small one. | ||
crates/knotless-serve/src/web.rs+3−0
| @@ -66,6 +66,7 @@ mod repos; | ||
mod runs; | ||
mod settings; | ||
mod stars; | ||
mod steps; | ||
mod themes; | ||
mod threads; | ||
mod views; | ||
| @@ -167,6 +168,8 @@ pub fn queries() -> Router<Arc<App>> { | ||
post(controls::remove_triager), | ||
) | ||
.route("/xrpc/dev.knotless.listThemes", get(themes::list_themes)) | ||
.route("/xrpc/dev.knotless.listSteps", get(steps::list_steps)) | ||
.route("/xrpc/dev.knotless.getStep", get(steps::get_step)) | ||
.route("/xrpc/dev.knotless.createTheme", post(themes::create_theme)) | ||
.route("/xrpc/dev.knotless.deleteTheme", post(themes::delete_theme)) | ||
.route("/xrpc/dev.knotless.followActor", post(follow_actor)) | ||
crates/knotless-serve/tests/web.rs+157−0
| @@ -3105,3 +3105,160 @@ fn badges_count_where_an_account_lives() { | ||
assert_eq!(kinds, ["carry-your-own", "moved-house"]); | ||
wait_until(|| index.home(did).unwrap().unwrap().0 == Home::default()); | ||
} | ||
| ||
#[test] | ||
fn the_steps_shared() { | ||
let site = serve(true); | ||
let owner = person(account(), &site); | ||
let handle = owner.account.handle.clone(); | ||
let url = owner.url("steps"); | ||
ok(&owner, &["create", &url]); | ||
let work = owner.work("w", 1); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
let commit = owner.git(&work, &["rev-parse", "HEAD"]); | ||
| ||
// Steps, each released as its package with the commit it is at; a | ||
// file that is no step, and a release naming no commit, are passed by. | ||
let file = |name: &str, text: &str| { | ||
let path = owner.path(name); | ||
std::fs::write(&path, text).unwrap(); | ||
path.to_string_lossy().into_owned() | ||
}; | ||
let wait = file( | ||
"wait.yml", | ||
"description: Waits for this commit's checks.\ninputs:\n workflows: {description: Which must pass, required: true}\n minutes: {default: \"25\"}\nneeds: [curl]\nsecrets: [TOKEN]\nsteps:\n - run: echo\n", | ||
); | ||
let lint = file("lint.yml", "steps:\n - run: cargo clippy\n"); | ||
let broken = file("broken.yml", "not: a step\n"); | ||
let publish = |package: &str, version: &str, path: &str, commit: Option<&str>| { | ||
let mut args = vec!["publish", &url, package, version, path]; | ||
args.extend(commit.map(|c| ["--commit", c]).into_iter().flatten()); | ||
ok(&owner, &args); | ||
}; | ||
publish("@step/wait", "1.0.0", &wait, Some(&commit)); | ||
let repo = format!("at://{handle}/dev.knotless.repo/steps"); | ||
let releases = || site.json(&xrpc("listReleases", &[("repo", &repo)]))["releases"].clone(); | ||
wait_until(|| releases().as_array().unwrap().len() == 1); | ||
// What was indexed before they start is read first. | ||
let rt = &site._rt; | ||
rt.spawn(knotless_serve::shared::follow(site.app.clone())); | ||
rt.spawn(knotless_serve::languages::follow(site.app.clone())); | ||
publish("@step/wait", "1.1.0", &wait, Some(&commit)); | ||
publish("@step/wait", "2.0.0-rc.1", &wait, Some(&commit)); | ||
publish("@step/lint", "0.3.0", &lint, Some(&commit)); | ||
publish("@step/broken", "1.0.0", &broken, Some(&commit)); | ||
publish("@step/loose", "1.0.0", &lint, None); | ||
wait_until(|| releases().as_array().unwrap().len() == 6); | ||
| ||
let list = |params: &[(&str, &str)]| site.json(&xrpc("listSteps", params)); | ||
let names = |listed: &Value| -> Vec<String> { | ||
let steps = listed["steps"].as_array().unwrap().iter(); | ||
steps.map(|s| s["name"].as_str().unwrap().into()).collect() | ||
}; | ||
wait_until(|| names(&list(&[])).len() == 2 && list(&[])["steps"][1]["version"] == "1.1.0"); | ||
let all = list(&[]); | ||
assert_eq!(names(&all), ["lint", "wait"]); | ||
let shown = &all["steps"][1]; | ||
assert_eq!(shown["repo"]["handle"], handle.as_str()); | ||
assert_eq!(shown["repo"]["name"], "steps"); | ||
assert_eq!(shown["description"], "Waits for this commit's checks."); | ||
assert_eq!(shown["secretCount"], 1); | ||
assert_eq!(shown["useCount"], 0); | ||
assert!(all["steps"][0]["description"].is_null()); | ||
assert!(all["cursor"].is_null()); | ||
// A page at a time, by name; by how a name starts; one repository's. | ||
let first = list(&[("limit", "1")]); | ||
assert_eq!(names(&first), ["lint"]); | ||
let cursor = first["cursor"].as_str().unwrap().to_string(); | ||
let rest = list(&[("limit", "1"), ("cursor", &cursor)]); | ||
assert_eq!(names(&rest), ["wait"]); | ||
assert!(rest["cursor"].is_null()); | ||
assert_eq!(names(&list(&[("q", "wa")])), ["wait"]); | ||
assert!(names(&list(&[("q", "x")])).is_empty()); | ||
assert_eq!(names(&list(&[("repo", &repo)])).len(), 2); | ||
let other = owner.url("user"); | ||
ok(&owner, &["create", &other]); | ||
let used_in = format!("at://{handle}/dev.knotless.repo/user"); | ||
let status = | ||
|nsid: &str, params: &[(&str, &str)]| site.get(&xrpc(nsid, params)).status().as_u16(); | ||
wait_until(|| status("listSteps", &[("repo", &used_in)]) == 200); | ||
assert!(names(&list(&[("repo", &used_in)])).is_empty()); | ||
assert_eq!(status("listSteps", &[("repo", "at://nope")]), 400); | ||
let nowhere = format!("at://{handle}/dev.knotless.repo/nowhere"); | ||
assert_eq!(status("listSteps", &[("repo", &nowhere)]), 404); | ||
let unknown = "at://nobody.invalid/dev.knotless.repo/x"; | ||
assert_eq!(status("listSteps", &[("repo", unknown)]), 404); | ||
| ||
// One step: what it takes and asks for, and its versions, the highest | ||
// first. | ||
let get = |name: &str| site.json(&xrpc("getStep", &[("repo", &repo), ("name", name)])); | ||
let step = get("wait"); | ||
assert_eq!(step["version"], "1.1.0"); | ||
assert_eq!(step["commit"], commit.as_str()); | ||
assert_eq!(step["path"], ".knotless/steps/wait.yml"); | ||
assert_eq!(step["uses"], format!("{handle}/steps/wait@1")); | ||
assert_eq!( | ||
step["inputs"], | ||
serde_json::json!([ | ||
{"name": "minutes", "required": false, "default": "25"}, | ||
{"name": "workflows", "required": true, "description": "Which must pass"}, | ||
]) | ||
); | ||
assert_eq!(step["needs"], serde_json::json!([{"name": "curl"}])); | ||
assert_eq!(step["secrets"], serde_json::json!([{"name": "TOKEN"}])); | ||
let versions: Vec<&str> = step["versions"] | ||
.as_array() | ||
.unwrap() | ||
.iter() | ||
.map(|v| v["version"].as_str().unwrap()) | ||
.collect(); | ||
assert_eq!(versions, ["2.0.0-rc.1", "1.1.0", "1.0.0"]); | ||
assert_eq!(get("lint")["uses"], format!("{handle}/steps/lint@0.3")); | ||
for missing in ["broken", "loose", "nothing"] { | ||
assert_eq!( | ||
status("getStep", &[("repo", &repo), ("name", missing)]), | ||
404 | ||
); | ||
} | ||
| ||
// Used, as a repository's lock on its default branch pins it: read as | ||
// what the repository is written in is counted, once a list shows it. | ||
// Pinned twice, it is one repository using it. | ||
let user = owner.work("u", 1); | ||
std::fs::create_dir_all(user.join(".knotless")).unwrap(); | ||
let pin = format!("{{version: 1.1.0, commit: {commit}}}"); | ||
let lock = format!( | ||
"{handle}/steps/wait@1: {pin}\n{handle}/steps/wait@1.1: {pin}\n{handle}/nowhere/wait@1: {pin}\nnobody.invalid/x/y@1: {pin}\nnot-a-use: {pin}\n" | ||
); | ||
owner.commit_file(&user, ".knotless/uses.lock", &lock); | ||
owner.git(&user, &["push", "-q", &other, "main"]); | ||
let users = || { | ||
site.profile(&handle); | ||
get("wait")["useCount"].clone() | ||
}; | ||
wait_until(|| users() == 1); | ||
let step = get("wait"); | ||
assert_eq!( | ||
step["usedBy"], | ||
serde_json::json!([{"did": owner.account.did, "handle": handle, "name": "user"}]) | ||
); | ||
assert_eq!(list(&[("q", "wait")])["steps"][0]["useCount"], 1); | ||
| ||
// Taken down, a step's releases leave it. | ||
let index = site.app.index.clone().unwrap(); | ||
for v in step["versions"].as_array().unwrap() { | ||
let uri = v["uri"].as_str().unwrap(); | ||
let subject = serde_json::json!({"$type": "com.atproto.repo.strongRef", "uri": uri}); | ||
index | ||
.take_down("did:web:here", uri, &subject, true) | ||
.unwrap(); | ||
} | ||
assert_eq!(names(&list(&[])), ["lint"]); | ||
| ||
// With no index, there are none. | ||
let plain = serve(false); | ||
assert_eq!(plain.get(&xrpc("listSteps", &[])).status().as_u16(), 404); | ||
plain | ||
._rt | ||
.block_on(knotless_serve::shared::follow(plain.app.clone())); | ||
} | ||
docs/design/reusable-workflows.md+14−4
| @@ -234,14 +234,24 @@ Once it lands, the default branch's runs use the steps with its | ||
secrets, and the reviewed diff of the lock is the approval. For the rest | ||
of who runs whose code, see [security](security.md). | ||
| ||
## Finding a step | ||
| ||
The pages list every shared step the appview has indexed at `/steps`, | ||
by name, each with its repository, what it does, its latest version, how | ||
many public repositories pin it and how many secrets it asks for. A | ||
step's own page, `/steps/<owner>/<repo>/<step>`, gives the lines to paste | ||
into a workflow, its inputs, the secrets and commands it asks for, each | ||
version with the commit it was published at, and the repositories that | ||
use it. A repository that publishes steps links to them from its release | ||
line, and each step's release links to its page. How the appview finds | ||
them, and counts their uses from each repository's lock, is in | ||
[serve](serve.md#shared-steps). | ||
| ||
## Not built yet | ||
| ||
- Outputs. Steps pass state to later steps through files in the shared | ||
container. This was left out of the first version on purpose. | ||
- A directory of steps on the pages, indexed by knotless-serve from step | ||
files at release commits, with use counts from the locks on default | ||
branches. It is meant to hold other kinds of shared thing, such as | ||
themes, later. | ||
- Themes and other kinds of shared thing in the directory of steps. | ||
- Private step repositories. A step's repository is read through the | ||
runner's mirror as a public one is, and shared steps have been tested | ||
only with public ones. | ||