Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ pub enum PermissionValue {
#[serde(rename_all = "kebab-case")]
pub enum Permissions {
Actions,
ArtifactMetadata,
Attestations,
Checks,
Contents,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions flowey/flowey_core/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2234,6 +2234,7 @@ pub mod steps {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum GhPermission {
Actions,
ArtifactMetadata,
Attestations,
Checks,
Contents,
Expand Down
53 changes: 53 additions & 0 deletions flowey/flowey_lib_common/src/attest_build_provenance.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<(PathBuf, Option<String>)>>,
pub done: WriteVar<SideEffect>,
}
}

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::<Vec<_>>()
.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(())
}
}
1 change: 1 addition & 0 deletions flowey/flowey_lib_common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
112 changes: 95 additions & 17 deletions flowey/flowey_lib_common/src/publish_gh_release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<C = VarNotClaimed> {
/// First component of a github repo path
Expand All @@ -27,8 +51,14 @@ pub struct GhReleaseParams<C = VarNotClaimed> {
pub title: ReadVar<String, C>,
/// Files to upload.
pub files: ReadVar<Vec<(PathBuf, Option<String>)>, 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<ReadVar<SideEffect, C>>,

pub done: WriteVar<SideEffect, C>,
}
Expand All @@ -42,7 +72,10 @@ impl GhReleaseParams {
tag,
title,
files,
notes,
draft,
on_existing,
prerequisites,
done,
} = self;

Expand All @@ -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),
}
}
Expand Down Expand Up @@ -94,30 +130,66 @@ impl FlowNode for Node {
tag,
title,
files,
notes,
draft,
on_existing,
prerequisites,
done: _,
} = req;
Comment thread
benhillis marked this conversation as resolved.

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);
Expand All @@ -132,9 +204,15 @@ impl FlowNode for Node {
}
})
.collect::<Vec<_>>();
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(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,13 @@ impl SimpleFlowNode for Node {
tag,
title,
files,
notes: flowey_lib_common::publish_gh_release::GhReleaseNotes::Text("TODO".into()),
Comment thread
benhillis marked this conversation as resolved.
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,
},
));
Expand Down
Loading