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