1use camino::{Utf8Path, Utf8PathBuf};
12use serde::{Deserialize, Serialize};
13
14use crate::detect::Forge;
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::error::RkError;
17use crate::release_state::Registry;
18
19use super::adapter::{self, Version};
20use super::forge::Client;
21use super::guard::{self, Existing, Outcome, Parent, Push};
22use super::packed;
23use super::{BUILD_REPORT, CHECKSUMS, Context, Done, NOTES, OUT_DIR, cliff};
24
25pub const SCHEMA: &str = "rk.release-build/2";
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Artifact {
31 pub path: String,
33 pub sha256: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BuildReport {
41 pub schema: String,
43 pub state: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub reason: Option<String>,
48 pub version: String,
50 pub tag: String,
52 pub candidate: bool,
54 pub commit: String,
56 pub artifacts: Vec<Artifact>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub checksums: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub notes: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub files: Option<Vec<String>>,
67}
68
69pub fn run(target: &Utf8Path) -> Result<Done<BuildReport>, RkError> {
76 let context = Context::load(target)?;
77 context.require_tools("build")?;
78 cliff::require_pin(target)?;
79 let head = context.head()?;
80 let version = context.adapter.read_version(target)?;
81 let tag = context.adapter.tag_of(version);
82 let mut report = BuildReport {
83 schema: SCHEMA.to_owned(),
84 state: "skip".to_owned(),
85 reason: None,
86 version: version.spelled(context.driver()),
87 tag: tag.clone(),
88 candidate: version.is_candidate(),
89 commit: head.clone(),
90 artifacts: Vec::new(),
91 checksums: None,
92 notes: None,
93 files: None,
94 };
95 let outcome = decide(&context, &head, version, &tag)?;
96 match outcome {
97 Outcome::Skip(reason) => {
98 report.reason = Some(reason.clone());
99 write_report(&context, &report)?;
100 return Ok(Done {
101 report,
102 lines: vec![format!("skip: {reason}")],
103 next: Vec::new(),
104 });
105 }
106 Outcome::Refuse(reason) => {
107 return Err(super::conflict(reason, super::RECOVERY_TAG));
108 }
109 Outcome::Release | Outcome::Resume => {}
110 }
111 outcome.as_str().clone_into(&mut report.state);
112 let epoch = super::git_line(
113 target,
114 &["log", "-1", "--format=%ct", "HEAD"],
115 "the commit time",
116 )?;
117 let built = context.adapter.build(target, &epoch)?;
118 let mut artifacts = Vec::new();
119 for path in &built {
120 artifacts.push(Artifact {
121 path: path.to_string(),
122 sha256: super::file_sha256(&target.join(path))?,
123 });
124 }
125 if context.registry == Some(Registry::Pypi)
126 && let Some(package) = context.package.as_deref()
127 {
128 compare_with_registry(package, version, &artifacts)?;
129 }
130 let out = target.join(OUT_DIR);
131 if out.exists() {
132 std::fs::remove_dir_all(&out).map_err(RkError::Io)?;
133 }
134 std::fs::create_dir_all(&out).map_err(RkError::Io)?;
135 let notes = Utf8PathBuf::from(format!("{OUT_DIR}/{NOTES}"));
136 let paths = match packed::previous_tag(target, &tag)? {
141 Some(previous) => packed::compare(&context, &previous)?.history,
142 None => Vec::new(),
143 };
144 cliff::notes(target, &tag, &target.join(¬es), &paths)?;
145 let checksums = Utf8PathBuf::from(format!("{OUT_DIR}/{CHECKSUMS}"));
146 let mut sums = String::new();
147 for artifact in &artifacts {
148 sums.push_str(&artifact.sha256);
149 sums.push_str(" ");
150 sums.push_str(&super::file_name(Utf8Path::new(&artifact.path)));
151 sums.push('\n');
152 }
153 std::fs::write(target.join(&checksums), sums).map_err(RkError::Io)?;
154 report.artifacts = artifacts;
155 report.checksums = Some(checksums.to_string());
156 report.notes = Some(notes.to_string());
157 write_report(&context, &report)?;
158 let mut lines = vec![format!(
159 "{}: {tag} at {head}{}",
160 report.state,
161 if report.candidate {
162 ", a release candidate"
163 } else {
164 ""
165 }
166 )];
167 lines.extend(
168 report
169 .artifacts
170 .iter()
171 .map(|artifact| format!("built {} sha256 {}", artifact.path, artifact.sha256)),
172 );
173 Ok(Done {
174 report,
175 lines,
176 next: vec!["attest the artifacts, then rk release publish".to_owned()],
177 })
178}
179
180pub fn list(target: &Utf8Path) -> Result<Done<BuildReport>, RkError> {
193 let context = Context::load(target)?;
194 let head = context.head()?;
195 let version = context.adapter.read_version(target)?;
196 let files: Vec<String> = context
197 .adapter
198 .packed(target, "HEAD")?
199 .into_iter()
200 .collect();
201 Ok(Done {
202 lines: files.clone(),
203 report: BuildReport {
204 schema: SCHEMA.to_owned(),
205 state: "list".to_owned(),
206 reason: None,
207 version: version.spelled(context.driver()),
208 tag: context.adapter.tag_of(version),
209 candidate: version.is_candidate(),
210 commit: head,
211 artifacts: Vec::new(),
212 checksums: None,
213 notes: None,
214 files: Some(files),
215 },
216 next: Vec::new(),
217 })
218}
219
220fn decide(context: &Context, head: &str, version: Version, tag: &str) -> Result<Outcome, RkError> {
222 let push = Push {
223 version,
224 parent: parent_version(context)?,
225 merges_request: true,
226 };
227 if let Some(reason) = guard::trigger(push) {
228 return Ok(Outcome::Skip(reason));
229 }
230 let Some(base) = context
231 .pushed_branch()
232 .filter(|branch| context.is_base(branch))
233 else {
234 return Ok(Outcome::Skip(
235 "the push is to neither the trunk nor a release line".to_owned(),
236 ));
237 };
238 let client = Client::resolve(context.forge, &context.target, &context.repo)?;
239 if !version.is_candidate() {
240 let merges = client.merges_request(head, &base, &super::request_branch(&base))?;
241 if let Some(reason) = guard::trigger(Push {
242 merges_request: merges,
243 ..push
244 }) {
245 return Ok(Outcome::Skip(reason));
246 }
247 }
248 forge_outcome(&client, head, tag)
249}
250
251pub fn forge_outcome(client: &Client, head: &str, tag: &str) -> Result<Outcome, RkError> {
258 let existing = match client.release(tag)? {
259 None => Existing::None,
260 Some(release) if release.draft => Existing::Draft,
261 Some(_) => Existing::Published,
262 };
263 let at = client.tag(tag);
264 Ok(guard::judge(head, tag, existing, &at))
265}
266
267fn parent_version(context: &Context) -> Result<Parent, RkError> {
270 let (file, _) = adapter::version_file(context.driver()).unwrap_or(("VERSION", None));
271 let spec = format!("HEAD^:{file}");
272 let output = super::git(&context.target, &["show", &spec])?;
273 if !output.status.success() {
274 return Ok(Parent::Absent);
275 }
276 let text = String::from_utf8_lossy(&output.stdout);
277 Ok(Parent::Carries(
278 adapter::version_in(context.driver(), &text)
279 .as_deref()
280 .and_then(Version::parse),
281 ))
282}
283
284fn compare_with_registry(
289 package: &str,
290 version: Version,
291 artifacts: &[Artifact],
292) -> Result<(), RkError> {
293 let spelled = version.spelled("python");
294 let served = crate::release_state::registry_files(Registry::Pypi, package, &spelled)
295 .map_err(|reason| {
296 RkError::refusal(
297 Diagnostic::new(
298 Reason::ObservationIncomplete,
299 format!("PyPI did not answer for {package} {spelled}: {reason}"),
300 )
301 .expected("PyPI's JSON API answering 200 or 404 for the version")
302 .action("rerun the job once PyPI answers")
303 .target_state("no upload and no forge write ran"),
304 )
305 })?
306 .unwrap_or_default();
307 for artifact in artifacts {
308 let name = super::file_name(Utf8Path::new(&artifact.path));
309 if let Some((_, digest)) = served.iter().find(|(file, _)| *file == name)
310 && *digest != artifact.sha256
311 {
312 return Err(super::conflict(
313 format!(
314 "PyPI serves {name} for {package} {spelled} with sha256 {digest}, and this rebuild has {}; yank the version and fix forward",
315 artifact.sha256
316 ),
317 super::RECOVERY_DEFECTIVE,
318 ));
319 }
320 }
321 Ok(())
322}
323
324fn write_report(context: &Context, report: &BuildReport) -> Result<(), RkError> {
327 let out = context.target.join(OUT_DIR);
328 std::fs::create_dir_all(&out).map_err(RkError::Io)?;
329 let json = serde_json::to_string_pretty(report).map_err(|error| anyhow::anyhow!(error))?;
330 std::fs::write(out.join(BUILD_REPORT), format!("{json}\n")).map_err(RkError::Io)?;
331 if context.forge == Forge::Github
332 && let Some(path) = std::env::var_os("GITHUB_OUTPUT").filter(|path| !path.is_empty())
333 {
334 use std::io::Write as _;
335 let paths: Vec<&str> = report
336 .artifacts
337 .iter()
338 .map(|artifact| artifact.path.as_str())
339 .collect();
340 let delimiter = format!("rk-release-{}", report.commit);
341 let mut block = vec![
342 format!("state={}", report.state),
343 format!("version={}", report.version),
344 format!("tag={}", report.tag),
345 format!("candidate={}", report.candidate),
346 format!("artifacts<<{delimiter}"),
347 ];
348 block.extend(paths.iter().map(|path| (*path).to_owned()));
349 block.push(delimiter);
350 let mut file = std::fs::OpenOptions::new()
351 .create(true)
352 .append(true)
353 .open(path)
354 .map_err(RkError::Io)?;
355 for line in block {
356 writeln!(file, "{line}").map_err(RkError::Io)?;
357 }
358 }
359 Ok(())
360}