Skip to main content

flowey_lib_hvlite/_jobs/
publish_openvmm_gh_release.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Create a tag and draft GitHub release for OpenVMM.
5//!
6//! Publication is deliberately two-stage: this job only ever creates the tag
7//! and a *draft*. A maintainer reviews the draft and clicks Publish. The
8//! irreversible release step therefore stays with a human, while the release
9//! cannot be rebound to another commit during review.
10//!
11//! GitHub automatically provides source archives for the release tag. The only
12//! uploaded asset is the vendor archive required for offline Cargo builds.
13
14use crate::assemble_openvmm_vendor_release::{VendorReleaseOutput, read_vendor_identity};
15use flowey::node::prelude::*;
16
17flowey_request! {
18    pub struct Request {
19        pub release: ReadVar<VendorReleaseOutput>,
20        pub done: WriteVar<SideEffect>,
21    }
22}
23
24new_simple_flow_node!(struct Node);
25
26impl SimpleFlowNode for Node {
27    type Request = Request;
28
29    fn imports(ctx: &mut ImportCtx<'_>) {
30        ctx.import::<flowey_lib_common::publish_gh_release::Node>();
31        ctx.import::<flowey_lib_common::use_gh_cli::Node>();
32    }
33
34    fn process_request(request: Self::Request, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
35        let Request { release, done } = request;
36
37        let files = ctx.emit_rust_stepv("enumerate release files", |ctx| {
38            let release = release.clone().claim(ctx);
39            move |rt| {
40                let release = rt.read(release);
41                vendor_release_files(&release.assets)
42            }
43        });
44
45        let identity = ctx.emit_rust_stepv("resolve source release identity", |ctx| {
46            let release = release.claim(ctx);
47            move |rt| {
48                let release = rt.read(release);
49                read_vendor_identity(&release.assets)
50            }
51        });
52
53        let target = identity.map(ctx, |identity| identity.revision);
54        let tag = identity.map(ctx, |identity| format!("openvmm-v{}", identity.version));
55        let title = identity.map(ctx, |identity| format!("OpenVMM v{}", identity.version));
56
57        // Refuse an existing release before creating the tag. The tag is a
58        // side effect this job cannot take back, and a release that already
59        // exists for this version means a rerun would otherwise pin a tag that
60        // the pre-existing release silently adopts.
61        let gh_cli = ctx.reqv(flowey_lib_common::use_gh_cli::Request::Get);
62        let no_existing_release = ctx.emit_rust_step("ensure no existing source release", |ctx| {
63            let gh_cli = gh_cli.clone().claim(ctx);
64            let tag = tag.clone().claim(ctx);
65            move |rt| {
66                let gh_cli = rt.read(gh_cli);
67                let tag = rt.read(tag);
68
69                let output =
70                    flowey::shell_cmd!(rt, "{gh_cli} release view {tag} --repo microsoft/openvmm")
71                        .ignore_status()
72                        .output()
73                        .context("failed to query the OpenVMM release")?;
74
75                if output.status.success() {
76                    anyhow::bail!(
77                        "a GitHub release already exists for tag {tag}. It may already have \
78                         been reviewed or published, so this run will not pin a tag it could \
79                         adopt. Delete it and rerun if it should be regenerated."
80                    );
81                }
82
83                let stderr = String::from_utf8_lossy(&output.stderr);
84                if !stderr.contains("release not found") {
85                    anyhow::bail!(
86                        "failed to query the OpenVMM release for tag {tag}: {}",
87                        stderr.trim()
88                    );
89                }
90
91                Ok(())
92            }
93        });
94
95        // Create the tag before the draft so a later tag cannot silently rebind
96        // the release to a different commit. Reruns reuse it only when it still
97        // names the exact archived revision.
98        let tag_is_pinned = ctx.emit_rust_step("pin source release tag", |ctx| {
99            // Claiming without reading is what orders this step after the
100            // check. The side effect a rust step hands back is never written to
101            // the var db, so reading it at runtime would panic.
102            no_existing_release.claim(ctx);
103            // Order the archive-existence check ahead of the tag as well. The
104            // tag cannot be taken back, so an artifact that arrived without its
105            // vendor archive must fail before the tag exists, not after.
106            let _files = files.clone().claim(ctx);
107            let gh_cli = gh_cli.claim(ctx);
108            let tag = tag.clone().claim(ctx);
109            let target = target.clone().claim(ctx);
110            move |rt| {
111                let gh_cli = rt.read(gh_cli);
112                let tag = rt.read(tag);
113                let target = rt.read(target);
114
115                let ref_name = format!("refs/tags/{tag}");
116                let create = flowey::shell_cmd!(
117                    rt,
118                    "{gh_cli} api --method POST repos/microsoft/openvmm/git/refs -f ref={ref_name} -f sha={target}"
119                )
120                .ignore_status()
121                .output()
122                .context("failed to create the OpenVMM release tag")?;
123                if create.status.success() {
124                    log::info!("created release tag {tag} at {target}");
125                    return Ok(());
126                }
127
128                let existing = flowey::shell_cmd!(
129                    rt,
130                    "{gh_cli} api repos/microsoft/openvmm/git/ref/tags/{tag}"
131                )
132                .ignore_status()
133                .output()
134                .context("failed to query the existing OpenVMM release tag")?;
135                if !existing.status.success() {
136                    anyhow::bail!(
137                        "failed to create release tag {tag}: {}; querying the existing tag also \
138                         failed: {}",
139                        String::from_utf8_lossy(&create.stderr).trim(),
140                        String::from_utf8_lossy(&existing.stderr).trim()
141                    );
142                }
143
144                let existing: serde_json::Value = serde_json::from_slice(&existing.stdout)
145                    .context("failed to parse the existing OpenVMM release tag")?;
146                let tag_type = existing["object"]["type"].as_str();
147                let tag_target = existing["object"]["sha"].as_str();
148                if tag_type == Some("tag") {
149                    // `object.sha` names the annotation, not a commit, so it
150                    // cannot be compared against the archived revision.
151                    anyhow::bail!(
152                        "release tag {tag} already exists as an annotated tag (object {}), but \
153                         this pipeline publishes lightweight tags naming commit {target}",
154                        tag_target.unwrap_or("<unknown>")
155                    );
156                }
157                if tag_type != Some("commit") || tag_target != Some(target.as_str()) {
158                    anyhow::bail!(
159                        "release tag {tag} already exists at {} ({}) instead of commit {target}",
160                        tag_target.unwrap_or("<unknown>"),
161                        tag_type.unwrap_or("unknown object type")
162                    );
163                }
164
165                log::info!("reusing release tag {tag} at {target}");
166                Ok(())
167            }
168        });
169
170        ctx.req(flowey_lib_common::publish_gh_release::Request(
171            flowey_lib_common::publish_gh_release::GhReleaseParams {
172                repo_owner: "microsoft".into(),
173                repo_name: "openvmm".into(),
174                target,
175                tag,
176                title,
177                files,
178                // The draft body is written by the maintainer reviewing it.
179                // Generated notes would compare against the previous tag, and
180                // the first release has no previous tag to compare against.
181                notes: flowey_lib_common::publish_gh_release::GhReleaseNotes::Empty,
182                draft: true,
183                verify_tag: true,
184                // Unlike a release that tracks every push, this pipeline only
185                // runs because someone asked for this version. Quietly doing
186                // nothing would look like it worked.
187                on_existing: flowey_lib_common::publish_gh_release::OnExistingRelease::Fail,
188                prerequisites: vec![tag_is_pinned],
189                done,
190            },
191        ));
192
193        Ok(())
194    }
195}
196
197fn vendor_release_files(assets: &Path) -> anyhow::Result<Vec<(PathBuf, Option<String>)>> {
198    let identity = read_vendor_identity(assets)?;
199    let archive = assets.join(identity.archive_name());
200    if !archive.is_file() {
201        anyhow::bail!("missing vendor archive {}", archive.display());
202    }
203
204    Ok(vec![(archive, None)])
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::assemble_openvmm_vendor_release::{IDENTITY_FILE, VendorReleaseIdentity};
211
212    #[test]
213    fn identity_stays_private_when_enumerating_release_assets() {
214        let dir = tempfile::tempdir().unwrap();
215        let identity = VendorReleaseIdentity {
216            version: "0.12.3".into(),
217            revision: "0123456789abcdef0123456789abcdef01234567".into(),
218        };
219        let archive = dir.path().join(identity.archive_name());
220
221        fs_err::write(
222            dir.path().join(IDENTITY_FILE),
223            serde_json::to_vec(&identity).unwrap(),
224        )
225        .unwrap();
226        fs_err::write(&archive, b"archive").unwrap();
227
228        assert_eq!(
229            vendor_release_files(dir.path()).unwrap(),
230            vec![(archive, None)]
231        );
232    }
233}