diff --git a/flowey/flowey_cli/src/pipeline_resolver/github_yaml/github_yaml_defs.rs b/flowey/flowey_cli/src/pipeline_resolver/github_yaml/github_yaml_defs.rs index f51880a695..55d2ac8417 100644 --- a/flowey/flowey_cli/src/pipeline_resolver/github_yaml/github_yaml_defs.rs +++ b/flowey/flowey_cli/src/pipeline_resolver/github_yaml/github_yaml_defs.rs @@ -131,6 +131,7 @@ pub enum PermissionValue { #[serde(rename_all = "kebab-case")] pub enum Permissions { Actions, + ArtifactMetadata, Attestations, Checks, Contents, diff --git a/flowey/flowey_cli/src/pipeline_resolver/github_yaml/mod.rs b/flowey/flowey_cli/src/pipeline_resolver/github_yaml/mod.rs index 5fa7f1501b..1641e8869a 100644 --- a/flowey/flowey_cli/src/pipeline_resolver/github_yaml/mod.rs +++ b/flowey/flowey_cli/src/pipeline_resolver/github_yaml/mod.rs @@ -576,6 +576,7 @@ EOF let perm_kind_to_yaml = |permission: &GhPermission| match permission { GhPermission::Actions => github_yaml_defs::Permissions::Actions, + GhPermission::ArtifactMetadata => github_yaml_defs::Permissions::ArtifactMetadata, GhPermission::Attestations => github_yaml_defs::Permissions::Attestations, GhPermission::Checks => github_yaml_defs::Permissions::Checks, GhPermission::Contents => github_yaml_defs::Permissions::Contents, diff --git a/flowey/flowey_core/src/node.rs b/flowey/flowey_core/src/node.rs index 53b71326d0..1bb26e8835 100644 --- a/flowey/flowey_core/src/node.rs +++ b/flowey/flowey_core/src/node.rs @@ -2234,6 +2234,7 @@ pub mod steps { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum GhPermission { Actions, + ArtifactMetadata, Attestations, Checks, Contents, diff --git a/flowey/flowey_lib_common/src/attest_build_provenance.rs b/flowey/flowey_lib_common/src/attest_build_provenance.rs new file mode 100644 index 0000000000..502c72df69 --- /dev/null +++ b/flowey/flowey_lib_common/src/attest_build_provenance.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Generate GitHub build provenance attestations for a set of files. + +use flowey::node::prelude::*; + +flowey_request! { + pub struct Request { + pub files: ReadVar)>>, + pub done: WriteVar, + } +} + +new_simple_flow_node!(struct Node); + +impl SimpleFlowNode for Node { + type Request = Request; + + fn imports(_ctx: &mut ImportCtx<'_>) {} + + fn process_request(request: Self::Request, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> { + let Request { files, done } = request; + let subject_paths = files.map(ctx, |files| { + files + .into_iter() + .map(|(path, _)| path.to_string_lossy().into_owned()) + .collect::>() + .join("\n") + }); + + let attested = if matches!(ctx.backend(), FlowBackend::Github) { + ctx.emit_gh_step("Attest release artifacts", "actions/attest@v4") + .with("subject-path", subject_paths) + .requires_permission(GhPermission::Contents, GhPermissionValue::Read) + .requires_permission(GhPermission::IdToken, GhPermissionValue::Write) + .requires_permission(GhPermission::Attestations, GhPermissionValue::Write) + .requires_permission(GhPermission::ArtifactMetadata, GhPermissionValue::Write) + .finish(ctx) + } else { + ctx.emit_rust_step("(stub) attest release artifacts", |ctx| { + subject_paths.claim(ctx); + |_rt| { + log::warn!("not running in GitHub Actions, so no attestation was generated"); + Ok(()) + } + }) + }; + + ctx.emit_side_effect_step([attested], [done]); + Ok(()) + } +} diff --git a/flowey/flowey_lib_common/src/lib.rs b/flowey/flowey_lib_common/src/lib.rs index aad40319f4..ddf7f6710d 100644 --- a/flowey/flowey_lib_common/src/lib.rs +++ b/flowey/flowey_lib_common/src/lib.rs @@ -14,6 +14,7 @@ pub mod ado_task_azure_key_vault; pub mod ado_task_npm_authenticate; pub mod ado_task_nuget_authenticate; pub mod ado_task_publish_test_results; +pub mod attest_build_provenance; pub mod cache; pub mod cfg_cargo_common_flags; pub mod cfg_persistent_dir_cargo_install; diff --git a/flowey/flowey_lib_common/src/publish_gh_release.rs b/flowey/flowey_lib_common/src/publish_gh_release.rs index 3a297e843f..966d37abaa 100644 --- a/flowey/flowey_lib_common/src/publish_gh_release.rs +++ b/flowey/flowey_lib_common/src/publish_gh_release.rs @@ -9,6 +9,30 @@ flowey_request! { pub struct Request(pub GhReleaseParams); } +#[derive(Serialize, Deserialize)] +pub enum GhReleaseNotes { + /// Create the release noninteractively with an empty body. + Empty, + Generated, + Text(String), +} + +/// What to do when a release already exists for the tag being published. +#[derive(Serialize, Deserialize)] +pub enum OnExistingRelease { + /// Leave it alone and report success. + /// + /// Suits a release whose tag comes from a version in the tree, where + /// rerunning on an unchanged version is routine and means nothing is + /// wrong. + Skip, + /// Fail. + /// + /// Assets are never replaced automatically, because the existing release + /// may already have been reviewed or published. + Fail, +} + #[derive(Serialize, Deserialize)] pub struct GhReleaseParams { /// First component of a github repo path @@ -27,8 +51,14 @@ pub struct GhReleaseParams { pub title: ReadVar, /// Files to upload. pub files: ReadVar)>, C>, + /// Release notes to attach to the release. + pub notes: GhReleaseNotes, /// Whether the release should be created as a draft pub draft: bool, + /// What to do when a release already exists for this tag. + pub on_existing: OnExistingRelease, + /// Side effects that must complete before the release is published. + pub prerequisites: Vec>, pub done: WriteVar, } @@ -42,7 +72,10 @@ impl GhReleaseParams { tag, title, files, + notes, draft, + on_existing, + prerequisites, done, } = self; @@ -53,7 +86,10 @@ impl GhReleaseParams { tag: tag.claim(ctx), title: title.claim(ctx), files: files.claim(ctx), + notes, draft, + on_existing, + prerequisites: prerequisites.claim(ctx), done: done.claim(ctx), } } @@ -94,30 +130,66 @@ impl FlowNode for Node { tag, title, files, + notes, draft, + on_existing, + prerequisites, done: _, } = req; + for prerequisite in prerequisites { + rt.read(prerequisite); + } + let repo = format!("{repo_owner}/{repo_name}"); let target = rt.read(target); let tag = rt.read(tag); - // check if the release already exists + // Check if the release already exists. // - // xshell doesn't give us the exit code, so we have to - // use the raw process API instead. - let mut command = std::process::Command::new(&gh_cli); - command - .arg("release").arg("view").arg(&tag).arg("--repo").arg(&repo); - let mut child = command.spawn().context( - "failed to spawn gh cli" - )?; - let status = child.wait()?; - - // success means the release already exists, so skip publishing this release - if status.success() { - log::info!("GitHub release with tag {tag} already exists in repo {repo}. Skipping..."); - continue; + // Capture the output rather than letting it inherit. On the + // ordinary path there is no release yet, so `gh` writes + // "release not found", which is a confusing thing to find in + // the log of a run that went on to publish successfully. It + // is still logged when the command fails for some other + // reason -- an auth failure or a 5xx also exit non-zero, and + // are indistinguishable from "not found" without it. + let output = + flowey::shell_cmd!(rt, "{gh_cli} release view {tag} --repo {repo}") + .ignore_status() + .output() + .context("failed to run gh cli")?; + + // Success means the release already exists. + if output.status.success() { + match on_existing { + OnExistingRelease::Skip => { + log::info!("GitHub release with tag {tag} already exists in repo {repo}. Skipping..."); + continue; + } + OnExistingRelease::Fail => { + anyhow::bail!( + "a GitHub release already exists for tag {tag} in repo \ + {repo}. Its assets are not replaced automatically, since \ + the existing release may already have been reviewed or \ + published. Delete it and rerun if it should be regenerated." + ); + } + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("release not found") { + anyhow::bail!( + "failed to query GitHub release {tag} in {repo}: {}", + stderr.trim() + ); + } + log::debug!( + "assuming no release exists for tag {tag} in repo {repo}; \ + `gh release view` exited {} with: {}", + output.status, + stderr.trim(), + ); }; let title = rt.read(title); @@ -132,9 +204,15 @@ impl FlowNode for Node { } }) .collect::>(); + let notes = match notes { + GhReleaseNotes::Empty => { + vec!["--notes".to_owned(), String::new()] + } + GhReleaseNotes::Generated => vec!["--generate-notes".to_owned()], + GhReleaseNotes::Text(notes) => vec!["--notes".to_owned(), notes], + }; let draft = draft.then_some("--draft"); - - flowey::shell_cmd!(rt, "{gh_cli} release create --repo {repo} --target {target} {tag} --title {title} --notes TODO {draft...} {files...}").run()?; + flowey::shell_cmd!(rt, "{gh_cli} release create {tag} {files...} --repo {repo} --target {target} --title {title} {notes...} {draft...}").run()?; } Ok(()) diff --git a/flowey/flowey_lib_hvlite/src/_jobs/publish_vmgstool_gh_release.rs b/flowey/flowey_lib_hvlite/src/_jobs/publish_vmgstool_gh_release.rs index 398ac5c1c6..acebef5ecf 100644 --- a/flowey/flowey_lib_hvlite/src/_jobs/publish_vmgstool_gh_release.rs +++ b/flowey/flowey_lib_hvlite/src/_jobs/publish_vmgstool_gh_release.rs @@ -94,7 +94,13 @@ impl SimpleFlowNode for Node { tag, title, files, + notes: flowey_lib_common::publish_gh_release::GhReleaseNotes::Text("TODO".into()), draft: true, + // This job runs on every push to main, but the tag only + // changes when the version in the tree does, so an existing + // release is the normal steady state rather than a problem. + on_existing: flowey_lib_common::publish_gh_release::OnExistingRelease::Skip, + prerequisites: Vec::new(), done, }, ));