release_kit/release_engine/
build.rs1use 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::{BUILD_REPORT, CHECKSUMS, Context, Done, NOTES, OUT_DIR, cliff};
23
24pub const SCHEMA: &str = "rk.release-build/1";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Artifact {
30 pub path: String,
32 pub sha256: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct BuildReport {
40 pub schema: String,
42 pub state: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub reason: Option<String>,
47 pub version: String,
49 pub tag: String,
51 pub candidate: bool,
53 pub commit: String,
55 pub artifacts: Vec<Artifact>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub checksums: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub notes: Option<String>,
63}
64
65pub fn run(target: &Utf8Path) -> Result<Done<BuildReport>, RkError> {
72 let context = Context::load(target)?;
73 cliff::require_pin(target)?;
74 let head = context.head()?;
75 let version = context.adapter.read_version(target)?;
76 let tag = context.adapter.tag_of(version);
77 let mut report = BuildReport {
78 schema: SCHEMA.to_owned(),
79 state: "skip".to_owned(),
80 reason: None,
81 version: version.spelled(context.driver()),
82 tag: tag.clone(),
83 candidate: version.is_candidate(),
84 commit: head.clone(),
85 artifacts: Vec::new(),
86 checksums: None,
87 notes: None,
88 };
89 let outcome = decide(&context, &head, version, &tag)?;
90 match outcome {
91 Outcome::Skip(reason) => {
92 report.reason = Some(reason.clone());
93 write_report(&context, &report)?;
94 return Ok(Done {
95 report,
96 lines: vec![format!("skip: {reason}")],
97 next: Vec::new(),
98 });
99 }
100 Outcome::Refuse(reason) => {
101 return Err(super::conflict(reason, super::RECOVERY_TAG));
102 }
103 Outcome::Release | Outcome::Resume => {}
104 }
105 outcome.as_str().clone_into(&mut report.state);
106 let epoch = super::git_line(
107 target,
108 &["log", "-1", "--format=%ct", "HEAD"],
109 "the commit time",
110 )?;
111 let built = context.adapter.build(target, &epoch)?;
112 let mut artifacts = Vec::new();
113 for path in &built {
114 artifacts.push(Artifact {
115 path: path.to_string(),
116 sha256: super::file_sha256(&target.join(path))?,
117 });
118 }
119 if context.registry == Some(Registry::Pypi)
120 && let Some(package) = context.package.as_deref()
121 {
122 compare_with_registry(package, version, &artifacts)?;
123 }
124 let out = target.join(OUT_DIR);
125 if out.exists() {
126 std::fs::remove_dir_all(&out).map_err(RkError::Io)?;
127 }
128 std::fs::create_dir_all(&out).map_err(RkError::Io)?;
129 let notes = Utf8PathBuf::from(format!("{OUT_DIR}/{NOTES}"));
130 cliff::notes(target, &tag, &target.join(¬es))?;
131 let checksums = Utf8PathBuf::from(format!("{OUT_DIR}/{CHECKSUMS}"));
132 let mut sums = String::new();
133 for artifact in &artifacts {
134 sums.push_str(&artifact.sha256);
135 sums.push_str(" ");
136 sums.push_str(&super::file_name(Utf8Path::new(&artifact.path)));
137 sums.push('\n');
138 }
139 std::fs::write(target.join(&checksums), sums).map_err(RkError::Io)?;
140 report.artifacts = artifacts;
141 report.checksums = Some(checksums.to_string());
142 report.notes = Some(notes.to_string());
143 write_report(&context, &report)?;
144 let mut lines = vec![format!(
145 "{}: {tag} at {head}{}",
146 report.state,
147 if report.candidate {
148 ", a release candidate"
149 } else {
150 ""
151 }
152 )];
153 lines.extend(
154 report
155 .artifacts
156 .iter()
157 .map(|artifact| format!("built {} sha256 {}", artifact.path, artifact.sha256)),
158 );
159 Ok(Done {
160 report,
161 lines,
162 next: vec!["attest the artifacts, then rk release publish".to_owned()],
163 })
164}
165
166fn decide(context: &Context, head: &str, version: Version, tag: &str) -> Result<Outcome, RkError> {
168 let push = Push {
169 version,
170 parent: parent_version(context)?,
171 merges_request: true,
172 };
173 if let Some(reason) = guard::trigger(push) {
174 return Ok(Outcome::Skip(reason));
175 }
176 let Some(base) = context
177 .pushed_branch()
178 .filter(|branch| context.is_base(branch))
179 else {
180 return Ok(Outcome::Skip(
181 "the push is to neither the trunk nor a release line".to_owned(),
182 ));
183 };
184 let client = Client::resolve(context.forge, &context.target, &context.repo)?;
185 if !version.is_candidate() {
186 let merges = client.merges_request(head, &base, &super::request_branch(&base))?;
187 if let Some(reason) = guard::trigger(Push {
188 merges_request: merges,
189 ..push
190 }) {
191 return Ok(Outcome::Skip(reason));
192 }
193 }
194 forge_outcome(&client, head, tag)
195}
196
197pub fn forge_outcome(client: &Client, head: &str, tag: &str) -> Result<Outcome, RkError> {
204 let existing = match client.release(tag)? {
205 None => Existing::None,
206 Some(release) if release.draft => Existing::Draft,
207 Some(_) => Existing::Published,
208 };
209 let at = client.tag(tag);
210 Ok(guard::judge(head, tag, existing, &at))
211}
212
213fn parent_version(context: &Context) -> Result<Parent, RkError> {
216 let (file, _) = adapter::version_file(context.driver()).unwrap_or(("VERSION", None));
217 let spec = format!("HEAD^:{file}");
218 let output = super::git(&context.target, &["show", &spec])?;
219 if !output.status.success() {
220 return Ok(Parent::Absent);
221 }
222 let text = String::from_utf8_lossy(&output.stdout);
223 Ok(Parent::Carries(
224 adapter::version_in(context.driver(), &text)
225 .as_deref()
226 .and_then(Version::parse),
227 ))
228}
229
230fn compare_with_registry(
235 package: &str,
236 version: Version,
237 artifacts: &[Artifact],
238) -> Result<(), RkError> {
239 let spelled = version.spelled("python");
240 let served = crate::release_state::registry_files(Registry::Pypi, package, &spelled)
241 .map_err(|reason| {
242 RkError::refusal(
243 Diagnostic::new(
244 Reason::ObservationIncomplete,
245 format!("PyPI did not answer for {package} {spelled}: {reason}"),
246 )
247 .expected("PyPI's JSON API answering 200 or 404 for the version")
248 .action("rerun the job once PyPI answers")
249 .target_state("no upload and no forge write ran"),
250 )
251 })?
252 .unwrap_or_default();
253 for artifact in artifacts {
254 let name = super::file_name(Utf8Path::new(&artifact.path));
255 if let Some((_, digest)) = served.iter().find(|(file, _)| *file == name)
256 && *digest != artifact.sha256
257 {
258 return Err(super::conflict(
259 format!(
260 "PyPI serves {name} for {package} {spelled} with sha256 {digest}, and this rebuild has {}; yank the version and fix forward",
261 artifact.sha256
262 ),
263 super::RECOVERY_DEFECTIVE,
264 ));
265 }
266 }
267 Ok(())
268}
269
270fn write_report(context: &Context, report: &BuildReport) -> Result<(), RkError> {
273 let out = context.target.join(OUT_DIR);
274 std::fs::create_dir_all(&out).map_err(RkError::Io)?;
275 let json = serde_json::to_string_pretty(report).map_err(|error| anyhow::anyhow!(error))?;
276 std::fs::write(out.join(BUILD_REPORT), format!("{json}\n")).map_err(RkError::Io)?;
277 if context.forge == Forge::Github
278 && let Some(path) = std::env::var_os("GITHUB_OUTPUT").filter(|path| !path.is_empty())
279 {
280 use std::io::Write as _;
281 let paths: Vec<&str> = report
282 .artifacts
283 .iter()
284 .map(|artifact| artifact.path.as_str())
285 .collect();
286 let delimiter = format!("rk-release-{}", report.commit);
287 let mut block = vec![
288 format!("state={}", report.state),
289 format!("version={}", report.version),
290 format!("tag={}", report.tag),
291 format!("candidate={}", report.candidate),
292 format!("artifacts<<{delimiter}"),
293 ];
294 block.extend(paths.iter().map(|path| (*path).to_owned()));
295 block.push(delimiter);
296 let mut file = std::fs::OpenOptions::new()
297 .create(true)
298 .append(true)
299 .open(path)
300 .map_err(RkError::Io)?;
301 for line in block {
302 writeln!(file, "{line}").map_err(RkError::Io)?;
303 }
304 }
305 Ok(())
306}