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
72 changes: 72 additions & 0 deletions src/branching.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
use crate::Context;

impl Context {
/// Let $stable, $beta, $master be the tips of each branch (when starting).
///
/// * Set stable to $beta.
/// * Set beta to $master (ish, look for the version bump).
/// * Create a rust-lang/cargo branch for the appropriate beta commit.
/// * Post a PR against the newly created beta branch bump src/ci/channel to `beta`.
pub fn do_branching(&mut self) -> anyhow::Result<()> {
let mut github = if let Some(github) = self.config.github() {
github
} else {
eprintln!("Skipping branching -- github credentials not configured");
return Ok(());
};
let mut token = github.token("rust-lang/rust")?;
let prebump_sha = token.last_commit_for_file("src/version")?;
let beta_sha = token.get_ref("heads/beta")?;

let stable_version = token.read_file(Some("stable"), "src/version")?;
let beta_version = token.read_file(Some("beta"), "src/version")?;
let future_beta_version = token.read_file(Some(&prebump_sha), "src/version")?;

// Check that we've not already promoted. Rather than trying to assert
// +1 version numbers, we instead have a simpler check that all the
// versions are unique -- before promotion we should have:
//
// * stable @ 1.61.0
// * beta @ 1.62.0
// * prebump @ 1.63.0
//
// and after promotion we will have (if we were to read the files again):
//
// * stable @ 1.62.0
// * beta @ 1.63.0
// * prebump @ 1.63.0
//
// In this state, if we try to promote again, we want to bail out. The
// stable == beta check isn't as useful, but still nice to have.
if stable_version.content()? == beta_version.content()? {
anyhow::bail!(
"Stable and beta have the same version: {}; refusing to promote branches.",
stable_version.content()?.trim()
);
}
if beta_version.content()? == future_beta_version.content()? {
anyhow::bail!(
"Beta and pre-bump master ({}) have the same version: {}; refusing to promote branches.",
prebump_sha,
beta_version.content()?.trim()
);
}

// No need to disable branch protection, as the promote-release app is
// specifically authorized to force-push to these branches.
token.update_ref("heads/stable", &beta_sha, true)?;
token.update_ref("heads/beta", &prebump_sha, true)?;

let cargo_sha = token
.read_file(Some(&prebump_sha), "src/tools/cargo")?
.submodule_sha()
.to_owned();

let mut github = self.config.github().unwrap();
let mut token = github.token("rust-lang/cargo")?;
let new_beta = future_beta_version.content()?.trim().to_owned();
token.create_ref(&dbg!(format!("heads/rust-{}", new_beta)), &cargo_sha)?;

Ok(())
}
}
124 changes: 124 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::discourse::Discourse;
use crate::github::Github;
use crate::Context;
use anyhow::{Context as _, Error};
Expand Down Expand Up @@ -46,7 +47,40 @@ impl std::fmt::Display for Channel {
}
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(crate) enum Action {
/// This is the default action, what we'll do if the environment variable
/// isn't set. It takes the configured channel and pushes artifacts into the
/// appropriate buckets, taking care of other helper tasks along the way.
PromoteRelease,

/// This promotes the branches up a single release:
///
/// Let $stable, $beta, $master be the tips of each branch (when starting).
///
/// * Set stable to $beta.
/// * Set beta to $master (ish, look for the version bump).
/// * Create a rust-lang/cargo branch for the appropriate beta commit.
/// * Post a PR against the newly created beta branch bump src/ci/channel to `beta`.
PromoteBranches,
}

impl FromStr for Action {
type Err = Error;

fn from_str(input: &str) -> Result<Self, Self::Err> {
match input {
"promote-release" => Ok(Action::PromoteRelease),
"promote-branches" => Ok(Action::PromoteBranches),
_ => anyhow::bail!("unknown channel: {}", input),
}
}
}

pub(crate) struct Config {
/// This is the action we're expecting to take.
pub(crate) action: Action,

/// The channel we're currently releasing.
pub(crate) channel: Channel,
/// CloudFront distribution ID for doc.rust-lang.org.
Expand Down Expand Up @@ -113,6 +147,27 @@ pub(crate) struct Config {
/// Should be a org/repo code, e.g., rust-lang/rust.
pub(crate) rustc_tag_repository: Option<String>,

/// Where to publish new blog PRs.
///
/// We create a new PR announcing releases in this repository; currently we
/// don't automatically merge it (but that might change in the future).
///
/// Should be a org/repo code, e.g., rust-lang/blog.rust-lang.org.
pub(crate) blog_repository: Option<String>,

/// The expected release date, for the blog post announcing dev-static
/// releases. Expected to be in YYYY-MM-DD format.
///
/// This is used to produce the expected release date in blog posts and to
/// generate the release notes URL (targeting stable branch on
/// rust-lang/rust).
pub(crate) scheduled_release_date: Option<chrono::NaiveDate>,

/// These are Discourse configurations for where to post dev-static
/// announcements. Currently we only post dev release announcements.
pub(crate) discourse_api_key: Option<String>,
pub(crate) discourse_api_user: Option<String>,

/// This is a github app private key, used for the release steps which
/// require action on GitHub (e.g., kicking off a new thanks GHA build,
/// opening pull requests against the blog for dev releases, promoting
Expand All @@ -131,6 +186,7 @@ pub(crate) struct Config {
impl Config {
pub(crate) fn from_env() -> Result<Self, Error> {
Ok(Self {
action: default_env("ACTION", Action::PromoteRelease)?,
bypass_startup_checks: bool_env("BYPASS_STARTUP_CHECKS")?,
channel: require_env("CHANNEL")?,
cloudfront_doc_id: require_env("CLOUDFRONT_DOC_ID")?,
Expand All @@ -151,6 +207,10 @@ impl Config {
upload_dir: require_env("UPLOAD_DIR")?,
wip_recompress: bool_env("WIP_RECOMPRESS")?,
rustc_tag_repository: maybe_env("RUSTC_TAG_REPOSITORY")?,
blog_repository: maybe_env("BLOG_REPOSITORY")?,
scheduled_release_date: maybe_env("BLOG_SCHEDULED_RELEASE_DATE")?,
discourse_api_user: maybe_env("DISCOURSE_API_USER")?,
discourse_api_key: maybe_env("DISCOURSE_API_KEY")?,
github_app_key: maybe_env("GITHUB_APP_KEY")?,
github_app_id: maybe_env("GITHUB_APP_ID")?,
})
Expand All @@ -163,6 +223,70 @@ impl Config {
None
}
}
pub(crate) fn discourse(&self) -> Option<Discourse> {
if let (Some(key), Some(user)) = (&self.discourse_api_key, &self.discourse_api_user) {
Some(Discourse::new(
"https://internals.rust-lang.org".to_owned(),
user.clone(),
key.clone(),
))
} else {
None
}
}

pub(crate) fn blog_contents(
&self,
release: &str,
archive_date: &str,
for_blog: bool,
internals_url: Option<&str>,
) -> Option<String> {
let scheduled_release_date = self.scheduled_release_date?;
let release_notes_url = format!(
"https://github.com/rust-lang/rust/blob/stable/RELEASES.md#version-{}-{}",
release.replace('.', ""),
scheduled_release_date.format("%Y-%m-%d"),
);
let human_date = scheduled_release_date.format("%B %d");
let internals = internals_url
.map(|url| format!("You can leave feedback on the [internals thread]({url})."))
.unwrap_or_default();
let prefix = if for_blog {
format!(
r#"---
layout: post
title: "{} pre-release testing"
author: Release automation
team: The Release Team <https://www.rust-lang.org/governance/teams/release>
---{}"#,
release, "\n\n",
)
} else {
String::new()
};
Some(format!(
"{prefix}The {release} pre-release is ready for testing. The release is scheduled for
{human_date}. [Release notes can be found here.][relnotes]

You can try it out locally by running:

```plain
RUSTUP_DIST_SERVER=https://dev-static.rust-lang.org rustup update stable
```

The index is <https://dev-static.rust-lang.org/dist/{archive_date}/index.html>.

{internals}

The release team is also thinking about changes to our pre-release process:
we'd love your feedback [on this GitHub issue][feedback].

[relnotes]: {release_notes_url}
[feedback]: https://github.com/rust-lang/release-team/issues/16
"
))
}
}

fn maybe_env<R>(name: &str) -> Result<Option<R>, Error>
Expand Down
68 changes: 68 additions & 0 deletions src/curl_helper.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use anyhow::Context;
use curl::easy::Easy;

pub trait BodyExt {
fn with_body<S>(&mut self, body: S) -> Request<'_, S>;
fn without_body(&mut self) -> Request<'_, ()>;
}

impl BodyExt for Easy {
fn with_body<S>(&mut self, body: S) -> Request<'_, S> {
Request {
body: Some(body),
client: self,
}
}
fn without_body(&mut self) -> Request<'_, ()> {
Request {
body: None,
client: self,
}
}
}

pub struct Request<'a, S> {
body: Option<S>,
client: &'a mut Easy,
}

impl<S: serde::Serialize> Request<'_, S> {
pub fn send_with_response<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T> {
use std::io::Read;
let mut response = Vec::new();
let body = self.body.map(|body| serde_json::to_vec(&body).unwrap());
{
let mut transfer = self.client.transfer();
// The unwrap in the read_function is basically guaranteed to not
// happen: reading into a slice can't fail. We can't use `?` since the
// return type inside transfer isn't compatible with io::Error.
if let Some(mut body) = body.as_deref() {
transfer.read_function(move |dest| Ok(body.read(dest).unwrap()))?;
}
transfer.write_function(|new_data| {
response.extend_from_slice(new_data);
Ok(new_data.len())
})?;
transfer.perform()?;
}
serde_json::from_slice(&response)
.with_context(|| format!("{}", String::from_utf8_lossy(&response)))
}

pub fn send(self) -> anyhow::Result<()> {
use std::io::Read;
let body = self.body.map(|body| serde_json::to_vec(&body).unwrap());
{
let mut transfer = self.client.transfer();
// The unwrap in the read_function is basically guaranteed to not
// happen: reading into a slice can't fail. We can't use `?` since the
// return type inside transfer isn't compatible with io::Error.
if let Some(mut body) = body.as_deref() {
transfer.read_function(move |dest| Ok(body.read(dest).unwrap()))?;
}
transfer.perform()?;
}

Ok(())
}
}
69 changes: 69 additions & 0 deletions src/discourse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use crate::curl_helper::BodyExt;
use curl::easy::Easy;

pub struct Discourse {
root: String,
api_key: String,
api_username: String,
client: Easy,
}

impl Discourse {
pub fn new(root: String, api_username: String, api_key: String) -> Discourse {
Discourse {
root,
api_key,
api_username,
client: Easy::new(),
}
}

fn start_new_request(&mut self) -> anyhow::Result<()> {
self.client.reset();
self.client.useragent("rust-lang/promote-release")?;
let mut headers = curl::easy::List::new();
headers.append(&format!("Api-Key: {}", self.api_key))?;
headers.append(&format!("Api-Username: {}", self.api_username))?;
headers.append("Content-Type: application/json")?;
self.client.http_headers(headers)?;
Ok(())
}

/// Returns a URL to the topic
pub fn create_topic(
&mut self,
category: u32,
title: &str,
body: &str,
) -> anyhow::Result<String> {
#[derive(serde::Serialize)]
struct Request<'a> {
title: &'a str,
#[serde(rename = "raw")]
body: &'a str,
category: u32,
archetype: &'a str,
}
#[derive(serde::Deserialize)]
struct Response {
topic_id: u32,
topic_slug: String,
}
self.start_new_request()?;
self.client.post(true)?;
self.client.url(&format!("{}/posts.json", self.root))?;
let resp = self
.client
.with_body(Request {
title,
body,
category,
archetype: "regular",
})
.send_with_response::<Response>()?;
Ok(format!(
"{}/t/{}/{}",
self.root, resp.topic_slug, resp.topic_id
))
}
}
Loading