Skip to main content

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}