1use camino::{Utf8Path, Utf8PathBuf};
11use serde::Serialize;
12
13use crate::detect::Forge;
14use crate::diagnostic::{Diagnostic, Reason};
15use crate::error::RkError;
16
17use super::adapter::Version;
18use super::build::{BuildReport, forge_outcome};
19use super::forge::{Asset, Client};
20use super::guard::Outcome;
21use super::{BUILD_REPORT, CHECKSUMS, Context, Done, OUT_DIR};
22
23pub const SCHEMA: &str = "rk.release-publish/1";
25
26#[derive(Debug, Default, Serialize)]
28pub struct PublishReport {
29 pub schema: &'static str,
31 pub state: &'static str,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub reason: Option<String>,
36 pub tag: String,
38 pub candidate: bool,
40 pub uploaded: Vec<String>,
42 pub replaced: Vec<String>,
44 pub kept: Vec<String>,
46 pub linked: Vec<String>,
48}
49
50struct Local {
52 path: Utf8PathBuf,
53 name: String,
54 sha256: String,
55}
56
57pub fn run(target: &Utf8Path) -> Result<Done<PublishReport>, RkError> {
64 let context = Context::load(target)?;
65 let build = read_build(target)?;
66 let mut report = PublishReport {
67 schema: SCHEMA,
68 state: "skip",
69 tag: build.tag.clone(),
70 candidate: build.candidate,
71 ..PublishReport::default()
72 };
73 if build.state == "skip" {
74 report.reason.clone_from(&build.reason);
75 return Ok(Done {
76 report,
77 lines: vec![format!(
78 "skip: {}",
79 build
80 .reason
81 .as_deref()
82 .unwrap_or("the build released nothing")
83 )],
84 next: Vec::new(),
85 });
86 }
87 let head = context.head()?;
88 if head != build.commit {
89 return Err(RkError::refusal(
90 Diagnostic::new(
91 Reason::StateDrift,
92 format!(
93 "the build report is of {}, and the checkout stands at {head}",
94 build.commit
95 ),
96 )
97 .expected("the build report of this commit")
98 .target_state("unchanged"),
99 ));
100 }
101 let client = Client::resolve(context.forge, target, &context.repo)?;
102 match forge_outcome(&client, &head, &build.tag)? {
104 Outcome::Skip(reason) => {
105 report.reason = Some(reason.clone());
106 return Ok(Done {
107 report,
108 lines: vec![format!("skip: {reason}")],
109 next: Vec::new(),
110 });
111 }
112 Outcome::Refuse(reason) => return Err(super::conflict(reason, super::RECOVERY_TAG)),
113 Outcome::Release | Outcome::Resume => {}
114 }
115 let locals = locals(target, &build)?;
116 let notes = build
117 .notes
118 .as_deref()
119 .and_then(|notes| std::fs::read_to_string(target.join(notes)).ok())
120 .unwrap_or_default();
121 let resumed = match context.forge {
122 Forge::Github => github(&client, target, &head, &build, ¬es, &locals, &mut report)?,
123 Forge::Gitlab => gitlab(&client, target, &head, &build, ¬es, &locals, &mut report)?,
124 };
125 report.state = if resumed { "resumed" } else { "published" };
126 let mut lines = vec![format!(
127 "{} {}{} at {head}",
128 report.state,
129 build.tag,
130 if build.candidate {
131 " as a prerelease"
132 } else {
133 ""
134 }
135 )];
136 for (verb, names) in [
137 ("uploaded", &report.uploaded),
138 ("replaced", &report.replaced),
139 ("kept", &report.kept),
140 ("linked", &report.linked),
141 ] {
142 if !names.is_empty() {
143 lines.push(format!("{verb} {}", names.join(", ")));
144 }
145 }
146 Ok(Done {
147 report,
148 lines,
149 next: vec!["rk release status --wait <sha> reports the release".to_owned()],
150 })
151}
152
153fn read_build(target: &Utf8Path) -> Result<BuildReport, RkError> {
155 let report_path = target.join(OUT_DIR).join(BUILD_REPORT);
156 std::fs::read_to_string(&report_path)
157 .ok()
158 .and_then(|text| serde_json::from_str(&text).ok())
159 .filter(|report: &BuildReport| report.schema == super::build::SCHEMA)
160 .ok_or_else(|| {
161 RkError::refusal(
162 Diagnostic::new(
163 Reason::StateDrift,
164 format!("no rk.release-build/1 report at {report_path}"),
165 )
166 .expected("the report rk release build writes, carried to this job")
167 .action("run rk release build first, and carry .rk-release/ to this job")
168 .target_state("unchanged"),
169 )
170 })
171}
172
173fn locals(target: &Utf8Path, build: &BuildReport) -> Result<Vec<Local>, RkError> {
177 let mut locals = Vec::new();
178 for artifact in &build.artifacts {
179 let path = Utf8PathBuf::from(&artifact.path);
180 let sha256 = super::file_sha256(&target.join(&path))?;
181 if sha256 != artifact.sha256 {
182 return Err(RkError::refusal(
183 Diagnostic::new(
184 Reason::StateDrift,
185 format!("{} changed since the build reported it", artifact.path),
186 )
187 .expected("the artifacts the build reported, carried unchanged")
188 .target_state("unchanged"),
189 ));
190 }
191 locals.push(Local {
192 name: super::file_name(&path),
193 path,
194 sha256,
195 });
196 }
197 let mut extra: Vec<Utf8PathBuf> = vec![Utf8PathBuf::from(format!("{OUT_DIR}/{CHECKSUMS}"))];
198 extra.extend(
199 build
200 .artifacts
201 .iter()
202 .map(|artifact| Utf8PathBuf::from(format!("{}{BUNDLE}", artifact.path))),
203 );
204 for path in extra {
205 if target.join(&path).is_file() {
206 locals.push(Local {
207 name: super::file_name(&path),
208 sha256: super::file_sha256(&target.join(&path))?,
209 path,
210 });
211 }
212 }
213 Ok(locals)
214}
215
216const BUNDLE: &str = ".sigstore.json";
219
220fn sort<'a>(
228 locals: &'a [Local],
229 held: &[Asset],
230 keep_bundles: bool,
231) -> (Vec<&'a Local>, Vec<&'a Local>, Vec<&'a Local>) {
232 let (mut missing, mut differing, mut same) = (Vec::new(), Vec::new(), Vec::new());
233 for local in locals {
234 match held.iter().rev().find(|asset| asset.name == local.name) {
235 None => missing.push(local),
236 Some(asset) if asset.sha256.as_deref() == Some(local.sha256.as_str()) => {
237 same.push(local);
238 }
239 Some(_) => differing.push(local),
240 }
241 }
242 if keep_bundles {
243 let (kept, rest): (Vec<&Local>, Vec<&Local>) = differing.into_iter().partition(|local| {
245 local
246 .name
247 .strip_suffix(BUNDLE)
248 .is_some_and(|artifact| same.iter().any(|held: &&Local| held.name == artifact))
249 });
250 same.extend(kept);
251 differing = rest;
252 }
253 (missing, differing, same)
254}
255
256fn names(locals: &[&Local]) -> Vec<String> {
257 locals.iter().map(|local| local.name.clone()).collect()
258}
259
260fn paths(target: &Utf8Path, locals: &[&Local]) -> Vec<Utf8PathBuf> {
261 locals
262 .iter()
263 .map(|local| target.join(&local.path))
264 .collect()
265}
266
267fn refuse_differing(differing: &[&Local], tag: &str, place: &str) -> Result<(), RkError> {
269 if differing.is_empty() {
270 return Ok(());
271 }
272 Err(super::conflict(
273 format!(
274 "{place} of {tag} holds {} with other bytes than this build",
275 names(differing).join(", ")
276 ),
277 super::RECOVERY_DEFECTIVE,
278 ))
279}
280
281fn github(
284 client: &Client,
285 target: &Utf8Path,
286 head: &str,
287 build: &BuildReport,
288 notes: &str,
289 locals: &[Local],
290 report: &mut PublishReport,
291) -> Result<bool, RkError> {
292 let tag = &build.tag;
293 let existing = client.release(tag)?;
294 let Some(release) = existing else {
296 let id = client.create_draft(tag, head, notes, build.candidate)?;
297 let all: Vec<&Local> = locals.iter().collect();
298 client.upload(tag, &paths(target, &all), false)?;
299 client.publish_draft(&id, head, build.candidate)?;
300 report.uploaded = names(&all);
301 return Ok(false);
302 };
303 let (missing, differing, same) = sort(locals, &release.assets, !release.draft);
304 report.kept = names(&same);
305 if release.draft {
307 client.upload(tag, &paths(target, &missing), false)?;
308 client.upload(tag, &paths(target, &differing), true)?;
309 client.publish_draft(&release.id, head, build.candidate)?;
310 report.uploaded = names(&missing);
311 report.replaced = names(&differing);
312 return Ok(true);
313 }
314 refuse_differing(&differing, tag, "the published release")?;
315 client.upload(tag, &paths(target, &missing), false)?;
316 report.uploaded = names(&missing);
317 Ok(true)
318}
319
320fn gitlab(
324 client: &Client,
325 target: &Utf8Path,
326 head: &str,
327 build: &BuildReport,
328 notes: &str,
329 locals: &[Local],
330 report: &mut PublishReport,
331) -> Result<bool, RkError> {
332 let tag = &build.tag;
333 let version = Version::from_tag(tag).map_or_else(
334 || tag.trim_start_matches('v').to_owned(),
335 |v| v.spelled("semver"),
336 );
337 let stored = client.package_files(&version)?;
338 let (missing, differing, same) = sort(locals, &stored, true);
339 refuse_differing(&differing, tag, "the package registry")?;
340 for local in &missing {
342 client.upload_package(&version, &target.join(&local.path))?;
343 }
344 report.uploaded = names(&missing);
345 report.kept = names(&same);
346 let links: Vec<(String, String)> = locals
347 .iter()
348 .map(|local| {
349 (
350 local.name.clone(),
351 client.package_url(&version, &local.name),
352 )
353 })
354 .collect();
355 let existed = !same.is_empty();
356 match client.release(tag)? {
357 None => {
358 let description = if build.candidate {
359 let notice =
361 super::forge_text("blocks/release-candidate-notice.md.in", &[("RK_TAG", tag)])?;
362 let mut text = notice;
363 text.push('\n');
364 text.push('\n');
365 text.push_str(notes);
366 text
367 } else {
368 notes.to_owned()
369 };
370 client.create_release(tag, head, &description, &links)?;
371 report.linked = links.iter().map(|(name, _)| name.clone()).collect();
372 Ok(existed)
373 }
374 Some(release) => {
375 for (name, url) in &links {
376 if !release.assets.iter().any(|asset| asset.name == *name) {
377 client.add_link(tag, name, url)?;
378 report.linked.push(name.clone());
379 }
380 }
381 Ok(true)
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::{Asset, Local, sort};
389
390 fn local(name: &str, sha256: &str) -> Local {
391 Local {
392 path: format!("dist/{name}").into(),
393 name: name.to_owned(),
394 sha256: sha256.to_owned(),
395 }
396 }
397
398 fn held(name: &str, sha256: &str) -> Asset {
399 Asset {
400 name: name.to_owned(),
401 sha256: Some(sha256.to_owned()),
402 }
403 }
404
405 fn named(locals: &[&Local]) -> Vec<String> {
406 locals.iter().map(|local| local.name.clone()).collect()
407 }
408
409 #[test]
411 fn a_held_bundle_is_kept_only_beside_a_matching_artifact() {
412 let locals = [
413 local("w.tar.gz", "aa"),
414 local("w.tar.gz.sigstore.json", "b2"),
415 ];
416 let matching = [held("w.tar.gz", "aa"), held("w.tar.gz.sigstore.json", "b1")];
417 let (missing, differing, same) = sort(&locals, &matching, true);
418 assert!(missing.is_empty() && differing.is_empty());
419 assert_eq!(named(&same), ["w.tar.gz", "w.tar.gz.sigstore.json"]);
420 let (_, differing, _) = sort(&locals, &matching, false);
422 assert_eq!(named(&differing), ["w.tar.gz.sigstore.json"]);
423 let other = [held("w.tar.gz", "00"), held("w.tar.gz.sigstore.json", "b1")];
425 let (_, differing, same) = sort(&locals, &other, true);
426 assert!(same.is_empty());
427 assert_eq!(named(&differing), ["w.tar.gz", "w.tar.gz.sigstore.json"]);
428 }
429}