flowey_lib_common/publish_gh_release.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Publish a github release
5
6use flowey::node::prelude::*;
7
8flowey_request! {
9 pub struct Request(pub GhReleaseParams);
10}
11
12#[derive(Serialize, Deserialize)]
13pub enum GhReleaseNotes {
14 /// Create the release noninteractively with an empty body.
15 Empty,
16 Generated,
17 Text(String),
18}
19
20/// What to do when a release already exists for the tag being published.
21#[derive(Serialize, Deserialize)]
22pub enum OnExistingRelease {
23 /// Leave it alone and report success.
24 ///
25 /// Suits a release whose tag comes from a version in the tree, where
26 /// rerunning on an unchanged version is routine and means nothing is
27 /// wrong.
28 Skip,
29 /// Fail.
30 ///
31 /// Assets are never replaced automatically, because the existing release
32 /// may already have been reviewed or published.
33 Fail,
34}
35
36#[derive(Serialize, Deserialize)]
37pub struct GhReleaseParams<C = VarNotClaimed> {
38 /// First component of a github repo path
39 ///
40 /// e.g: the "foo" in "github.com/foo/bar"
41 pub repo_owner: String,
42 /// Second component of a github repo path
43 ///
44 /// e.g: the "bar" in "github.com/foo/bar"
45 pub repo_name: String,
46 /// Commit hash to target
47 pub target: ReadVar<String, C>,
48 /// Tag associated with the release artifact.
49 pub tag: ReadVar<String, C>,
50 /// Title associated with the release artifact.
51 pub title: ReadVar<String, C>,
52 /// Files to upload.
53 pub files: ReadVar<Vec<(PathBuf, Option<String>)>, C>,
54 /// Release notes to attach to the release.
55 pub notes: GhReleaseNotes,
56 /// Whether the release should be created as a draft
57 pub draft: bool,
58 /// Require the tag to exist before creating the release.
59 pub verify_tag: bool,
60 /// What to do when a release already exists for this tag.
61 pub on_existing: OnExistingRelease,
62 /// Side effects that must complete before the release is published.
63 ///
64 /// These are only claimed, never read: claiming is what orders the publish
65 /// step after them, and side effects handed back by a rust step are never
66 /// written to the var db, so reading one would panic at runtime.
67 pub prerequisites: Vec<ReadVar<SideEffect, C>>,
68
69 pub done: WriteVar<SideEffect, C>,
70}
71
72impl GhReleaseParams {
73 pub fn claim(self, ctx: &mut StepCtx<'_>) -> GhReleaseParams<VarClaimed> {
74 let GhReleaseParams {
75 repo_owner,
76 repo_name,
77 target,
78 tag,
79 title,
80 files,
81 notes,
82 draft,
83 verify_tag,
84 on_existing,
85 prerequisites,
86 done,
87 } = self;
88
89 GhReleaseParams {
90 repo_owner,
91 repo_name,
92 target: target.claim(ctx),
93 tag: tag.claim(ctx),
94 title: title.claim(ctx),
95 files: files.claim(ctx),
96 notes,
97 draft,
98 verify_tag,
99 on_existing,
100 prerequisites: prerequisites.claim(ctx),
101 done: done.claim(ctx),
102 }
103 }
104}
105
106new_flow_node!(struct Node);
107
108impl FlowNode for Node {
109 type Request = Request;
110
111 fn imports(ctx: &mut ImportCtx<'_>) {
112 ctx.import::<crate::cache::Node>();
113 ctx.import::<crate::use_gh_cli::Node>();
114 }
115
116 fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
117 if requests.is_empty() {
118 return Ok(());
119 }
120
121 let gh_cli = ctx.reqv(crate::use_gh_cli::Request::Get);
122
123 ctx.emit_rust_step("publish github releases", |ctx| {
124 let requests = requests
125 .into_iter()
126 .map(|r| r.0.claim(ctx))
127 .collect::<Vec<_>>();
128 let gh_cli = gh_cli.claim(ctx);
129
130 move |rt| {
131 let gh_cli = rt.read(gh_cli);
132
133 for req in requests {
134 let GhReleaseParams {
135 repo_owner,
136 repo_name,
137 target,
138 tag,
139 title,
140 files,
141 notes,
142 draft,
143 verify_tag,
144 on_existing,
145 prerequisites: _,
146 done: _,
147 } = req;
148
149 let repo = format!("{repo_owner}/{repo_name}");
150 let target = rt.read(target);
151 let tag = rt.read(tag);
152
153 // Check if the release already exists.
154 //
155 // Capture the output rather than letting it inherit. On the
156 // ordinary path there is no release yet, so `gh` writes
157 // "release not found", which is a confusing thing to find in
158 // the log of a run that went on to publish successfully. It
159 // is still logged when the command fails for some other
160 // reason -- an auth failure or a 5xx also exit non-zero, and
161 // are indistinguishable from "not found" without it.
162 let output =
163 flowey::shell_cmd!(rt, "{gh_cli} release view {tag} --repo {repo}")
164 .ignore_status()
165 .output()
166 .context("failed to run gh cli")?;
167
168 // Success means the release already exists.
169 if output.status.success() {
170 match on_existing {
171 OnExistingRelease::Skip => {
172 log::info!("GitHub release with tag {tag} already exists in repo {repo}. Skipping...");
173 continue;
174 }
175 OnExistingRelease::Fail => {
176 anyhow::bail!(
177 "a GitHub release already exists for tag {tag} in repo \
178 {repo}. Its assets are not replaced automatically, since \
179 the existing release may already have been reviewed or \
180 published. Delete it and rerun if it should be regenerated."
181 );
182 }
183 }
184 } else {
185 let stderr = String::from_utf8_lossy(&output.stderr);
186 if !stderr.contains("release not found") {
187 anyhow::bail!(
188 "failed to query GitHub release {tag} in {repo}: {}",
189 stderr.trim()
190 );
191 }
192 log::debug!(
193 "assuming no release exists for tag {tag} in repo {repo}; \
194 `gh release view` exited {} with: {}",
195 output.status,
196 stderr.trim(),
197 );
198 };
199
200 let title = rt.read(title);
201 let files = rt.read(files)
202 .into_iter()
203 .map(|(path, label)| {
204 let path = path.to_string_lossy().to_string();
205 if let Some(label) = label {
206 format!("{path}#{label}")
207 } else {
208 path
209 }
210 })
211 .collect::<Vec<_>>();
212 let notes = match notes {
213 GhReleaseNotes::Empty => {
214 vec!["--notes".to_owned(), String::new()]
215 }
216 GhReleaseNotes::Generated => vec!["--generate-notes".to_owned()],
217 GhReleaseNotes::Text(notes) => vec!["--notes".to_owned(), notes],
218 };
219 let draft = draft.then_some("--draft");
220 let verify_tag = verify_tag.then_some("--verify-tag");
221 flowey::shell_cmd!(rt, "{gh_cli} release create {tag} {files...} --repo {repo} --target {target} --title {title} {notes...} {draft...} {verify_tag...}").run()?;
222 }
223
224 Ok(())
225 }
226 });
227
228 Ok(())
229 }
230}