OXYGENHUB

Sealed approvals: the approval attestation and oxy seal --flow b582948f1912

A person's approval (or rejection) of one job of one OxygenFlow run, signed
with their own key: attestation kind approval (run, job, job hash, state,
sorted input blobs, environment, approve), SignedApproval ([attest, sigs,
identity], verifies standalone; covers() matches exactly one statement).
oxy seal --flow https://hub/OWNER/NAME/flow/N --job NAME [--reject] fetches
the hub's statement, checks it is this repository's, signs and posts it.
Spec 14.4. Tests: round trip; another plan, state, job, run, environment or
verdict is not covered; a changed or wrongly signed approval fails.

Refs andymccall/oxygenhub#235
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 09:45 UTC
Signature
✔ signed by Claude Code
Hash
b582948f1912885006d8a3c2471bcd6ae0acc2b2172055b53e005846938dd2d0
Depends on
4213c41dfc64 5b9c6ad45f6e 75167672ebcd d42369abf180 d443639bcf35 d82e44097323 fc1a466a1e66 fce44ae9ad29 ff0af1ee299d ff1f62fee654
Depended on by
6c3dbe2018e0 642da2636de2 f53cbe7a204e
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
15 atoms, 2 tree operations, 6 files

Browse the tree at this change

added crates/oxy-core/src/approval.rs +228 −0
1 +
//! A person's approval of one job of one run, signed (oxygenhub#235): the
2 +
//! approval attestation, the signer's signature over it, and the signer's
3 +
//! identity, so it verifies with nothing else. The hub decides whether the
4 +
//! signer may approve; the signature makes the decision theirs, and binds
5 +
//! it to exactly that job, state, plan and environment.
6 +
7 +
use crate::attest::{Attest, AttestBody};
8 +
use crate::cbor::{self, Value};
9 +
use crate::hash::ObjectRef;
10 +
use crate::identity::{verify, Domain, Identity, SecretKey, Signature};
11 +
use crate::objects::{array, bytes, Hlc, ObjectError};
12 +
13 +
/// What is approved.
14 +
#[derive(Clone, Debug, PartialEq, Eq)]
15 +
pub struct Statement {
16 +
    pub repo: [u8; 16],
17 +
    /// The run, as its URL on the hub.
18 +
    pub run: String,
19 +
    pub job: String,
20 +
    /// The job's hash with its sandbox (OxygenFlow spec 6.2).
21 +
    pub job_hash: ObjectRef,
22 +
    pub state: ObjectRef,
23 +
    /// The blobs the job takes as inputs (a plan), any order.
24 +
    pub inputs: Vec<ObjectRef>,
25 +
    pub environment: String,
26 +
    pub approve: bool,
27 +
}
28 +
29 +
#[derive(Debug, thiserror::Error)]
30 +
pub enum ApprovalError {
31 +
    #[error("the attestation is not an approval")]
32 +
    NotApproval,
33 +
    #[error("no signature by the signer {0}")]
34 +
    Unsigned(String),
35 +
    #[error("the signature does not verify: {0}")]
36 +
    BadSignature(String),
37 +
    #[error("{0}")]
38 +
    Schema(String),
39 +
}
40 +
41 +
impl From<ObjectError> for ApprovalError {
42 +
    fn from(e: ObjectError) -> Self {
43 +
        ApprovalError::Schema(e.to_string())
44 +
    }
45 +
}
46 +
47 +
#[derive(Clone, Debug, PartialEq, Eq)]
48 +
pub struct SignedApproval {
49 +
    pub attest: Attest,
50 +
    pub sigs: Vec<Signature>,
51 +
    pub signer: Identity,
52 +
}
53 +
54 +
impl SignedApproval {
55 +
    pub fn sign(s: &Statement, signer: &Identity, sk: &SecretKey) -> SignedApproval {
56 +
        let mut inputs = s.inputs.clone();
57 +
        inputs.sort();
58 +
        inputs.dedup();
59 +
        let attest = Attest {
60 +
            repo: s.repo,
61 +
            hlc: Hlc::now(),
62 +
            body: AttestBody::Approval {
63 +
                run: s.run.clone(),
64 +
                job: s.job.clone(),
65 +
                job_hash: s.job_hash,
66 +
                state: s.state,
67 +
                inputs,
68 +
                environment: s.environment.clone(),
69 +
                approve: s.approve,
70 +
            },
71 +
        };
72 +
        let sig = sk.sign(Domain::Attest, &signer.hash(), &attest.hash());
73 +
        SignedApproval {
74 +
            attest,
75 +
            sigs: vec![sig],
76 +
            signer: signer.clone(),
77 +
        }
78 +
    }
79 +
80 +
    /// What was approved, read back from the attestation.
81 +
    pub fn statement(&self) -> Result<Statement, ApprovalError> {
82 +
        let AttestBody::Approval {
83 +
            run,
84 +
            job,
85 +
            job_hash,
86 +
            state,
87 +
            inputs,
88 +
            environment,
89 +
            approve,
90 +
        } = &self.attest.body
91 +
        else {
92 +
            return Err(ApprovalError::NotApproval);
93 +
        };
94 +
        Ok(Statement {
95 +
            repo: self.attest.repo,
96 +
            run: run.clone(),
97 +
            job: job.clone(),
98 +
            job_hash: *job_hash,
99 +
            state: *state,
100 +
            inputs: inputs.clone(),
101 +
            environment: environment.clone(),
102 +
            approve: *approve,
103 +
        })
104 +
    }
105 +
106 +
    /// It is an approval and the signer signed it. Whether the signer may
107 +
    /// approve is the reader's call.
108 +
    pub fn verify(&self) -> Result<(), ApprovalError> {
109 +
        self.statement()?;
110 +
        let key = self.signer.hash();
111 +
        let sig = self
112 +
            .sigs
113 +
            .iter()
114 +
            .find(|s| s.key == key)
115 +
            .ok_or_else(|| ApprovalError::Unsigned(key.short(12)))?;
116 +
        verify(&self.signer, sig, Domain::Attest, &self.attest.hash())
117 +
            .map_err(|e| ApprovalError::BadSignature(e.to_string()))
118 +
    }
119 +
120 +
    /// Whether `s` is what this approves (the time it was made aside).
121 +
    pub fn covers(&self, s: &Statement) -> bool {
122 +
        let mut want = s.inputs.clone();
123 +
        want.sort();
124 +
        want.dedup();
125 +
        self.statement().is_ok_and(|mine| {
126 +
            mine == Statement {
127 +
                inputs: want,
128 +
                ..s.clone()
129 +
            }
130 +
        })
131 +
    }
132 +
133 +
    /// `[attest, [Signature], identity]`, each object as its bytes.
134 +
    pub fn encode(&self) -> Vec<u8> {
135 +
        cbor::encode(&Value::Array(vec![
136 +
            Value::Bytes(self.attest.encode()),
137 +
            Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()),
138 +
            Value::Bytes(self.signer.encode()),
139 +
        ]))
140 +
    }
141 +
142 +
    pub fn decode(b: &[u8]) -> Result<SignedApproval, ApprovalError> {
143 +
        let v = cbor::decode(b).map_err(ObjectError::from)?;
144 +
        let a = array(&v, "signed approval")?;
145 +
        if a.len() != 3 {
146 +
            return Err(ApprovalError::Schema(
147 +
                "signed approval: expected [attest, sigs, identity]".into(),
148 +
            ));
149 +
        }
150 +
        Ok(SignedApproval {
151 +
            attest: Attest::decode(bytes(&a[0], "attest")?)?,
152 +
            sigs: array(&a[1], "sigs")?
153 +
                .iter()
154 +
                .map(Signature::from_value)
155 +
                .collect::<Result<_, _>>()?,
156 +
            signer: Identity::decode(bytes(&a[2], "identity")?)?,
157 +
        })
158 +
    }
159 +
}
160 +
161 +
#[cfg(test)]
162 +
mod tests {
163 +
    use super::*;
164 +
    use crate::identity::SecretKey;
165 +
166 +
    fn statement() -> Statement {
167 +
        Statement {
168 +
            repo: [7; 16],
169 +
            run: "https://hub.example/andy/infra/flow/8".into(),
170 +
            job: "apply".into(),
171 +
            job_hash: ObjectRef([1; 32]),
172 +
            state: ObjectRef([2; 32]),
173 +
            inputs: vec![ObjectRef([4; 32]), ObjectRef([3; 32])],
174 +
            environment: "prod".into(),
175 +
            approve: true,
176 +
        }
177 +
    }
178 +
179 +
    #[test]
180 +
    fn an_approval_signs_exactly_one_job_state_plan_and_environment() {
181 +
        let sk = SecretKey::generate();
182 +
        let id = sk.identity("Andy", "[email protected]", 1);
183 +
        let sa = SignedApproval::sign(&statement(), &id, &sk);
184 +
        sa.verify().unwrap();
185 +
        let back = SignedApproval::decode(&sa.encode()).unwrap();
186 +
        assert_eq!(back, sa, "round trip");
187 +
        assert!(back.covers(&statement()), "inputs in any order");
188 +
        for other in [
189 +
            Statement {
190 +
                inputs: vec![ObjectRef([9; 32])],
191 +
                ..statement()
192 +
            },
193 +
            Statement {
194 +
                state: ObjectRef([8; 32]),
195 +
                ..statement()
196 +
            },
197 +
            Statement {
198 +
                job_hash: ObjectRef([8; 32]),
199 +
                ..statement()
200 +
            },
201 +
            Statement {
202 +
                run: "https://hub.example/andy/infra/flow/9".into(),
203 +
                ..statement()
204 +
            },
205 +
            Statement {
206 +
                environment: "dev".into(),
207 +
                ..statement()
208 +
            },
209 +
            Statement {
210 +
                approve: false,
211 +
                ..statement()
212 +
            },
213 +
        ] {
214 +
            assert!(!back.covers(&other), "{other:?}");
215 +
        }
216 +
        // Changed after signing: the signature no longer holds.
217 +
        let mut forged = sa.clone();
218 +
        if let AttestBody::Approval { inputs, .. } = &mut forged.attest.body {
219 +
            inputs[0] = ObjectRef([9; 32]);
220 +
        }
221 +
        assert!(forged.verify().is_err());
222 +
        // Signed by someone else than the identity it carries.
223 +
        let other_sk = SecretKey::generate();
224 +
        let mut stolen = SignedApproval::sign(&statement(), &id, &other_sk);
225 +
        stolen.signer = id.clone();
226 +
        assert!(stolen.verify().is_err());
227 +
    }
228 +
}
added src/cli/approve.rs +151 −0
1 +
//! `oxy seal --flow RUN_URL --job NAME [--reject]` (oxygenhub#235): sign an
2 +
//! approval of one job of one run with your key, so the hub can show, and
3 +
//! anyone can check, who released it. The hub says what the job is (its
4 +
//! hash with its sandbox, the state, the plan it takes, the environment);
5 +
//! the signature covers exactly that and nothing else.
6 +
7 +
use super::{open, ui};
8 +
use anyhow::{anyhow, bail, Context, Result};
9 +
use oxy_core::approval::{SignedApproval, Statement};
10 +
use oxy_core::hash::ObjectRef;
11 +
use serde_json::Value;
12 +
13 +
/// `https://hub/OWNER/NAME/flow/N` to the hub's base, `OWNER/NAME`, N and
14 +
/// the repository's URL.
15 +
fn run_url(url: &str) -> Result<(String, String, i64, String)> {
16 +
    let url = url.trim_end_matches('/');
17 +
    let (repo_url, n) = url
18 +
        .rsplit_once("/flow/")
19 +
        .ok_or_else(|| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?;
20 +
    let number: i64 = n
21 +
        .parse()
22 +
        .map_err(|_| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?;
23 +
    let (rest, name) = repo_url
24 +
        .rsplit_once('/')
25 +
        .ok_or_else(|| anyhow!("{url} is not a run URL"))?;
26 +
    let (base, owner) = rest
27 +
        .rsplit_once('/')
28 +
        .ok_or_else(|| anyhow!("{url} is not a run URL"))?;
29 +
    Ok((
30 +
        base.to_string(),
31 +
        format!("{owner}/{name}"),
32 +
        number,
33 +
        repo_url.to_string(),
34 +
    ))
35 +
}
36 +
37 +
fn to_hex(b: &[u8]) -> String {
38 +
    b.iter().map(|x| format!("{x:02x}")).collect()
39 +
}
40 +
41 +
fn oref(v: &Value, key: &str) -> Result<ObjectRef> {
42 +
    v.get(key)
43 +
        .and_then(Value::as_str)
44 +
        .and_then(ObjectRef::from_hex)
45 +
        .ok_or_else(|| anyhow!("the hub's statement has no valid {key}"))
46 +
}
47 +
48 +
pub fn seal(url: &str, job: &str, reject: bool, as_json: bool) -> Result<i32> {
49 +
    let repo = open()?;
50 +
    let (base, slug, number, repo_url) = run_url(url)?;
51 +
    let options = repo.options_for_url(&repo_url);
52 +
    let api = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/approval");
53 +
    let body = oxy_core::protocol::http_get(&api, &options).map_err(|e| anyhow!("{e}"))?;
54 +
    let v: Value = serde_json::from_slice(&body).context("the hub did not answer with JSON")?;
55 +
    let repo_hex = v.get("repo").and_then(Value::as_str).unwrap_or("");
56 +
    let mine = to_hex(&repo.id()?);
57 +
    if repo_hex != mine {
58 +
        bail!("run #{number} belongs to repository {repo_hex}, not this checkout's ({mine}): run oxy seal --flow from a checkout of {slug}");
59 +
    }
60 +
    let mut repo_id = [0u8; 16];
61 +
    for (i, b) in repo_id.iter_mut().enumerate() {
62 +
        *b = u8::from_str_radix(&repo_hex[i * 2..i * 2 + 2], 16)?;
63 +
    }
64 +
    let inputs = v
65 +
        .get("inputs")
66 +
        .and_then(Value::as_array)
67 +
        .map(|a| {
68 +
            a.iter()
69 +
                .filter_map(|x| x.as_str().and_then(ObjectRef::from_hex))
70 +
                .collect::<Vec<_>>()
71 +
        })
72 +
        .unwrap_or_default();
73 +
    let statement = Statement {
74 +
        repo: repo_id,
75 +
        run: v
76 +
            .get("run")
77 +
            .and_then(Value::as_str)
78 +
            .unwrap_or(url)
79 +
            .to_string(),
80 +
        job: job.to_string(),
81 +
        job_hash: oref(&v, "job_hash")?,
82 +
        state: oref(&v, "state")?,
83 +
        inputs,
84 +
        environment: v
85 +
            .get("environment")
86 +
            .and_then(Value::as_str)
87 +
            .unwrap_or("")
88 +
            .to_string(),
89 +
        approve: !reject,
90 +
    };
91 +
    let (key, sk) = repo
92 +
        .secret_key()?
93 +
        .ok_or_else(|| anyhow!("no signing key: create one with `oxy identity new`"))?;
94 +
    let me = repo.identity(&key)?;
95 +
    let signed = SignedApproval::sign(&statement, &me, &sk);
96 +
    let post = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/seal");
97 +
    let payload = serde_json::json!({ "signed": to_hex(&signed.encode()) }).to_string();
98 +
    let (code, answer) = oxy_core::protocol::http_json("POST", &post, Some(&payload), &options)
99 +
        .map_err(|e| anyhow!("{e}"))?;
100 +
    if !(200..300).contains(&code) {
101 +
        let why = serde_json::from_str::<Value>(&answer)
102 +
            .ok()
103 +
            .and_then(|v| v.get("error").and_then(Value::as_str).map(String::from));
104 +
        bail!(
105 +
            "the hub refused the seal (HTTP {code}): {}",
106 +
            why.unwrap_or(answer)
107 +
        );
108 +
    }
109 +
    if as_json {
110 +
        println!("{answer}");
111 +
    } else {
112 +
        let verb = if reject { "Rejected" } else { "Approved" };
113 +
        println!(
114 +
            "{} {job} of run #{number} with key {}",
115 +
            ui::done(verb),
116 +
            &key.to_hex()[..12]
117 +
        );
118 +
        println!(
119 +
            "  state {} · job {}",
120 +
            &statement.state.to_hex()[..12],
121 +
            &statement.job_hash.to_hex()[..12]
122 +
        );
123 +
        for i in &statement.inputs {
124 +
            println!("  takes {}", &i.to_hex()[..12]);
125 +
        }
126 +
        if !statement.environment.is_empty() {
127 +
            println!("  for environment {}", statement.environment);
128 +
        }
129 +
    }
130 +
    Ok(0)
131 +
}
132 +
133 +
#[cfg(test)]
134 +
mod tests {
135 +
    #[test]
136 +
    fn run_urls_are_read() {
137 +
        let (base, slug, n, repo) =
138 +
            super::run_url("https://oxygenhub.io/andymccall/oxygenhub-gcp/flow/8/").unwrap();
139 +
        assert_eq!(
140 +
            (base.as_str(), slug.as_str(), n, repo.as_str()),
141 +
            (
142 +
                "https://oxygenhub.io",
143 +
                "andymccall/oxygenhub-gcp",
144 +
                8,
145 +
                "https://oxygenhub.io/andymccall/oxygenhub-gcp"
146 +
            )
147 +
        );
148 +
        assert!(super::run_url("https://oxygenhub.io/andymccall/x/proposal/3").is_err());
149 +
        assert!(super::run_url("https://oxygenhub.io/andymccall/x/flow/abc").is_err());
150 +
    }
151 +
}
modified crates/oxy-core/src/attest.rs +58 −0
…
36 unchanged lines
37 37
        state: ObjectRef,
38 38
        run: String,
39 39
    },
40 +
    /// A person approved (or rejected) one job of one run (OxygenFlow
41 +
    /// approval gates; oxygenhub#235): the job as hashed with its sandbox,
42 +
    /// on that state, taking exactly those input blobs (a reviewed plan),
43 +
    /// for that environment. It approves nothing else.
44 +
    Approval {
45 +
        run: String,
46 +
        job: String,
47 +
        job_hash: ObjectRef,
48 +
        state: ObjectRef,
49 +
        inputs: Vec<ObjectRef>,
50 +
        environment: String,
51 +
        approve: bool,
52 +
    },
40 53
}
41 54
42 55
#[derive(Clone, Debug, PartialEq, Eq)]
…
13 unchanged lines
56 69
            AttestBody::Rotate { .. } => "rotate",
57 70
            AttestBody::Revoke { .. } => "revoke",
58 71
            AttestBody::Receipt { .. } => "receipt",
72 +
            AttestBody::Approval { .. } => "approval",
59 73
        }
60 74
    }
61 75
…
46 unchanged lines
108 122
                (0, Value::Bytes(receipt.0.to_vec())),
109 123
                (1, Value::Bytes(state.0.to_vec())),
110 124
                (2, Value::Text(run.clone())),
125 +
            ]),
126 +
            AttestBody::Approval {
127 +
                run,
128 +
                job,
129 +
                job_hash,
130 +
                state,
131 +
                inputs,
132 +
                environment,
133 +
                approve,
134 +
            } => Value::map([
135 +
                (0, Value::Text(run.clone())),
136 +
                (1, Value::Text(job.clone())),
137 +
                (2, Value::Bytes(job_hash.0.to_vec())),
138 +
                (3, Value::Bytes(state.0.to_vec())),
139 +
                (
140 +
                    4,
141 +
                    Value::Array(inputs.iter().map(|i| Value::Bytes(i.0.to_vec())).collect()),
142 +
                ),
143 +
                (5, Value::Text(environment.clone())),
144 +
                (6, Value::Uint(u64::from(*approve))),
111 145
            ]),
112 146
        };
113 147
        Value::map([
…
62 unchanged lines
176 210
                state: oref(field(b, 1, "state")?, "state")?,
177 211
                run: text(field(b, 2, "run")?, "run")?.to_owned(),
178 212
            },
213 +
            "approval" => {
214 +
                let inputs = array(field(b, 4, "inputs")?, "inputs")?
215 +
                    .iter()
216 +
                    .map(|h| oref(h, "input"))
217 +
                    .collect::<Result<Vec<_>, _>>()?;
218 +
                if !inputs.windows(2).all(|w| w[0] < w[1]) {
219 +
                    return Err(ObjectError::Schema(
220 +
                        "approval: inputs must be sorted and unique".into(),
221 +
                    ));
222 +
                }
223 +
                AttestBody::Approval {
224 +
                    run: text(field(b, 0, "run")?, "run")?.to_owned(),
225 +
                    job: text(field(b, 1, "job")?, "job")?.to_owned(),
226 +
                    job_hash: oref(field(b, 2, "job_hash")?, "job_hash")?,
227 +
                    state: oref(field(b, 3, "state")?, "state")?,
228 +
                    inputs,
229 +
                    environment: text(field(b, 5, "environment")?, "environment")?.to_owned(),
230 +
                    approve: match uint(field(b, 6, "approve")?, "approve")? {
231 +
                        0 => false,
232 +
                        1 => true,
233 +
                        _ => return Err(ObjectError::Schema("approval: approve is 0 or 1".into())),
234 +
                    },
235 +
                }
236 +
            }
179 237
            other => {
180 238
                return Err(ObjectError::Schema(format!(
181 239
                    "unknown attestation kind {other:?}"
…
68 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
5 unchanged lines
6 6
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
7 7
8 8
pub mod alloc_stats;
9 +
pub mod approval;
9 10
pub mod attest;
10 11
pub mod attributes;
11 12
pub mod cbor;
…
25 unchanged lines
modified docs/spec.md +5 −1
…
624 unchanged lines
625 625
```
626 626
Attest {
627 627
  version [0]: 1
628 -
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt"
628 +
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt" | "approval"
629 629
  repo    [2]: bytes(16)               // repository ID, chosen at init
630 630
  hlc     [3]: HLC
631 631
  body    [4]: map                     // per kind
…
5 unchanged lines
637 637
revoke: { key: KeyId, reason }
638 638
lock:   { node: NodeId, path, acquire: bool, expires }
639 639
receipt: { receipt: ObjectRef, state: ObjectRef, run: text }   // §14.6
640 +
approval: { run: text, job: text, job_hash: ObjectRef, state: ObjectRef,
641 +
            inputs: [ObjectRef], environment: text, approve: 0 | 1 }  // sorted, unique inputs
640 642
```
641 643
644 +
An **approval** is a person's decision about one job of one OxygenFlow run (an approval gate, OxygenFlow spec §5.3.2): the job as hashed with its sandbox, on that state, taking exactly those input blobs (for a deploy, the reviewed plan), for that environment. It approves nothing else: another run, state, plan or environment needs another approval. It is handed to the hub as `[attest bytes, [Signature], identity bytes]`, signed in domain `attest` by the approver's own key (`oxy seal --flow URL --job NAME`, `--reject` to reject), so it verifies with nothing else; whether the signer may approve is the hub's rule (a key linked to an account with the gate's role; delegated keys only where the repository allows). The hub keeps it in the repository, so it can be verified without the hub.
645 +
642 646
A **seal** is one signature over a batch of change hashes. A composite signature is 3373 bytes, which is larger than a typical change, so signing every change individually roughly doubles the size of history in high-frequency workflows. A seal replaces that with one signature per push.
643 647
644 648
**Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts.
…
401 unchanged lines
modified src/cli/mod.rs +18 −0
1 1
//! Command-line definitions and dispatch.
2 2
3 +
mod approve;
3 4
mod auth;
4 5
mod format;
5 6
mod identity;
…
408 unchanged lines
414 415
        /// Seal every change of a hub proposal and push the seal (your approval)
415 416
        #[arg(long, conflicts_with = "changes")]
416 417
        proposal: Option<String>,
418 +
        /// Approve a job of a hub run that waits for a sealed approval: `--flow https://hub/OWNER/NAME/flow/N --job apply`
419 +
        #[arg(long, conflicts_with_all = ["changes", "proposal"], requires = "job")]
420 +
        flow: Option<String>,
421 +
        /// The job to approve, with --flow
422 +
        #[arg(long, requires = "flow")]
423 +
        job: Option<String>,
424 +
        /// Reject the job instead of approving it, with --flow
425 +
        #[arg(long, requires = "flow")]
426 +
        reject: bool,
417 427
        /// Machine-readable output: one JSON document on stdout
418 428
        #[arg(long)]
419 429
        json: bool,
…
785 unchanged lines
1205 1215
            PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out),
1206 1216
        },
1207 1217
        Cmd::Seal {
1218 +
            flow: Some(url),
1219 +
            job: Some(job),
1220 +
            reject,
1221 +
            json,
1222 +
            ..
1223 +
        } => approve::seal(&url, &job, reject, json),
1224 +
        Cmd::Seal {
1208 1225
            changes,
1209 1226
            proposal: Some(url),
1210 1227
            json,
1228 +
            ..
1211 1229
        } if changes.is_empty() => proposal::seal(&url, json),
1212 1230
        Cmd::Seal { changes, json, .. } => identity::seal(&changes, json),
1213 1231
        Cmd::Proposal { cmd } => match cmd {
…
45 unchanged lines