OXYGENHUB

Create and withdraw tags on the website (closes #295, closes #301) ba0a55bb5b3c

A writer creates a tag on the Tags page, oh tag create or POST …/tags,
signed by the hub as their delegate under the policy; a maintainer
withdraws one there, with oh tag delete, DELETE …/tags/{tag} or oxy push
--delete tags/NAME, with a signed withdrawal, never while a release uses
it. Logged (untag), audited, listed with why; clones drop it on fetch.
Pins oxygen with tag withdrawal and oxygen#135.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7fb5eaa0e2da
When
· 2026-10-24 22:31 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
ba0a55bb5b3cbbf862258bc5c9de6d41e5073b1fd1f730a7bbe7f87a25fcb327
Depends on
166f6e9bf4f1 16ee5d19d220 19bb31ff1f5c 2291f8cc9c96 380996db84e5 3f4b0ea6eabf 5c6e92fd8d71 5ea4872f23e8 745d225de344 8dcaeeb848c6 93be9ce4653e 965b950f9b96 9ff27da517fe a46d15e72101 a66c05a6a881 a7e6e8e6094c b5668108487f b64e2537d702 d23464c0ac60 e5b97ac399e7 f677d6270298
Depended on by
fac6853c4e46 351cf57e5608 668b2ad7495c 69e4f27c5633 456f4197ade3 794ff94f194a
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
36 atoms, 1 tree operation, 16 files

Browse the tree at this change

added crates/hub/src/tags.rs +200 −0
1 +
//! Making and withdrawing tags for a signed-in user (#295, #301), from the
2 +
//! Tags page and the API: signed by the hub key as the user's delegate (in a
3 +
//! repository without a trust policy, by the hub's own key when the user has
4 +
//! not delegated), received as a pushed tag or withdrawal is, so the same
5 +
//! checks apply, then recorded, audited and logged as a push would be.
6 +
7 +
use crate::db::{self, accounts::Account};
8 +
use crate::repos::edits::Signer;
9 +
use crate::repos::RepoName;
10 +
use crate::Shared;
11 +
use oxy_core::hash::ChangeHash;
12 +
13 +
/// A tag made or withdrawn.
14 +
pub struct Done {
15 +
    pub name: String,
16 +
    /// The state it names (hex).
17 +
    pub state: String,
18 +
    pub heads: Vec<ChangeHash>,
19 +
}
20 +
21 +
/// Who may sign for the user: the hub key as their delegate, else (only
22 +
/// where no trust policy asks for the user) the hub's own key.
23 +
struct Signers {
24 +
    delegate: Option<Signer>,
25 +
    hub: Option<Signer>,
26 +
    /// Why there is no delegate.
27 +
    why: String,
28 +
}
29 +
30 +
impl Signers {
31 +
    async fn of(app: &Shared, me: &Account) -> Signers {
32 +
        match crate::web::edit::signer_for(app, me.id).await {
33 +
            Ok(s) => Signers { delegate: Some(s), hub: None, why: String::new() },
34 +
            Err(why) => Signers { delegate: None, hub: crate::web::edit::hub_signer(app).ok(), why },
35 +
        }
36 +
    }
37 +
38 +
    fn pick(self, policy: bool) -> Result<Signer, String> {
39 +
        match (self.delegate, self.hub) {
40 +
            (Some(s), _) => Ok(s),
41 +
            (None, Some(h)) if !policy => Ok(h),
42 +
            _ => Err(self.why),
43 +
        }
44 +
    }
45 +
}
46 +
47 +
/// Create tag `name` for the state of `target` (a bookmark, tag or change;
48 +
/// the default bookmark when empty).
49 +
pub async fn create(
50 +
    app: &Shared,
51 +
    row: &db::repos::RepoRow,
52 +
    repo: &RepoName,
53 +
    me: &Account,
54 +
    name: &str,
55 +
    target: &str,
56 +
    message: &str,
57 +
) -> Result<Done, String> {
58 +
    let name = name.trim().to_string();
59 +
    if name.is_empty() {
60 +
        return Err("A tag needs a name.".into());
61 +
    }
62 +
    let target = match target.trim() {
63 +
        "" => row.default_bookmark.clone(),
64 +
        t => t.to_string(),
65 +
    };
66 +
    let signers = Signers::of(app, me).await;
67 +
    let (n, msg, rid) = (name.clone(), message.trim().to_string(), row.id);
68 +
    let out = app
69 +
        .repos
70 +
        .write(repo, move |r, _| {
71 +
            if r.tag_heads(&n)?.is_some() {
72 +
                return Ok(Err(format!("There is already a tag {n}, and tags are immutable: withdraw it and tag under a new name.")));
73 +
            }
74 +
            let Ok(heads) = r.resolve_heads(&target) else {
75 +
                return Ok(Err(format!("There is no bookmark, tag or change {target}.")));
76 +
            };
77 +
            let signer = match signers.pick(r.current_policy()?.is_some()) {
78 +
                Ok(s) => s,
79 +
                Err(why) => return Ok(Err(why)),
80 +
            };
81 +
            let report = r.host_tag_create(
82 +
                &n,
83 +
                &heads,
84 +
                &msg,
85 +
                (&signer.hub_key, &signer.hub_secret),
86 +
                &[signer.hub_identity.clone(), signer.author.clone()],
87 +
            )?;
88 +
            let reference = format!("tags/{n}");
89 +
            match report.results.iter().find(|x| x.name == reference) {
90 +
                Some(x) if x.ok => {}
91 +
                Some(x) => return Ok(Err(x.reason.clone())),
92 +
                None => return Ok(Err("the repository took nothing".into())),
93 +
            }
94 +
            let state = r.view()?.tags.get(&n).map(|s| s.to_hex()).unwrap_or_default();
95 +
            let heads = r.tag_heads(&n)?.unwrap_or_default();
96 +
            Ok(Ok((state, heads, crate::tlog::push_entries(r, Some(rid), &report))))
97 +
        })
98 +
        .await
99 +
        .map_err(|e| e.to_string())?;
100 +
    let (state, heads, entries) = out?;
101 +
    crate::tlog::log(app, entries).await;
102 +
    let reference = format!("tags/{name}");
103 +
    let outcome = db::repos::PushOutcome {
104 +
        reference: reference.clone(),
105 +
        ok: true,
106 +
        reason: "tagged on the hub".into(),
107 +
        old_state: String::new(),
108 +
        new_state: state.clone(),
109 +
        heads: heads.len() as i64,
110 +
        changes: 0,
111 +
    };
112 +
    db::repos::record_push(&app.db, row.id, Some((me.id, &me.username)), &[outcome]).await.ok();
113 +
    db::accounts::audit(&app.db, Some(me), "tag.created", &format!("{}/{}", row.owner, row.name), &name).await.ok();
114 +
    crate::hooks::emit(
115 +
        app,
116 +
        row,
117 +
        "push",
118 +
        &me.username,
119 +
        serde_json::json!({ "updates": [{ "reference": reference, "old_state": "", "new_state": state, "heads": heads.len(), "changes": 0 }], "via": "web" }),
120 +
    )
121 +
    .await;
122 +
    // A tag runs the jobs whose `on` names it, as a pushed one does.
123 +
    crate::flow::on_push(app, row, &reference, &state, &heads, &me.username).await;
124 +
    Ok(Done { name, state, heads })
125 +
}
126 +
127 +
/// Withdraw tag `tag`, saying why. Refused while a release uses it.
128 +
pub async fn withdraw(
129 +
    app: &Shared,
130 +
    row: &db::repos::RepoRow,
131 +
    repo: &RepoName,
132 +
    me: &Account,
133 +
    tag: &str,
134 +
    reason: &str,
135 +
) -> Result<Done, String> {
136 +
    let tag = tag.trim().to_string();
137 +
    if let Ok(Some(rel)) = db::releases::get(&app.db, row.id, &tag).await {
138 +
        return Err(format!("The release {:?} uses {tag}: delete the release first.", rel.title));
139 +
    }
140 +
    let signers = Signers::of(app, me).await;
141 +
    let (t, why, rid) = (tag.clone(), reason.trim().to_string(), row.id);
142 +
    let out = app
143 +
        .repos
144 +
        .write(repo, move |r, _| {
145 +
            let Some(state) = r.view()?.tags.get(&t).copied() else {
146 +
                return Ok(Err(format!("There is no tag {t}.")));
147 +
            };
148 +
            let heads = r.store().get_state(&state).map(|s| s.heads).unwrap_or_default();
149 +
            let signer = match signers.pick(r.current_policy()?.is_some()) {
150 +
                Ok(s) => s,
151 +
                Err(why) => return Ok(Err(why)),
152 +
            };
153 +
            let report = r.host_tag_withdrawal(
154 +
                &t,
155 +
                &why,
156 +
                (&signer.hub_key, &signer.hub_secret),
157 +
                &[signer.hub_identity.clone(), signer.author.clone()],
158 +
            )?;
159 +
            let reference = format!("tags/{t}");
160 +
            match report.results.iter().find(|x| x.name == reference) {
161 +
                Some(x) if x.ok => {}
162 +
                Some(x) => return Ok(Err(x.reason.clone())),
163 +
                None => return Ok(Err("the repository took nothing".into())),
164 +
            }
165 +
            Ok(Ok((state.to_hex(), heads, crate::tlog::push_entries(r, Some(rid), &report))))
166 +
        })
167 +
        .await
168 +
        .map_err(|e| e.to_string())?;
169 +
    let (state, heads, entries) = out?;
170 +
    crate::tlog::log(app, entries).await;
171 +
    let reference = format!("tags/{tag}");
172 +
    let outcome = db::repos::PushOutcome {
173 +
        reference: reference.clone(),
174 +
        ok: true,
175 +
        reason: if reason.trim().is_empty() { "withdrawn on the hub".into() } else { format!("withdrawn on the hub: {}", reason.trim()) },
176 +
        old_state: state.clone(),
177 +
        new_state: oxy_core::hash::ObjectRef::ZERO.to_hex(),
178 +
        heads: 0,
179 +
        changes: 0,
180 +
    };
181 +
    db::repos::record_push(&app.db, row.id, Some((me.id, &me.username)), &[outcome]).await.ok();
182 +
    db::accounts::audit(
183 +
        &app.db,
184 +
        Some(me),
185 +
        "tag.withdrawn",
186 +
        &format!("{}/{}", row.owner, row.name),
187 +
        format!("{tag} {}", reason.trim()).trim_end(),
188 +
    )
189 +
    .await
190 +
    .ok();
191 +
    crate::hooks::emit(
192 +
        app,
193 +
        row,
194 +
        "push",
195 +
        &me.username,
196 +
        serde_json::json!({ "updates": [{ "reference": reference, "old_state": state, "new_state": oxy_core::hash::ObjectRef::ZERO.to_hex(), "heads": 0, "changes": 0, "deleted": true }], "via": "web" }),
197 +
    )
198 +
    .await;
199 +
    Ok(Done { name: tag, state, heads })
200 +
}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Create and withdraw tags on the website (#295, #301):** a writer creates a tag on the Tags page (or `oh tag create`, `POST …/tags`), signed by the hub as their delegate, under the policy, and its tag jobs run; a maintainer withdraws one there (or `oh tag delete`, `DELETE …/tags/{tag}`, or `oxy push --delete tags/NAME`) with a signed withdrawal, never while a release uses it. The withdrawal is logged, audited and listed on the page with why, and clones drop the tag on their next fetch. Tags never move, and a withdrawn name is not used again. Pins oxygen with tag withdrawal and with keyless grants that survive their owner's key rotation (oxygen#135).
7 8
- **An agent's changes verify in a new repository (oxygen#134):** a keyless agent's change in a repository its owner had never pushed to read INVALID ("grant is by an unknown author"). The hub now sends the grant's signer with each certificate, and with oxy's fix the agent keeps it and pushes it beside the certificate, so the repository can check the grant.
8 9
- **A mistyped address shows the not-found page (#294):** an address the hub could not read, such as a project or issue number that is not a number, answered with a raw parse error ("Invalid URL: Cannot parse value …"); it now shows the site's not-found page, signed-in header and all.
9 10
- **Embed a drop (#280):** a drop's page offers an `<iframe>` snippet; `/{owner}/drops/{id}/embed` shows its files with a link back, framed by any site, with no scripts and no forms.
…
286 unchanged lines
modified crates/hub-api/src/lib.rs +11 −0
…
1044 unchanged lines
1045 1045
    pub content: Option<String>,
1046 1046
}
1047 1047
1048 +
/// `POST …/tags` (#295): tag the state of `target` (a bookmark, tag or
1049 +
/// change; the default bookmark when absent).
1048 1050
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1051 +
pub struct NewTag {
1052 +
    pub name: String,
1053 +
    #[serde(default)]
1054 +
    pub target: Option<String>,
1055 +
    #[serde(default)]
1056 +
    pub message: Option<String>,
1057 +
}
1058 +
1059 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1049 1060
pub struct NewRelease {
1050 1061
    pub tag: String,
1051 1062
    pub title: String,
…
349 unchanged lines
modified crates/hub/src/api/mod.rs +57 −1
…
103 unchanged lines
104 104
        .route("/repos", get(list_repos).post(create_repo))
105 105
        .route("/repos/{owner}/{name}", get(get_repo))
106 106
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
107 -
        .route("/repos/{owner}/{name}/tags", get(tags))
107 +
        .route("/repos/{owner}/{name}/tags", get(tags).post(create_tag))
108 +
        .route("/repos/{owner}/{name}/tags/{tag}", axum::routing::delete(withdraw_tag))
108 109
        .route("/repos/{owner}/{name}/consumers", get(consumers))
109 110
        .route("/repos/{owner}/{name}/diffusion", get(diffusion).put(set_diffusion))
110 111
        .route("/repos/{owner}/{name}/backup", axum::routing::put(set_backup))
…
630 unchanged lines
741 742
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
742 743
}
743 744
745 +
/// `POST …/tags` (#295): what the Tags page's Create does.
746 +
async fn create_tag(
747 +
    State(app): State<Shared>,
748 +
    headers: HeaderMap,
749 +
    Path((owner, name)): Path<(String, String)>,
750 +
    Json(body): Json<NewTag>,
751 +
) -> Result<(StatusCode, Json<Tag>), ApiError> {
752 +
    let c = caller(&app, &headers).await?;
753 +
    let me = c.require("repo:write")?.clone();
754 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
755 +
    let made = crate::tags::create(
756 +
        &app,
757 +
        &row,
758 +
        &repo,
759 +
        &me,
760 +
        &body.name,
761 +
        body.target.as_deref().unwrap_or(""),
762 +
        body.message.as_deref().unwrap_or(""),
763 +
    )
764 +
    .await
765 +
    .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
766 +
    let t = app
767 +
        .repos
768 +
        .read(&repo, |r, _| view::tags(r))
769 +
        .await?
770 +
        .into_iter()
771 +
        .find(|t| t.name == made.name)
772 +
        .ok_or_else(|| not_found("no such tag"))?;
773 +
    Ok((StatusCode::CREATED, Json(Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer })))
774 +
}
775 +
776 +
#[derive(Deserialize)]
777 +
struct WithdrawQuery {
778 +
    #[serde(default)]
779 +
    reason: String,
780 +
}
781 +
782 +
/// `DELETE …/tags/{tag}?reason=…` (#301): what the Tags page's Withdraw
783 +
/// does. Maintainers; refused (409) while a release uses the tag.
784 +
async fn withdraw_tag(
785 +
    State(app): State<Shared>,
786 +
    headers: HeaderMap,
787 +
    Path((owner, name, tag)): Path<(String, String, String)>,
788 +
    Query(q): Query<WithdrawQuery>,
789 +
) -> Result<StatusCode, ApiError> {
790 +
    let c = caller(&app, &headers).await?;
791 +
    let me = c.require("repo:write")?.clone();
792 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?;
793 +
    if db::releases::get(&app.db, row.id, &tag).await?.is_some() {
794 +
        return Err(ApiError(StatusCode::CONFLICT, format!("a release uses {tag}: delete the release first")));
795 +
    }
796 +
    crate::tags::withdraw(&app, &row, &repo, &me, &tag, &q.reason).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
797 +
    Ok(StatusCode::NO_CONTENT)
798 +
}
799 +
744 800
#[derive(Deserialize)]
745 801
struct HistoryQuery {
746 802
    path: Option<String>,
…
2059 unchanged lines
modified crates/hub/src/lib.rs +1 −0
…
50 unchanged lines
51 51
pub mod social;
52 52
pub mod storage;
53 53
pub mod swarm;
54 +
pub mod tags;
54 55
pub mod terraform;
55 56
pub mod throttle;
56 57
pub mod tlog;
…
369 unchanged lines
modified crates/hub/src/protocol.rs +12 −0
…
403 unchanged lines
404 404
        Err(Failure(StatusCode::UNPROCESSABLE_ENTITY, text))
405 405
    }
406 406
407 +
    /// A tag a release names is not withdrawn (#301): the release would
408 +
    /// point at nothing. Delete the release first.
409 +
    fn vet_tag_withdrawal(&self, name: &str, tag: &str) -> Result<(), String> {
410 +
        let Some(parsed) = RepoName::parse(name) else { return Ok(()) };
411 +
        let Ok(row) = self.row(&parsed) else { return Ok(()) };
412 +
        match self.block(db::releases::get(&self.pool, row.id, tag)) {
413 +
            Ok(Some(rel)) => Err(format!("the release {:?} uses tags/{tag}: delete the release first", rel.title)),
414 +
            Ok(None) => Ok(()),
415 +
            Err(e) => Err(format!("could not check the releases of {name}: {e}")),
416 +
        }
417 +
    }
418 +
407 419
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
408 420
        // The keys that signed what it brought were used (#286): linked
409 421
        // ones show it under Settings › Keys.
…
243 unchanged lines
modified crates/hub/src/repos/view.rs +32 −0
…
891 unchanged lines
892 892
    pub signer: Option<String>,
893 893
}
894 894
895 +
/// A tag withdrawn here (#301): the state it named, why, by whom and when.
895 896
#[derive(Clone, Debug)]
897 +
pub struct WithdrawnView {
898 +
    pub name: String,
899 +
    pub heads: Vec<String>,
900 +
    pub reason: String,
901 +
    pub signer: Option<String>,
902 +
    pub when_ms: i64,
903 +
}
904 +
905 +
#[derive(Clone, Debug)]
896 906
pub struct Overview {
897 907
    pub tree: Tree,
898 908
    pub changes: usize,
…
242 unchanged lines
1141 1151
        .map(|t| TagView { name: t.name, heads: t.heads.iter().map(|h| h.to_hex()).collect(), message: t.message, signer: t.signer })
1142 1152
        .collect();
1143 1153
    out.sort_by(|a, b| b.name.cmp(&a.name));
1154 +
    Ok(out)
1155 +
}
1156 +
1157 +
/// The tags withdrawn here, newest first.
1158 +
pub fn withdrawn_tags(repo: &Repo) -> Result<Vec<WithdrawnView>> {
1159 +
    let mut out: Vec<WithdrawnView> = repo
1160 +
        .remote_refs()?
1161 +
        .withdrawn
1162 +
        .into_iter()
1163 +
        .filter_map(|t| {
1164 +
            let sa = t.attestation?;
1165 +
            let oxy_core::attest::AttestBody::Untag { reason, .. } = &sa.attest.body else { return None };
1166 +
            Some(WithdrawnView {
1167 +
                name: t.name,
1168 +
                heads: t.heads.iter().map(|h| h.to_hex()).collect(),
1169 +
                reason: reason.clone(),
1170 +
                signer: sa.sigs.first().and_then(|s| repo.store().get_identity(&s.key).ok()).map(|i| i.name),
1171 +
                when_ms: sa.attest.hlc.wall_ms as i64,
1172 +
            })
1173 +
        })
1174 +
        .collect();
1175 +
    out.sort_by_key(|w| std::cmp::Reverse(w.when_ms));
1144 1176
    Ok(out)
1145 1177
}
1146 1178
…
374 unchanged lines
modified crates/hub/src/tlog.rs +14 −1
…
496 unchanged lines
497 497
        out.push(Pending { kind, repo: rid, repo_row: row, object, signers });
498 498
    };
499 499
    for u in &report.updated {
500 -
        if u.new_state.is_zero() || !report.results.iter().any(|r| r.ok && r.name == u.name) {
500 +
        if !report.results.iter().any(|r| r.ok && r.name == u.name) {
501 501
            continue;
502 502
        }
503 +
        // A tag's withdrawal (#301), and when the hub accepted it.
504 +
        if let (Some(tag), true) = (u.name.strip_prefix("tags/"), u.new_state.is_zero()) {
505 +
            if let Ok(Some((r, sa))) = repo.tag_withdrawal(tag) {
506 +
                add(&mut out, "untag", r, sig_keys(&sa.sigs));
507 +
                if let Ok(Some(acc)) = repo.acceptance_of(&r) {
508 +
                    add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
509 +
                }
510 +
            }
511 +
            continue;
512 +
        }
513 +
        if u.new_state.is_zero() {
514 +
            continue;
515 +
        }
503 516
        if let Some(tag) = u.name.strip_prefix("tags/") {
504 517
            if let Ok(Some((r, sa))) = repo.tag_attest(tag) {
505 518
                add(&mut out, "tag", r, sig_keys(&sa.sigs));
…
411 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
234 unchanged lines
235 235
        .route("/{owner}/{name}/bookmarks", get(refs::bookmarks))
236 236
        .route("/{owner}/{name}/bookmarks/delete", post(refs::delete_bookmark))
237 237
        .route("/{owner}/{name}/tags", get(refs::tags))
238 +
        .route("/{owner}/{name}/tags/create", post(refs::create_tag))
239 +
        .route("/{owner}/{name}/tags/withdraw", post(refs::withdraw_tag))
238 240
        .route("/{owner}/{name}/artifacts", get(artifacts::artifacts))
239 241
        .route("/{owner}/{name}/images", get(artifacts::images))
240 242
        .route("/{owner}/{name}/terraform", get(artifacts::terraform))
…
708 unchanged lines
modified crates/hub/src/web/refs.rs +111 −2
…
146 unchanged lines
147 147
    shell: Shell,
148 148
    repo: RepoShell,
149 149
    tags: Vec<view::TagView>,
150 +
    withdrawn: Vec<view::WithdrawnView>,
151 +
    /// Tags a release uses (not withdrawn while it does).
152 +
    released: Vec<String>,
153 +
    can_create: bool,
154 +
    can_withdraw: bool,
155 +
    notice: Option<String>,
156 +
    error: Option<String>,
150 157
}
151 158
159 +
impl TagsPage {
160 +
    fn released(&self, tag: &str) -> bool {
161 +
        self.released.iter().any(|t| t == tag)
162 +
    }
163 +
}
164 +
165 +
async fn tags_page(
166 +
    app: &Shared,
167 +
    shell: Shell,
168 +
    repo: crate::repos::RepoName,
169 +
    row: db::repos::RepoRow,
170 +
    role: Role,
171 +
    notice: Option<String>,
172 +
    error: Option<String>,
173 +
) -> Result<Page<TagsPage>, Error> {
174 +
    let (tags, withdrawn) = app
175 +
        .repos
176 +
        .read(&repo, |r, _| Ok((view::tags(r)?, view::withdrawn_tags(r)?)))
177 +
        .await
178 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
179 +
    let released = db::releases::list(&app.db, row.id).await.unwrap_or_default().into_iter().map(|r| r.tag).collect();
180 +
    let signed_in = shell.user.is_some();
181 +
    Ok(Page(TagsPage {
182 +
        shell,
183 +
        repo: RepoShell::new(app, &row, "tags").with_role(role),
184 +
        tags,
185 +
        withdrawn,
186 +
        released,
187 +
        can_create: signed_in && role >= Role::Write,
188 +
        can_withdraw: signed_in && role >= Role::Maintain,
189 +
        notice,
190 +
        error,
191 +
    }))
192 +
}
193 +
152 194
pub async fn tags(State(app): State<Shared>, viewer: Viewer, Path((owner, name)): Path<(String, String)>) -> Result<Page<TagsPage>, Error> {
153 195
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
154 196
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
155 -
    let tags = app.repos.read(&repo, |r, _| view::tags(r)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
156 -
    Ok(Page(TagsPage { shell, repo: RepoShell::new(&app, &row, "tags").with_role(role), tags }))
197 +
    tags_page(&app, shell, repo, row, role, None, None).await
198 +
}
199 +
200 +
#[derive(Deserialize)]
201 +
pub struct CreateTagForm {
202 +
    #[serde(rename = "_csrf")]
203 +
    csrf_token: String,
204 +
    name: String,
205 +
    #[serde(default)]
206 +
    target: String,
207 +
    #[serde(default)]
208 +
    message: String,
209 +
}
210 +
211 +
/// Create a tag from the web (#295): signed by the hub key as the user's
212 +
/// delegate, under the policy, as `oxy tag create` and a push would be.
213 +
pub async fn create_tag(
214 +
    State(app): State<Shared>,
215 +
    viewer: Viewer,
216 +
    Path((owner, name)): Path<(String, String)>,
217 +
    Form(f): Form<CreateTagForm>,
218 +
) -> Result<Page<TagsPage>, Error> {
219 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
220 +
    let me = require(&viewer, &shell)?;
221 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
222 +
    csrf(&viewer, &f.csrf_token, &shell)?;
223 +
    match crate::tags::create(&app, &row, &repo, &me.account, &f.name, &f.target, &f.message).await {
224 +
        Ok(t) => {
225 +
            let notice = format!(
226 +
                "Tagged {} ({} head{}). Start a release from it under Releases.",
227 +
                t.name,
228 +
                t.heads.len(),
229 +
                if t.heads.len() == 1 { "" } else { "s" }
230 +
            );
231 +
            tags_page(&app, shell, repo, row, role, Some(notice), None).await
232 +
        }
233 +
        Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not create the tag: {e}"))).await,
234 +
    }
235 +
}
236 +
237 +
#[derive(Deserialize)]
238 +
pub struct WithdrawTagForm {
239 +
    #[serde(rename = "_csrf")]
240 +
    csrf_token: String,
241 +
    tag: String,
242 +
    #[serde(default)]
243 +
    reason: String,
244 +
}
245 +
246 +
/// Withdraw a tag from the web (#301), as `oxy push --delete tags/NAME`
247 +
/// does: a signed withdrawal, kept and logged. Maintainers only; never
248 +
/// while a release uses the tag.
249 +
pub async fn withdraw_tag(
250 +
    State(app): State<Shared>,
251 +
    viewer: Viewer,
252 +
    Path((owner, name)): Path<(String, String)>,
253 +
    Form(f): Form<WithdrawTagForm>,
254 +
) -> Result<Page<TagsPage>, Error> {
255 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tags"), &viewer);
256 +
    let me = require(&viewer, &shell)?;
257 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
258 +
    csrf(&viewer, &f.csrf_token, &shell)?;
259 +
    match crate::tags::withdraw(&app, &row, &repo, &me.account, &f.tag, &f.reason).await {
260 +
        Ok(t) => {
261 +
            let notice = format!("Withdrew {}. Clones drop it on their next fetch; the name is not used again.", t.name);
262 +
            tags_page(&app, shell, repo, row, role, Some(notice), None).await
263 +
        }
264 +
        Err(e) => tags_page(&app, shell, repo, row, role, None, Some(format!("Could not withdraw {}: {e}", f.tag.trim()))).await,
265 +
    }
157 266
}
158 267
159 268
#[derive(Template)]
…
56 unchanged lines
modified crates/hub/tests/releases.rs +110 −0
…
371 unchanged lines
372 372
    .await;
373 373
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
374 374
}
375 +
376 +
/// #295, #301: a tag is made on the hub (signed by its key as the user's
377 +
/// delegate, or its own key where no policy asks for the user), and
378 +
/// withdrawn there or by a push, never while a release uses it; a clone
379 +
/// drops it on its next fetch, and the withdrawal is audited and logged.
380 +
#[tokio::test(flavor = "multi_thread")]
381 +
async fn tags_are_made_and_withdrawn_on_the_hub() {
382 +
    hermetic();
383 +
    let dir = tempfile::tempdir().unwrap();
384 +
    let mut config = Config::load(dir.path()).unwrap();
385 +
    config.registration.setup_code = "test-setup".into();
386 +
    config.auth.push_tokens = vec!["site".into()];
387 +
    config.registration.mode = "open".into();
388 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
389 +
    let port = listener.local_addr().unwrap().port();
390 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
391 +
    let app = oxygenhub::build(config).await.unwrap();
392 +
    let router = oxygenhub::router(app.clone());
393 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
394 +
    let base = format!("http://127.0.0.1:{port}");
395 +
    let app = &app;
396 +
    let andy = send(
397 +
        app,
398 +
        "POST",
399 +
        "/register",
400 +
        None,
401 +
        None,
402 +
        Some("application/x-www-form-urlencoded"),
403 +
        b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(),
404 +
    )
405 +
    .await
406 +
    .cookie
407 +
    .unwrap();
408 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
409 +
    let repo = RepoName::parse("andy/demo").unwrap();
410 +
    app.repos.create(&repo, None).unwrap();
411 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
412 +
    let work = tempfile::tempdir().unwrap();
413 +
    let mut client = Repo::init(work.path()).unwrap();
414 +
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
415 +
    client.config.set("core.sign", "change").unwrap();
416 +
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
417 +
    client.config.set("remote.origin.token", "site").unwrap();
418 +
    client.save_config().unwrap();
419 +
    write(work.path(), "a.txt", b"one\n");
420 +
    client.snapshot("t").unwrap();
421 +
    let c1 = client.commit("First", &[]).unwrap();
422 +
    client.tag_create("v0.1", &[c1], "").unwrap();
423 +
    client.tag_create("v0.2", &[c1], "").unwrap();
424 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
425 +
    let host = || app.repos.open(&repo).unwrap();
426 +
    let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text();
427 +
    assert!(page.contains("Create a tag") && page.contains("WITHDRAW"), "{page}");
428 +
    let csrf = csrf_of(&page);
429 +
430 +
    // Not delegated, no policy: the hub's own key signs it.
431 +
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.3&target=main&message=Third")).await;
432 +
    assert!(r.text().contains("Tagged v0.3"), "{}", r.text());
433 +
    assert_eq!(host().tag_heads("v0.3").unwrap(), Some(vec![c1]));
434 +
    // Delegated: as Andy's delegate.
435 +
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
436 +
    let key = client.config.author_key.unwrap();
437 +
    let id = client.identity(&key).unwrap();
438 +
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
439 +
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4&message=Fourth")).await;
440 +
    assert!(r.text().contains("Tagged v0.4"), "{}", r.text());
441 +
    let t = host().tags().unwrap().into_iter().find(|t| t.name == "v0.4").unwrap();
442 +
    assert!(t.signer.is_some(), "signed by the hub key for Andy");
443 +
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4")).await;
444 +
    assert!(r.text().contains("immutable"), "a tag never moves: {}", r.text());
445 +
446 +
    // The clone takes the hub's tags.
447 +
    assert_eq!(client.fetch("origin").unwrap().new_tags, 2);
448 +
    assert!(client.tag_heads("v0.3").unwrap().is_some());
449 +
450 +
    // A release on v0.1: neither the page nor a push withdraws it.
451 +
    db::releases::create(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, "v0.1", "Zero one", "", false, None)
452 +
        .await
453 +
        .unwrap();
454 +
    let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.1")).await;
455 +
    assert!(r.text().contains("delete the release first"), "{}", r.text());
456 +
    let r = client.push_delete("origin", "tags/v0.1").unwrap();
457 +
    assert!(!r[0].ok && r[0].reason.contains("delete the release first"), "{r:?}");
458 +
    assert!(host().tag_heads("v0.1").unwrap().is_some());
459 +
460 +
    // On the page: gone, listed as withdrawn with why, and audited.
461 +
    let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.3&reason=tagged+by+mistake")).await;
462 +
    assert!(r.text().contains("Withdrew v0.3"), "{}", r.text());
463 +
    assert_eq!(host().tag_heads("v0.3").unwrap(), None);
464 +
    let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text();
465 +
    assert!(page.contains("Withdrawn") && page.contains("tagged by mistake"), "{page}");
466 +
    let (w, _) = host().tag_withdrawal("v0.3").unwrap().unwrap();
467 +
    assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged");
468 +
    let audited: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit WHERE action = 'tag.withdrawn'").fetch_one(&app.db).await.unwrap();
469 +
    assert_eq!(audited, 1);
470 +
    // A clone drops it on its next fetch.
471 +
    let rep = client.fetch("origin").unwrap();
472 +
    assert_eq!(rep.withdrawn_tags, vec!["v0.3".to_string()]);
473 +
    assert_eq!(client.tag_heads("v0.3").unwrap(), None);
474 +
475 +
    // By a push: `oxy push --delete tags/NAME`.
476 +
    let r = client.push_delete("origin", "tags/v0.2").unwrap();
477 +
    assert!(r[0].ok, "{r:?}");
478 +
    assert_eq!(host().tag_heads("v0.2").unwrap(), None);
479 +
    let (w, _) = host().tag_withdrawal("v0.2").unwrap().unwrap();
480 +
    assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged");
481 +
    // The name is never used again.
482 +
    let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.2")).await;
483 +
    assert!(r.text().contains("withdrawn"), "{}", r.text());
484 +
}
modified crates/oh/src/main.rs +73 −1
…
8 unchanged lines
9 9
use anyhow::{bail, Context, Result};
10 10
use clap::{Parser, Subcommand};
11 11
use client::Client;
12 -
use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release};
12 +
use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release, Tag};
13 13
use std::time::Duration;
14 14
use target::{Choice, Config, Target};
15 15
…
56 unchanged lines
72 72
        #[command(subcommand)]
73 73
        cmd: ReleaseCmd,
74 74
    },
75 +
    /// Tags: list, create, withdraw
76 +
    Tag {
77 +
        #[command(subcommand)]
78 +
        cmd: TagCmd,
79 +
    },
75 80
    /// The repository's labels
76 81
    Label {
77 82
        #[command(subcommand)]
…
535 unchanged lines
613 618
}
614 619
615 620
#[derive(Subcommand)]
621 +
enum TagCmd {
622 +
    /// Tags, newest name first
623 +
    List,
624 +
    /// Tag the state of a bookmark, tag or change, signed by the hub as your
625 +
    /// delegate (as `oxy tag create` and a push would be)
626 +
    Create {
627 +
        name: String,
628 +
        /// What to tag (default: the default bookmark)
629 +
        #[arg(long)]
630 +
        target: Option<String>,
631 +
        #[arg(long, short)]
632 +
        message: Option<String>,
633 +
    },
634 +
    /// Withdraw a tag: a signed withdrawal, kept and logged; clones drop it on
635 +
    /// their next fetch, and the name is never used again. Refused while a
636 +
    /// release uses the tag
637 +
    Delete {
638 +
        name: String,
639 +
        /// Why, recorded with the withdrawal
640 +
        #[arg(long, short)]
641 +
        reason: Option<String>,
642 +
    },
643 +
}
644 +
645 +
#[derive(Subcommand)]
616 646
enum ReleaseCmd {
617 647
    /// Releases, newest first
618 648
    List,
…
139 unchanged lines
758 788
            Ok(0)
759 789
        }
760 790
        Cmd::Release { cmd } => release(&c, &t, cmd, json),
791 +
        Cmd::Tag { cmd } => tag(&c, &t, cmd, json),
761 792
        Cmd::Label { cmd } => label(&c, &t, cmd, json),
762 793
        Cmd::Milestone { cmd } => milestone(&c, &t, cmd, json),
763 794
        Cmd::Project { cmd, owner } => project(&c, &t, cmd, owner, json),
…
1058 unchanged lines
1822 1853
        }
1823 1854
        MilestoneCmd::Reopen { title } => {
1824 1855
            edit(&title, hub_api::MilestoneEdit { state: Some("open".into()), ..Default::default() }, "reopened")?;
1856 +
        }
1857 +
    }
1858 +
    Ok(0)
1859 +
}
1860 +
1861 +
fn tag(c: &Client, t: &Target, cmd: TagCmd, json: bool) -> Result<i32> {
1862 +
    let base = t.repo_path()?;
1863 +
    match cmd {
1864 +
        TagCmd::List => {
1865 +
            let path = format!("{base}/tags");
1866 +
            if json {
1867 +
                println!("{}", c.get_text(&path)?);
1868 +
                return Ok(0);
1869 +
            }
1870 +
            let tags: Vec<Tag> = c.get(&path)?;
1871 +
            if tags.is_empty() {
1872 +
                println!("{}", ui::dim("no tags"));
1873 +
            }
1874 +
            for t in tags {
1875 +
                let heads: Vec<&str> = t.heads.iter().map(|h| &h[..h.len().min(12)]).collect();
1876 +
                let signer = t.signer.map(|s| format!("signed by {s}")).unwrap_or_else(|| "unsigned".into());
1877 +
                println!("{}  {}  {}", t.name, ui::dim(&heads.join(" ")), ui::dim(&signer));
1878 +
            }
1879 +
        }
1880 +
        TagCmd::Create { name, target, message } => {
1881 +
            let new = hub_api::NewTag { name, target, message };
1882 +
            let made: Tag = c.send("POST", &format!("{base}/tags"), &new)?;
1883 +
            if json {
1884 +
                println!("{}", serde_json::to_string(&made)?);
1885 +
            } else {
1886 +
                println!("{} tagged {} {}", ui::ok("✔"), made.name, ui::dim(&format!("{} head(s)", made.heads.len())));
1887 +
            }
1888 +
        }
1889 +
        TagCmd::Delete { name, reason } => {
1890 +
            let reason = reason.unwrap_or_default();
1891 +
            c.send_text("DELETE", &format!("{base}/tags/{}?reason={}", enc(&name), enc(&reason)), &serde_json::json!({}))?;
1892 +
            if json {
1893 +
                println!("{}", serde_json::json!({ "withdrawn": name }));
1894 +
            } else {
1895 +
                println!("{} withdrew {} {}", ui::ok("✔"), name, ui::dim("(clones drop it on their next fetch)"));
1896 +
            }
1825 1897
        }
1826 1898
    }
1827 1899
    Ok(0)
…
129 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/oh.md +1 −0
…
56 unchanged lines
57 57
| `oh run log N JOB` | A job's log |
58 58
| `oh run rerun N` | Run a finished run's state again |
59 59
| `oh run cancel N` | Cancel a run still queued or running; its running jobs are stopped |
60 +
| `oh tag list`, `oh tag create NAME [--target REF] [-m MESSAGE]`, `oh tag delete NAME [-r REASON]` | Tags: made on the hub as your delegate; withdrawn with a signed withdrawal (never while a release uses one) |
60 61
| `oh release list`, `oh release create TAG [-t TITLE] [-n NOTES]` | Releases |
61 62
| `oh release upload TAG FILE…` | Attach assets |
62 63
| `oh release trust TAG` | The release's trust audit; exit 1 on a failure |
…
12 unchanged lines
modified docs/spec.md +2 −2
…
454 unchanged lines
455 455
456 456
## 9. Releases and tags
457 457
458 -
Tags come from the repository (spec §10): a name, the state, and a signed attestation when the tagger signed. The tags page lists them with signature status. A release attaches to a tag: notes (Markdown, generated draft from the changes since the previous release), assets (uploaded files, content addressed, size limited by `asset_limit`), a pre-release flag, and a latest marker. Source archives (`.tar.zst`, `.zip`) of the tagged state are generated on demand and cached.
458 +
Tags come from the repository (spec §10): a name, the state, and a signed attestation when the tagger signed. The tags page lists them with signature status. A writer creates one there (#295; `POST /tags`, `oh tag create NAME [--target REF] [-m MESSAGE]`): the hub signs it with its key as the user's delegate (in a repository without a trust policy, with its own key when the user has not delegated), and it is received as a pushed tag is, so the policy's rule for `tags/<name>` applies, the tag jobs of the pipeline run, and the push is recorded. Tags never move: a mistaken one is withdrawn (#301; Oxygen spec §10.2) by a maintainer there (`DELETE /tags/{tag}?reason=`, `oh tag delete NAME [-r REASON]`) or by `oxy push --delete tags/NAME`, with a signed `untag` attestation, never while a release uses the tag (delete the release first; a push's withdrawal is refused then too). The withdrawal is logged in the transparency log (`untag`), audited (`tag.withdrawn`) and recorded as a push; the page lists withdrawn tags with why, by whom and when, clones drop the tag on their next fetch, and the name is not used again. A release attaches to a tag: notes (Markdown, generated draft from the changes since the previous release), assets (uploaded files, content addressed, size limited by `asset_limit`), a pre-release flag, and a latest marker. Source archives (`.tar.zst`, `.zip`) of the tagged state are generated on demand and cached.
459 459
460 460
**Trust and audit** (§9.1). `/{owner}/{name}/releases/trust/{tag}` reports on a release; each release on the releases page links it.
461 461
- **Tag:** the tag's signer and whether its attestation verifies.
…
28 unchanged lines
490 490
491 491
### 12.1 API
492 492
493 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
493 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
494 494
495 495
### 12.2 Webhooks
496 496
…
396 unchanged lines
modified templates/tags.html +33 −2
…
5 unchanged lines
6 6
{% block content %}
7 7
<div class="head"><h1>Tags <small>{{ tags.len() }}</small></h1></div>
8 8
<div class="bar"><span class="modes"><a href="{{ repo.url }}/releases">releases</a><a href="{{ repo.url }}/tags" class="on" aria-current="page">tags</a></span></div>
9 -
{% if tags.is_empty() %}<p class="empty">No tags. <code>oxy tag create NAME -m "message"</code> then push.</p>{% else %}
9 +
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
10 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
11 +
{% if can_create %}
12 +
<details class="tag-create"{% if error.is_some() %} open{% endif %}><summary>Create a tag</summary>
13 +
  <form class="card wide" method="post" action="{{ repo.url }}/tags/create">
14 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
15 +
    <label for="tag-name">Name</label><input type="text" id="tag-name" name="name" placeholder="v1.0.0" required>
16 +
    <label for="tag-target">Of</label><input type="text" id="tag-target" name="target" placeholder="{{ repo.default_bookmark }}">
17 +
    <label for="tag-message">Message</label><input type="text" id="tag-message" name="message">
18 +
    <p class="small mute">Signed by this hub's key as your delegate, as <code>oxy tag create</code> and a push would be. Tags never move; a mistake is withdrawn, and its name is not used again.</p>
19 +
    <div class="choice"><button type="submit">CREATE TAG</button></div>
20 +
  </form>
21 +
</details>
22 +
{% endif %}
23 +
{% if tags.is_empty() %}<p class="empty">No tags. <code>oxy tag create NAME -m "message"</code> then push{% if can_create %}, or create one above{% endif %}.</p>{% else %}
10 24
<div class="table"><table>
11 -
<tr><th>Tag</th><th>State</th><th>Message</th><th>Signed</th></tr>
25 +
<tr><th>Tag</th><th>State</th><th>Message</th><th>Signed</th>{% if can_withdraw %}<th></th>{% endif %}</tr>
12 26
{% for t in tags %}
13 27
<tr>
14 28
  <td class="px"><a href="{{ repo.url }}/tree/{{ t.name }}">{{ t.name }}</a></td>
15 29
  <td>{% for h in t.heads %}<a class="chip" href="{{ repo.url }}/change/{{ h }}">{{ h|short }}</a> {% endfor %}</td>
16 30
  <td>{{ t.message }}</td>
17 31
  <td>{% if let Some(s) = t.signer %}<span class="sig sig-valid">✔</span> {{ s }}{% else %}<span class="sig sig-unsigned">○</span> unsigned{% endif %}</td>
32 +
  {% if can_withdraw %}<td class="small">{% if self.released(t.name.as_str()) %}<span class="mute" title="Delete the release first">in a release</span>{% else %}
33 +
    <details class="tiny"><summary>withdraw</summary><form class="tiny" method="post" action="{{ repo.url }}/tags/withdraw"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="tag" value="{{ t.name }}"><input type="text" name="reason" placeholder="why (optional)" aria-label="Why {{ t.name }} is withdrawn"> <button type="submit" class="small danger-btn" data-confirm="Withdraw the tag {{ t.name }}? Clones drop it on their next fetch, and the name can never be used again.">WITHDRAW</button></form></details>{% endif %}</td>{% endif %}
34 +
</tr>
35 +
{% endfor %}
36 +
</table></div>
37 +
{% endif %}
38 +
{% if !withdrawn.is_empty() %}
39 +
<h2>Withdrawn <small>{{ withdrawn.len() }}</small></h2>
40 +
<div class="table"><table>
41 +
<tr><th>Tag</th><th>Named</th><th>Why</th><th>By</th><th>When</th></tr>
42 +
{% for w in withdrawn %}
43 +
<tr>
44 +
  <td class="px mute"><s>{{ w.name }}</s></td>
45 +
  <td>{% for h in w.heads %}<a class="chip" href="{{ repo.url }}/change/{{ h }}">{{ h|short }}</a> {% endfor %}</td>
46 +
  <td>{% if w.reason.is_empty() %}<span class="mute">—</span>{% else %}{{ w.reason }}{% endif %}</td>
47 +
  <td>{% if let Some(s) = w.signer %}<span class="sig sig-valid">✔</span> {{ s }}{% else %}<span class="sig sig-unsigned">○</span> unsigned{% endif %}</td>
48 +
  <td><time title="{{ w.when_ms|when_i }}">{{ w.when_ms|ago_i }}</time></td>
18 49
</tr>
19 50
{% endfor %}
20 51
</table></div>
…
2 unchanged lines