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 |