chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
An organization's admins set its repositories' secrets
.changeset/org-admin-secrets.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: patch | ||
--- | ||
| ||
# An organization's admins set its repositories' secrets | ||
| ||
`knotless secret <url> set`, `ls` and `rm`, and the MCP `secret` tool, now work on a repository an organization owns when you are logged in as an admin who joined it, not only as the organization's own account. The values are sealed as the organization's, so its runners open them as before. | ||
crates/knotless/src/manage.rs+1−1
| @@ -678,7 +678,7 @@ pub async fn set_runner(ctx: &Ctx, url: &RepoUrl, who: &str, add: bool) -> Resul | ||
let said = match add { | ||
true => crate::secrets::unsealed_for(ctx, &auth, remote.did(), key, &who.did).await, | ||
false => { | ||
crate::secrets::forget(ctx, &auth, key, &who.did).await; | ||
crate::secrets::forget(ctx, &auth, remote.did(), key, &who.did).await; | ||
None | ||
} | ||
}; | ||
crates/knotless/src/secrets.rs+29−20
| @@ -1,7 +1,8 @@ | ||
//! Secrets an owner gives a repository's runners, each sealed to a runner's | ||
//! key ([`crate::sealed`]) and left at the appview, which holds only | ||
//! ciphertext. The owner puts, lists and deletes them; a runner lists those | ||
//! sealed to it, to open. Each signs for itself with a service token. | ||
//! ciphertext. The owner puts, lists and deletes them, as does an admin who | ||
//! joined the organization that owns it; a runner lists those sealed to | ||
//! it, to open. Each signs for itself with a service token. | ||
| ||
use crate::auth; | ||
use crate::crypto::PublicKey; | ||
| @@ -100,27 +101,31 @@ async fn call(ctx: &Ctx, me: &Session, nsid: &str, body: &Value) -> Result<Value | ||
xrpc.procedure(nsid, Some(body), Some(&auth)).await | ||
} | ||
| ||
/// Leaves `secret`, sealed, for `me`'s repository `repo`, by its key. | ||
pub async fn put(ctx: &Ctx, me: &Session, repo: &str, secret: &Secret) -> Result<()> { | ||
/// Leaves `secret`, sealed, for `owner`'s repository `repo`, by its key, | ||
/// as `me`: its owner, or an admin of it as an organization. | ||
pub async fn put(ctx: &Ctx, me: &Session, owner: &str, repo: &str, secret: &Secret) -> Result<()> { | ||
let mut body = serde_json::to_value(secret)?; | ||
body["owner"] = owner.into(); | ||
body["repo"] = repo.into(); | ||
let fields = ["repo", "runner", "name", "refs", "key", "sealed"]; | ||
let fields = ["owner", "repo", "runner", "name", "refs", "key", "sealed"]; | ||
body.as_object_mut() | ||
.expect("a secret is an object") | ||
.retain(|k, _| fields.contains(&k.as_str())); | ||
call(ctx, me, PUT, &body).await.map(drop) | ||
} | ||
| ||
/// Deletes those of `me`'s repository `repo` of `name`, sealed to | ||
/// `runner`, or both; with neither, all of them. Says how many it deleted. | ||
/// Deletes those of `owner`'s repository `repo` of `name`, sealed to | ||
/// `runner`, or both; with neither, all of them, as `me`: its owner, or an | ||
/// admin of it as an organization. Says how many it deleted. | ||
pub async fn delete( | ||
ctx: &Ctx, | ||
me: &Session, | ||
owner: &str, | ||
repo: &str, | ||
name: Option<&str>, | ||
runner: Option<&str>, | ||
) -> Result<u64> { | ||
let mut body = json!({ "repo": repo }); | ||
let mut body = json!({ "owner": owner, "repo": repo }); | ||
let only = [("name", name), ("runner", runner)]; | ||
for (field, value) in only.into_iter().filter_map(|(f, v)| Some((f, v?))) { | ||
body[field] = value.into(); | ||
| @@ -130,7 +135,8 @@ pub async fn delete( | ||
} | ||
| ||
/// Those left for `owner`'s repository `repo` that `me` may see: every | ||
/// one, without its value, to its owner; those sealed to it, to a runner. | ||
/// one, without its value, to its owner or an admin of it as an | ||
/// organization; those sealed to it, to a runner. | ||
pub async fn list(ctx: &Ctx, me: &Session, owner: &str, repo: &str) -> Result<Vec<Secret>> { | ||
#[derive(Deserialize)] | ||
struct Page { | ||
| @@ -170,15 +176,18 @@ pub async fn runner_key(ctx: &Ctx, runner: &str) -> Result<PublicKey> { | ||
PublicKey::from_did_key(key).with_context(|| format!("{runner}'s key is not a did:key")) | ||
} | ||
| ||
/// The repository `url` names, as its owner: its remote, session, and | ||
/// record. | ||
/// The repository `url` names, as whoever manages its secrets: its owner | ||
/// when logged in as them, else the account logged in, which the appview | ||
/// lets in only as an admin of it as an organization. Its remote, that | ||
/// session, and its record. | ||
async fn owned(ctx: &Ctx, url: &RepoUrl) -> Result<(Remote, Session, RepoRecord)> { | ||
ensure!( | ||
ctx.env.appview.is_some(), | ||
"secrets are left at an appview, and KNOTLESS_APPVIEW is set empty" | ||
); | ||
let remote = Remote::open(ctx, url).await?; | ||
let me = session::ensure(ctx, &remote.identity).await?; | ||
let me = session::reader(ctx, &remote.identity).await; | ||
let me = me.context("not logged in: run `knotless login <handle>`")?; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
| @@ -242,10 +251,10 @@ pub async fn set( | ||
sealed: Some(sealed::seal(&key, &bound, value)?), | ||
updated_at: String::new(), | ||
}; | ||
put(ctx, &me, &repo.key, &secret).await?; | ||
put(ctx, &me, remote.did(), &repo.key, &secret).await?; | ||
} | ||
for other in named.iter().filter(|r| !runners.contains(r)) { | ||
delete(ctx, &me, &repo.key, Some(name), Some(other)).await?; | ||
delete(ctx, &me, remote.did(), &repo.key, Some(name), Some(other)).await?; | ||
} | ||
Ok(runners) | ||
} | ||
| @@ -259,20 +268,20 @@ pub async fn of(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<Secret>> { | ||
/// Deletes the secret `name` of the repository at `url`, for every | ||
/// runner. Says how many runners' copies went. | ||
pub async fn remove(ctx: &Ctx, url: &RepoUrl, name: &str) -> Result<u64> { | ||
let (_, me, repo) = owned(ctx, url).await?; | ||
let gone = delete(ctx, &me, &repo.key, Some(name), None).await?; | ||
let (remote, me, repo) = owned(ctx, url).await?; | ||
let gone = delete(ctx, &me, remote.did(), &repo.key, Some(name), None).await?; | ||
ensure!(gone > 0, "{url} has no secret {name}"); | ||
Ok(gone) | ||
} | ||
| ||
/// What a runner's leaving means for the secrets of the repository with | ||
/// the key `repo`: those sealed to it are deleted. As far as it goes: one | ||
/// What a runner's leaving means for the secrets of `owner`'s repository | ||
/// with the key `repo`: those sealed to it are deleted. As far as it goes: one | ||
/// left behind, when the appview does not answer, reaches no run, as the | ||
/// runner runs a repository only while it names it. | ||
pub async fn forget(ctx: &Ctx, me: &Session, repo: &str, runner: &str) { | ||
pub async fn forget(ctx: &Ctx, me: &Session, owner: &str, repo: &str, runner: &str) { | ||
// With no appview, none were left at one. | ||
if ctx.env.appview.is_some() { | ||
let _ = delete(ctx, me, repo, None, Some(runner)).await; | ||
let _ = delete(ctx, me, owner, repo, None, Some(runner)).await; | ||
} | ||
} | ||
| ||
crates/knotless-serve/src/secrets.rs+32−10
| @@ -1,9 +1,10 @@ | ||
//! Secrets owners leave for their repositories' runners | ||
//! ([`knotless::secrets`]), each sealed to a runner's key, so this server | ||
//! holds only ciphertext. An owner puts, lists and deletes its own; a | ||
//! runner lists those sealed to it. Each signs for itself with a service | ||
//! token, or an owner with the pages' login, which seal each value in the | ||
//! browser; a repository is named by its owner and key, never its name. | ||
//! holds only ciphertext. An owner puts, lists and deletes its own, as an | ||
//! admin who joined an organization does the organization's; a runner | ||
//! lists those sealed to it. Each signs for itself with a service token, | ||
//! or with the pages' login, which seal each value in the browser; a | ||
//! repository is named by its owner and key, never its name. | ||
| ||
use crate::gateway::caller; | ||
use crate::moderation::{bad, index}; | ||
| @@ -11,7 +12,7 @@ use crate::viewer::Viewer; | ||
use crate::{App, AppError}; | ||
use axum::Json; | ||
use axum::extract::{Query, State}; | ||
use axum::http::{HeaderMap, header}; | ||
use axum::http::{HeaderMap, StatusCode, header}; | ||
use knotless::records::RerunVar; | ||
use knotless::secrets::{DELETE, LIST, PUT, REFS_MAX, Secret, VALUE_MAX}; | ||
use serde::Deserialize; | ||
| @@ -32,6 +33,22 @@ async fn asker(app: &App, headers: &HeaderMap, lxm: &str) -> Result<String, AppE | ||
crate::web::logged_in(app, viewer, headers, "manage a repository's secrets") | ||
} | ||
| ||
/// Whose repository `asker` names: `owner`'s, if they may manage its | ||
/// secrets, as its owner or an admin who joined it as an organization; or | ||
/// their own, with none named. | ||
fn managed(app: &App, asker: String, owner: Option<String>) -> Result<String, AppError> { | ||
let Some(owner) = owner.filter(|o| *o != asker) else { | ||
return Ok(asker); | ||
}; | ||
match app.org_admits(&owner, &asker) { | ||
true => Ok(owner), | ||
false => Err(AppError( | ||
StatusCode::FORBIDDEN, | ||
format!("{asker} is not an admin of {owner}"), | ||
)), | ||
} | ||
} | ||
| ||
/// Whether `repo` could be a repository's key: a TID, or a key as long. | ||
fn valid_repo(repo: &str) -> bool { | ||
(1..=32).contains(&repo.len()) && repo.bytes().all(|b| b.is_ascii_alphanumeric()) | ||
| @@ -39,6 +56,7 @@ fn valid_repo(repo: &str) -> bool { | ||
| ||
#[derive(Deserialize)] | ||
pub struct Put { | ||
owner: Option<String>, | ||
repo: String, | ||
runner: String, | ||
name: String, | ||
| @@ -55,7 +73,8 @@ pub async fn put( | ||
Json(body): Json<Put>, | ||
) -> Result<Json<Value>, AppError> { | ||
let index = index(&app)?; | ||
let owner = asker(&app, &headers, PUT).await?; | ||
let asker = asker(&app, &headers, PUT).await?; | ||
let owner = managed(&app, asker, body.owner)?; | ||
if !valid_repo(&body.repo) { | ||
return Err(bad("a repository is named by its key")); | ||
} | ||
| @@ -94,6 +113,7 @@ pub async fn put( | ||
| ||
#[derive(Deserialize)] | ||
pub struct Delete { | ||
owner: Option<String>, | ||
repo: String, | ||
name: Option<String>, | ||
runner: Option<String>, | ||
| @@ -105,7 +125,8 @@ pub async fn delete( | ||
Json(body): Json<Delete>, | ||
) -> Result<Json<Value>, AppError> { | ||
let index = index(&app)?; | ||
let owner = asker(&app, &headers, DELETE).await?; | ||
let asker = asker(&app, &headers, DELETE).await?; | ||
let owner = managed(&app, asker, body.owner)?; | ||
let (name, runner) = (body.name.as_deref(), body.runner.as_deref()); | ||
let deleted = index.delete_secrets(&owner, &body.repo, name, runner); | ||
Ok(Json( | ||
| @@ -130,9 +151,10 @@ pub async fn list( | ||
.secrets(&p.owner, &p.repo) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
// Its owner sees every one, and no value but those sealed to it, as a | ||
// runner that logs in as its owner; a runner, only its own. | ||
if me == p.owner { | ||
// Its owner, or an organization's admin, sees every one, and no value | ||
// but those sealed to it, as a runner that logs in as either; a runner, | ||
// only its own. | ||
if app.org_admits(&p.owner, &me) { | ||
let others = secrets.iter_mut().filter(|s| s.runner != me); | ||
others.for_each(|s| s.sealed = None); | ||
} else { | ||
crates/knotless-serve/tests/gateway/orgs.rs+41−0
| @@ -175,6 +175,47 @@ fn organizations_invite_and_members_join() { | ||
let made = ok(&member, &["create", &byadmin, "--runner", s]); | ||
assert!(made.starts_with(&format!("created {byadmin}")), "{made}"); | ||
assert!(pushes(&member, &byadmin)); | ||
// An admin gives its runners secrets from their own login too, sealed | ||
// as the organization's, which the runner opens; no one else may. | ||
let dir = tempfile::tempdir().unwrap(); | ||
let keys = knotless::sealed::RunnerKeys::open_or_make(&dir.path().join("k.json")).unwrap(); | ||
let published = json!({ | ||
"$type": knotless::sealed::KEY_NSID, | ||
"key": keys.public().to_did_key(), | ||
"createdAt": "2026-11-02T00:00:00.000Z", | ||
}); | ||
stranger.put_record( | ||
knotless::sealed::KEY_NSID, | ||
knotless::sealed::KEY_RKEY, | ||
published, | ||
); | ||
let sd = stranger.account.did.as_str(); | ||
let (code, said) = member.cli(&["secret", &byadmin, "set", "TOKEN"], b"s3cret"); | ||
assert_eq!((code, said), (0, format!("sealed TOKEN to {sd}"))); | ||
let listed = ok(&member, &["secret", &byadmin, "ls"]); | ||
assert!(listed.starts_with(&format!("TOKEN\t{sd}\t*")), "{listed}"); | ||
let od = org.account.did.as_str(); | ||
let key = org.record("dev.knotless.repo", "byadmin")["key"] | ||
.as_str() | ||
.unwrap() | ||
.to_string(); | ||
let r = ctx(&stranger); | ||
let its = gw._rt.block_on(knotless::session::me(&r)).unwrap(); | ||
let left = knotless::secrets::list(&r, &its, od, &key); | ||
let left = gw._rt.block_on(left).unwrap(); | ||
let bound = knotless::sealed::Bound { | ||
owner: od, | ||
repo: &key, | ||
runner: sd, | ||
name: "TOKEN", | ||
refs: &[], | ||
}; | ||
let opened = keys.open(&left[0].key, &bound, left[0].sealed.as_deref().unwrap()); | ||
assert_eq!(opened.unwrap(), b"s3cret"); | ||
let theirs = refused(&stranger, &["secret", &byadmin, "rm", "TOKEN"]); | ||
assert!(theirs.contains("is not an admin of"), "{theirs}"); | ||
ok(&member, &["secret", &byadmin, "rm", "TOKEN"]); | ||
assert_eq!(ok(&member, &["secret", &byadmin, "ls"]), ""); | ||
// The gateway acts only for an organization that delegated to it. | ||
let lxm = "dev.knotless.joinOrg"; | ||
let body = json!({"org": stranger.account.did}) | ||
crates/knotless-serve/tests/forge.rs+13−4
| @@ -2477,6 +2477,7 @@ fn secrets_reach_only_their_runner() { | ||
let me = |c: &knotless::env::Ctx| av.rt.block_on(knotless::session::me(c)).unwrap(); | ||
let (mine, its, theirs) = (me(&o), me(&r), me(&x)); | ||
let (od, rd) = (owner.account.did.clone(), runner.account.did.clone()); | ||
let xd = stranger.account.did.clone(); | ||
let secret = |name: &str, runner: &str, refs: &[&str], sealed: &[u8]| Secret { | ||
name: name.into(), | ||
runner: runner.into(), | ||
| @@ -2487,7 +2488,7 @@ fn secrets_reach_only_their_runner() { | ||
}; | ||
let put = |s: Secret| { | ||
av.rt | ||
.block_on(secrets::put(&o, &mine, "3kabc", &s)) | ||
.block_on(secrets::put(&o, &mine, &od, "3kabc", &s)) | ||
.unwrap() | ||
}; | ||
put(secret("TOKEN", &rd, &[], b"old")); | ||
| @@ -2547,12 +2548,12 @@ fn secrets_reach_only_their_runner() { | ||
("TOKEN", None) | ||
] | ||
); | ||
let self_gone = secrets::delete(&o, &mine, "3kabc", Some("SELF"), None); | ||
let self_gone = secrets::delete(&o, &mine, &od, "3kabc", Some("SELF"), None); | ||
assert_eq!(av.rt.block_on(self_gone).unwrap(), 1); | ||
| ||
// Deleted by name, for every runner; by runner; then the rest. | ||
let delete = |name: Option<&str>, runner: Option<&str>| { | ||
let done = secrets::delete(&o, &mine, "3kabc", name, runner); | ||
let done = secrets::delete(&o, &mine, &od, "3kabc", name, runner); | ||
av.rt.block_on(done).unwrap() | ||
}; | ||
assert_eq!(delete(Some("TOKEN"), Some("did:web:elsewhere.example")), 1); | ||
| @@ -2563,8 +2564,16 @@ fn secrets_reach_only_their_runner() { | ||
assert_eq!(delete(None, None), 1); | ||
assert!(list(&o, &mine, &od).is_empty()); | ||
// A stranger's delete deletes only its own, which it has none of. | ||
let theirs_gone = secrets::delete(&x, &theirs, "3kabc", None, None); | ||
let theirs_gone = secrets::delete(&x, &theirs, &xd, "3kabc", None, None); | ||
assert_eq!(av.rt.block_on(theirs_gone).unwrap(), 0); | ||
// Naming the owner, it is refused: the owner is no organization it | ||
// administers. | ||
let not_theirs = secrets::delete(&x, &theirs, &od, "3kabc", None, None); | ||
let refused = av.rt.block_on(not_theirs).unwrap_err().to_string(); | ||
assert!(refused.contains("is not an admin of"), "{refused}"); | ||
let other = secret("T", &rd, &[], b"s"); | ||
let not_put = secrets::put(&x, &theirs, &od, "3kabc", &other); | ||
assert!(av.rt.block_on(not_put).is_err()); | ||
| ||
// What a put must be, each refused. | ||
let client = reqwest::blocking::Client::new(); | ||
docs/design/runner.md+2−1
| @@ -861,7 +861,8 @@ Secrets come from two places. The file named by `--secrets`, keyed by the | ||
repository's label exactly as on the command line | ||
([format](../reference/workflows.md#secrets-file)), is the operator's. The | ||
owner's are those `knotless secret` sealed to the runner's key and left at | ||
the appview ([The runner's key](#the-runners-key)): for a name the workflow | ||
the appview, by the owner or, for an organization's repository, an admin | ||
who joined it ([The runner's key](#the-runners-key)): for a name the workflow | ||
lists and the file does not grant, the runner asks the appview | ||
(`dev.knotless.listSecrets`, with its own service token) for those sealed | ||
to it for the repository, by owner and key, and opens each with its keys, | ||
docs/design/security.md+5−2
| @@ -338,8 +338,11 @@ not vouch for runs untrusted until it lands. | ||
| ||
An owner can also leave a repository's runners secrets at the appview, | ||
each sealed to one runner's key ([The runner's | ||
key](runner.md#the-runners-key)). The appview holds only ciphertext: | ||
the owner lists names and refs, never a value, and a runner lists only | ||
key](runner.md#the-runners-key)), as can an admin who joined the | ||
organization that owns it, from their own login: the appview checks | ||
their standing as it does for the organization's gateway, and anyone | ||
else naming another owner is refused. The appview holds only ciphertext: | ||
the owner, or that admin, lists names and refs, never a value, and a runner lists only | ||
what was sealed to it. Each value is bound to the owner, the | ||
repository's key, the runner, its name and its refs, so one carried to | ||
another repository does not open. Base mode does not say who sealed a | ||
docs/reference/api.md+6−5
| @@ -346,8 +346,9 @@ without a token for the method. | ||
An owner leaves a repository's runners secrets, each sealed to one | ||
runner's `dev.knotless.runnerKey`, so this server holds only ciphertext | ||
([runner](../design/runner.md#the-runners-key)). A repository is named by | ||
its owner, the caller, and its key, never its name, so a rename keeps | ||
its secrets. Each method takes a service token for itself, from the | ||
its owner and its key, never its name, so a rename keeps its secrets. | ||
The owner is the caller, or, named as `owner`, an organization the caller | ||
is an admin of and has joined; any other `owner` is 403. Each method takes a service token for itself, from the | ||
caller's PDS, or the pages' login, as their writes take it (403 from | ||
another site), else 401, and needs `--index`. The pages seal each value | ||
in the browser, to the keys `listRunners` gives, so this server never | ||
| @@ -355,9 +356,9 @@ sees one. | ||
| ||
| Method | Auth | Parameters | Answer | | ||
| --- | --- | --- | --- | | ||
| `POST dev.knotless.putSecret` | service token, `lxm` this method, or the pages' login | `repo` (a key: 1 to 32 letters and digits), `runner` (a DID), `name` (a shell variable's name, not starting `KNOTLESS_RUN`), `refs` (at most 16 `{pattern}`, each 1 to 256 bytes), `key` (a `did:key`), `sealed` (1 to 16465 bytes); any else 400 | `{}`; one of the same repository, runner and name is replaced | | ||
| `POST dev.knotless.deleteSecret` | service token, `lxm` this method, or the pages' login | `repo`; `name`, `runner`, or both, to delete only those | `deleted`, how many of the caller's went | | ||
| `GET dev.knotless.listSecrets` | service token, `lxm` this method, or the pages' login | `owner`, `repo` | `secrets`, by name then runner, each `name`, `runner`, `refs`, `key`, `updatedAt`: every one to the owner, with `sealed` only on those sealed to it; those sealed to the caller, with `sealed`, to anyone else | | ||
| `POST dev.knotless.putSecret` | service token, `lxm` this method, or the pages' login | `owner` (a DID; absent, the caller), `repo` (a key: 1 to 32 letters and digits), `runner` (a DID), `name` (a shell variable's name, not starting `KNOTLESS_RUN`), `refs` (at most 16 `{pattern}`, each 1 to 256 bytes), `key` (a `did:key`), `sealed` (1 to 16465 bytes); any else 400 | `{}`; one of the same repository, runner and name is replaced | | ||
| `POST dev.knotless.deleteSecret` | service token, `lxm` this method, or the pages' login | `owner` (as `putSecret`), `repo`; `name`, `runner`, or both, to delete only those | `deleted`, how many of the owner's went | | ||
| `GET dev.knotless.listSecrets` | service token, `lxm` this method, or the pages' login | `owner`, `repo` | `secrets`, by name then runner, each `name`, `runner`, `refs`, `key`, `updatedAt`: every one to the owner, or an admin who joined it as an organization, with `sealed` only on those sealed to the caller; those sealed to the caller, with `sealed`, to anyone else | | ||
| ||
A `subject` is a `com.atproto.admin.defs#repoRef` or a | ||
`com.atproto.repo.strongRef`, else 400. `getSubjectStatus` with neither | ||
docs/reference/cli.md+2−2
| @@ -202,8 +202,8 @@ queue](../design/merge-queue.md). | ||
| `agents <url> [add \| remove <handle>]` | the accounts, such as a coding agent, whose pulls the runners check as they move, as code no one vouches for (see [the runner](../design/runner.md#whose-code-runs)); as owner, name one or stop naming it, printing `added` or `removed`. Prints each as its DID | | ||
| `minutes <url>` | the minutes each runner the repository names counts for its owner's public repositories in the last 30 days, a line each: the runner, then `<n> of <allowance> min in 30 days` (adding `past it: runs go one at a time` once past), `<n> min in 30 days, no allowance`, or `nothing counted yet`, and the soonest minutes to free up. Each runner counts on its own (see [the runner](../design/runner.md)) | | ||
| `runners <url> [add \| remove <handle>]` | the accounts whose checks of the repository readers show; `add` makes the account a member of a private repository, and invites it as `create` does (`added`, then `warning: …` if it could not be), which `remove` leaves it (`unshare` takes it back). A runner added after secrets were set has none of them, as nothing keeps their values to seal again: `warning: <did> gets the secrets set before only once each is set again: <names>`. `remove` deletes the secrets sealed to it | | ||
| `secret <url> set <name> [--ref <pattern>]... [--runner <handle>]...` | as owner, give the repository's runners the secret `<name>`, read from stdin less one line ending, sealed to the key each publishes so only it reads it, and left at the appview (`KNOTLESS_APPVIEW`), replacing one of the same name; `--ref`, repeatable, limits it to pushes to those refs, as a trigger's `refs` match; `--runner`, repeatable, by handle or DID, gives it to those of the repository's runners alone, and takes back the copy any other of them had. Prints `sealed <name> to <runners>`. Fails if the repository names no runners, a `--runner` is not one of them, or one it seals to publishes no key (a knotless-run from before). `<name>` is a shell variable's, not starting `KNOTLESS_RUN`; the value at most 16 KiB, the refs at most 16. See [Secrets](../design/runner.md#secrets) | | ||
| `secret <url> ls \| rm <name>` | as owner, each secret given, one line per runner, `<name>\t<runner>\t<refs, or *>\t<updated>`, never a value; or take one back from every runner, printing `removed <name>` | | ||
| `secret <url> set <name> [--ref <pattern>]... [--runner <handle>]...` | as owner, or logged in as an admin who joined the organization that owns it, give the repository's runners the secret `<name>`, read from stdin less one line ending, sealed to the key each publishes so only it reads it, and left at the appview (`KNOTLESS_APPVIEW`), replacing one of the same name; `--ref`, repeatable, limits it to pushes to those refs, as a trigger's `refs` match; `--runner`, repeatable, by handle or DID, gives it to those of the repository's runners alone, and takes back the copy any other of them had. Prints `sealed <name> to <runners>`. Fails if the repository names no runners, a `--runner` is not one of them, or one it seals to publishes no key (a knotless-run from before). `<name>` is a shell variable's, not starting `KNOTLESS_RUN`; the value at most 16 KiB, the refs at most 16. See [Secrets](../design/runner.md#secrets) | | ||
| `secret <url> ls \| rm <name>` | as owner or such an admin, each secret given, one line per runner, `<name>\t<runner>\t<refs, or *>\t<updated>`, never a value; or take one back from every runner, printing `removed <name>` | | ||
| `checks <url> [--ref <name>] [--commit <sha>] [--pull <at-uri>] [--queue <branch>] [--workflow <name>] [--log] [--images <dir>] [--wait <secs>] [--json]` | what those runners report, newest first; `--ref`, of pushes to that ref and runs asked for there, in full or a branch or tag named bare, as `main` (a pull's are read with `--pull`); `--queue`, of the merge queue's batches; `--log` adds the end of each log; `--images` saves each check's pictures; `--wait` waits for runs to finish | | ||
| `rerun <url> --ref <name> \| --pull <at-uri> [--workflow <name>]` | as owner, ask the runners to run it again, or only one of its workflows | | ||
| `rerun <url> --ref <name> --workflow <name> [-e <name>=<value>]...` | as owner, ask for one workflow there, with variables its steps see | | ||