Browse Source

Add GitHubReleasePlugin with createGitHubRelease task

Closes gh-10456
Closes gh-10457
Steve Riesenberg 3 years ago
parent
commit
80e39e9343

+ 8 - 0
build.gradle

@@ -22,6 +22,7 @@ apply plugin: 'org.springframework.security.update-dependencies'
 apply plugin: 'org.springframework.security.sagan'
 apply plugin: 'org.springframework.github.milestone'
 apply plugin: 'org.springframework.github.changelog'
+apply plugin: 'org.springframework.github.release'
 
 group = 'org.springframework.security'
 description = 'Spring Security'
@@ -46,6 +47,13 @@ tasks.named("gitHubCheckMilestoneHasNoOpenIssues") {
 	}
 }
 
+tasks.named("createGitHubRelease") {
+	repository {
+		owner = "spring-projects"
+		name = "spring-security"
+	}
+}
+
 tasks.named("updateDependencies") {
 	// we aren't Gradle 7 compatible yet
 	checkForGradleUpdate = false

+ 4 - 0
buildSrc/build.gradle

@@ -54,6 +54,10 @@ gradlePlugin {
 			id = "org.springframework.github.changelog"
 			implementationClass = "org.springframework.gradle.github.changelog.GitHubChangelogPlugin"
 		}
+		githubRelease {
+			id = "org.springframework.github.release"
+			implementationClass = "org.springframework.gradle.github.release.GitHubReleasePlugin"
+		}
 		s101 {
 			id = "s101"
 			implementationClass = "s101.S101Plugin"

+ 3 - 3
buildSrc/src/main/java/org/springframework/gradle/github/milestones/RepositoryRef.java → buildSrc/src/main/java/org/springframework/gradle/github/RepositoryRef.java

@@ -1,10 +1,11 @@
-package org.springframework.gradle.github.milestones;
+package org.springframework.gradle.github;
+
 public class RepositoryRef {
 	private String owner;
 
 	private String name;
 
-	RepositoryRef() {
+	public RepositoryRef() {
 	}
 
 	public RepositoryRef(String owner, String name) {
@@ -62,4 +63,3 @@ public class RepositoryRef {
 		}
 	}
 }
-

+ 5 - 4
buildSrc/src/main/java/org/springframework/gradle/github/changelog/GitHubChangelogPlugin.java

@@ -16,6 +16,9 @@
 
 package org.springframework.gradle.github.changelog;
 
+import java.io.File;
+import java.nio.file.Paths;
+
 import org.gradle.api.Action;
 import org.gradle.api.Plugin;
 import org.gradle.api.Project;
@@ -28,12 +31,10 @@ import org.gradle.api.artifacts.repositories.IvyArtifactRepository;
 import org.gradle.api.artifacts.repositories.IvyPatternRepositoryLayout;
 import org.gradle.api.tasks.JavaExec;
 
-import java.io.File;
-import java.nio.file.Paths;
-
 public class GitHubChangelogPlugin implements Plugin<Project> {
 
 	public static final String CHANGELOG_GENERATOR_CONFIGURATION_NAME = "changelogGenerator";
+	public static final String RELEASE_NOTES_PATH = "changelog/release-notes.md";
 
 	@Override
 	public void apply(Project project) {
@@ -42,7 +43,7 @@ public class GitHubChangelogPlugin implements Plugin<Project> {
 		project.getTasks().register("generateChangelog", JavaExec.class, new Action<JavaExec>() {
 			@Override
 			public void execute(JavaExec generateChangelog) {
-				File outputFile = project.file(Paths.get(project.getBuildDir().getPath(), "changelog/release-notes.md"));
+				File outputFile = project.file(Paths.get(project.getBuildDir().getPath(), RELEASE_NOTES_PATH));
 				outputFile.getParentFile().mkdirs();
 				generateChangelog.setGroup("Release");
 				generateChangelog.setDescription("Generates the changelog");

+ 4 - 2
buildSrc/src/main/java/org/springframework/gradle/github/milestones/GitHubMilestoneApi.java

@@ -16,6 +16,9 @@
 
 package org.springframework.gradle.github.milestones;
 
+import java.io.IOException;
+import java.util.List;
+
 import com.google.common.reflect.TypeToken;
 import com.google.gson.Gson;
 import okhttp3.Interceptor;
@@ -23,8 +26,7 @@ import okhttp3.OkHttpClient;
 import okhttp3.Request;
 import okhttp3.Response;
 
-import java.io.IOException;
-import java.util.List;
+import org.springframework.gradle.github.RepositoryRef;
 
 public class GitHubMilestoneApi {
 	private String baseUrl = "https://api.github.com";

+ 2 - 0
buildSrc/src/main/java/org/springframework/gradle/github/milestones/GitHubMilestoneHasNoOpenIssuesTask.java

@@ -21,6 +21,8 @@ import org.gradle.api.tasks.Input;
 import org.gradle.api.tasks.Optional;
 import org.gradle.api.tasks.TaskAction;
 
+import org.springframework.gradle.github.RepositoryRef;
+
 public class GitHubMilestoneHasNoOpenIssuesTask extends DefaultTask {
 	@Input
 	private RepositoryRef repository = new RepositoryRef();

+ 130 - 0
buildSrc/src/main/java/org/springframework/gradle/github/release/CreateGitHubReleaseTask.java

@@ -0,0 +1,130 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.gradle.github.release;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.gradle.api.Action;
+import org.gradle.api.DefaultTask;
+import org.gradle.api.Project;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.TaskAction;
+
+import org.springframework.gradle.github.RepositoryRef;
+import org.springframework.gradle.github.changelog.GitHubChangelogPlugin;
+
+/**
+ * @author Steve Riesenberg
+ */
+public class CreateGitHubReleaseTask extends DefaultTask {
+	@Input
+	private RepositoryRef repository = new RepositoryRef();
+
+	@Input @Optional
+	private String gitHubAccessToken;
+
+	@Input
+	private String version;
+
+	@Input @Optional
+	private String branch = "main";
+
+	@Input
+	private boolean createRelease = false;
+
+	@TaskAction
+	public void createGitHubRelease() {
+		String body = readReleaseNotes();
+		Release release = Release.tag(this.version)
+				.commit(this.branch)
+				.name(this.version)
+				.body(body)
+				.preRelease(this.version.contains("-"))
+				.build();
+
+		System.out.printf("%sCreating GitHub release for %s/%s@%s\n",
+				this.createRelease ? "" : "[DRY RUN] ",
+				this.repository.getOwner(),
+				this.repository.getName(),
+				this.version
+		);
+		System.out.printf("  Release Notes:\n\n----\n%s\n----\n\n", body.trim());
+
+		if (this.createRelease) {
+			GitHubReleaseApi github = new GitHubReleaseApi(this.gitHubAccessToken);
+			github.publishRelease(this.repository, release);
+		}
+	}
+
+	private String readReleaseNotes() {
+		Project project = getProject();
+		File inputFile = project.file(Paths.get(project.getBuildDir().getPath(), GitHubChangelogPlugin.RELEASE_NOTES_PATH));
+		try {
+			return Files.readString(inputFile.toPath());
+		} catch (IOException ex) {
+			throw new RuntimeException("Unable to read release notes from " + inputFile, ex);
+		}
+	}
+
+	public RepositoryRef getRepository() {
+		return repository;
+	}
+
+	public void repository(Action<RepositoryRef> repository) {
+		repository.execute(this.repository);
+	}
+
+	public void setRepository(RepositoryRef repository) {
+		this.repository = repository;
+	}
+
+	public String getGitHubAccessToken() {
+		return gitHubAccessToken;
+	}
+
+	public void setGitHubAccessToken(String gitHubAccessToken) {
+		this.gitHubAccessToken = gitHubAccessToken;
+	}
+
+	public String getVersion() {
+		return version;
+	}
+
+	public void setVersion(String version) {
+		this.version = version;
+	}
+
+	public String getBranch() {
+		return branch;
+	}
+
+	public void setBranch(String branch) {
+		this.branch = branch;
+	}
+
+	public boolean isCreateRelease() {
+		return createRelease;
+	}
+
+	public void setCreateRelease(boolean createRelease) {
+		this.createRelease = createRelease;
+	}
+}

+ 91 - 0
buildSrc/src/main/java/org/springframework/gradle/github/release/GitHubReleaseApi.java

@@ -0,0 +1,91 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.gradle.github.release;
+
+import java.io.IOException;
+
+import com.google.gson.Gson;
+import okhttp3.Interceptor;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+import org.springframework.gradle.github.RepositoryRef;
+
+/**
+ * Manage GitHub releases.
+ *
+ * @author Steve Riesenberg
+ */
+public class GitHubReleaseApi {
+	private String baseUrl = "https://api.github.com";
+
+	private final OkHttpClient httpClient;
+	private Gson gson = new Gson();
+
+	public GitHubReleaseApi(String gitHubAccessToken) {
+		this.httpClient = new OkHttpClient.Builder()
+				.addInterceptor(new AuthorizationInterceptor(gitHubAccessToken))
+				.build();
+	}
+
+	public void setBaseUrl(String baseUrl) {
+		this.baseUrl = baseUrl;
+	}
+
+	/**
+	 * Publish a release with no binary attachments.
+	 *
+	 * @param repository The repository owner/name
+	 * @param release The contents of the release
+	 */
+	public void publishRelease(RepositoryRef repository, Release release) {
+		String url = this.baseUrl + "/repos/" + repository.getOwner() + "/" + repository.getName() + "/releases";
+		String json = this.gson.toJson(release);
+		RequestBody body = RequestBody.create(MediaType.parse("application/json"), json);
+		Request request = new Request.Builder().url(url).post(body).build();
+		try {
+			Response response = this.httpClient.newCall(request).execute();
+			if (!response.isSuccessful()) {
+				throw new RuntimeException(String.format("Could not create release %s for repository %s/%s. Got response %s",
+						release.getName(), repository.getOwner(), repository.getName(), response));
+			}
+		} catch (IOException ex) {
+			throw new RuntimeException(String.format("Could not create release %s for repository %s/%s",
+					release.getName(), repository.getOwner(), repository.getName()), ex);
+		}
+	}
+
+	private static class AuthorizationInterceptor implements Interceptor {
+		private final String token;
+
+		public AuthorizationInterceptor(String token) {
+			this.token = token;
+		}
+
+		@Override
+		public Response intercept(Chain chain) throws IOException {
+			Request request = chain.request().newBuilder()
+					.addHeader("Authorization", "Bearer " + this.token)
+					.build();
+
+			return chain.proceed(request);
+		}
+	}
+}

+ 49 - 0
buildSrc/src/main/java/org/springframework/gradle/github/release/GitHubReleasePlugin.java

@@ -0,0 +1,49 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.gradle.github.release;
+
+import groovy.lang.MissingPropertyException;
+import org.gradle.api.Action;
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+
+/**
+ * @author Steve Riesenberg
+ */
+public class GitHubReleasePlugin implements Plugin<Project> {
+	@Override
+	public void apply(Project project) {
+		project.getTasks().register("createGitHubRelease", CreateGitHubReleaseTask.class, new Action<CreateGitHubReleaseTask>() {
+			@Override
+			public void execute(CreateGitHubReleaseTask createGitHubRelease) {
+				createGitHubRelease.setGroup("Release");
+				createGitHubRelease.setDescription("Create a github release");
+				createGitHubRelease.dependsOn("generateChangelog");
+
+				createGitHubRelease.setCreateRelease("true".equals(project.findProperty("createRelease")));
+				createGitHubRelease.setVersion((String) project.findProperty("nextVersion"));
+				if (project.hasProperty("branch")) {
+					createGitHubRelease.setBranch((String) project.findProperty("branch"));
+				}
+				createGitHubRelease.setGitHubAccessToken((String) project.findProperty("gitHubAccessToken"));
+				if (createGitHubRelease.isCreateRelease() && createGitHubRelease.getGitHubAccessToken() == null) {
+					throw new MissingPropertyException("Please provide an access token with -PgitHubAccessToken=...");
+				}
+			}
+		});
+	}
+}

+ 156 - 0
buildSrc/src/main/java/org/springframework/gradle/github/release/Release.java

@@ -0,0 +1,156 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.gradle.github.release;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Steve Riesenberg
+ */
+public class Release {
+	@SerializedName("tag_name")
+	private final String tag;
+
+	@SerializedName("target_commitish")
+	private final String commit;
+
+	@SerializedName("name")
+	private final String name;
+
+	@SerializedName("body")
+	private final String body;
+
+	@SerializedName("draft")
+	private final boolean draft;
+
+	@SerializedName("prerelease")
+	private final boolean preRelease;
+
+	@SerializedName("generate_release_notes")
+	private final boolean generateReleaseNotes;
+
+	private Release(String tag, String commit, String name, String body, boolean draft, boolean preRelease, boolean generateReleaseNotes) {
+		this.tag = tag;
+		this.commit = commit;
+		this.name = name;
+		this.body = body;
+		this.draft = draft;
+		this.preRelease = preRelease;
+		this.generateReleaseNotes = generateReleaseNotes;
+	}
+
+	public String getTag() {
+		return tag;
+	}
+
+	public String getCommit() {
+		return commit;
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public String getBody() {
+		return body;
+	}
+
+	public boolean isDraft() {
+		return draft;
+	}
+
+	public boolean isPreRelease() {
+		return preRelease;
+	}
+
+	public boolean isGenerateReleaseNotes() {
+		return generateReleaseNotes;
+	}
+
+	@Override
+	public String toString() {
+		return "Release{" +
+				"tag='" + tag + '\'' +
+				", commit='" + commit + '\'' +
+				", name='" + name + '\'' +
+				", body='" + body + '\'' +
+				", draft=" + draft +
+				", preRelease=" + preRelease +
+				", generateReleaseNotes=" + generateReleaseNotes +
+				'}';
+	}
+
+	public static Builder tag(String tag) {
+		return new Builder().tag(tag);
+	}
+
+	public static Builder commit(String commit) {
+		return new Builder().commit(commit);
+	}
+
+	public static final class Builder {
+		private String tag;
+		private String commit;
+		private String name;
+		private String body;
+		private boolean draft;
+		private boolean preRelease;
+		private boolean generateReleaseNotes;
+
+		private Builder() {
+		}
+
+		public Builder tag(String tag) {
+			this.tag = tag;
+			return this;
+		}
+
+		public Builder commit(String commit) {
+			this.commit = commit;
+			return this;
+		}
+
+		public Builder name(String name) {
+			this.name = name;
+			return this;
+		}
+
+		public Builder body(String body) {
+			this.body = body;
+			return this;
+		}
+
+		public Builder draft(boolean draft) {
+			this.draft = draft;
+			return this;
+		}
+
+		public Builder preRelease(boolean preRelease) {
+			this.preRelease = preRelease;
+			return this;
+		}
+
+		public Builder generateReleaseNotes(boolean generateReleaseNotes) {
+			this.generateReleaseNotes = generateReleaseNotes;
+			return this;
+		}
+
+		public Release build() {
+			return new Release(tag, commit, name, body, draft, preRelease, generateReleaseNotes);
+		}
+	}
+}

+ 4 - 3
buildSrc/src/test/java/io/spring/gradle/github/milestones/GitHubMilestoneApiTests.java

@@ -1,15 +1,16 @@
 package io.spring.gradle.github.milestones;
 
+import java.util.concurrent.TimeUnit;
+
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.springframework.gradle.github.milestones.GitHubMilestoneApi;
-import org.springframework.gradle.github.milestones.RepositoryRef;
 
-import java.util.concurrent.TimeUnit;
+import org.springframework.gradle.github.RepositoryRef;
+import org.springframework.gradle.github.milestones.GitHubMilestoneApi;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;

+ 3 - 1
buildSrc/src/test/java/org/springframework/gradle/github/milestones/GitHubMilestoneApiTests.java

@@ -1,5 +1,7 @@
 package org.springframework.gradle.github.milestones;
 
+import java.util.concurrent.TimeUnit;
+
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
@@ -7,7 +9,7 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-import java.util.concurrent.TimeUnit;
+import org.springframework.gradle.github.RepositoryRef;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;

+ 151 - 0
buildSrc/src/test/java/org/springframework/gradle/github/release/GitHubReleaseApiTests.java

@@ -0,0 +1,151 @@
+/*
+ * Copyright 2002-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.gradle.github.release;
+
+import java.util.concurrent.TimeUnit;
+
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.Test;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import org.springframework.gradle.github.RepositoryRef;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * @author Steve Riesenberg
+ */
+public class GitHubReleaseApiTests {
+	private GitHubReleaseApi github;
+
+	private RepositoryRef repository = new RepositoryRef("spring-projects", "spring-security");
+
+	private MockWebServer server;
+
+	private String baseUrl;
+
+	@BeforeEach
+	public void setup() throws Exception {
+		this.server = new MockWebServer();
+		this.server.start();
+		this.github = new GitHubReleaseApi("mock-oauth-token");
+		this.baseUrl = this.server.url("/api").toString();
+		this.github.setBaseUrl(this.baseUrl);
+	}
+
+	@AfterEach
+	public void cleanup() throws Exception {
+		this.server.shutdown();
+	}
+
+	@Test
+	public void publishReleaseWhenValidParametersThenSuccess() throws Exception {
+		String responseJson = "{\n" +
+				"  \"url\": \"https://api.github.com/spring-projects/spring-security/releases/1\",\n" +
+				"  \"html_url\": \"https://github.com/spring-projects/spring-security/releases/tags/v1.0.0\",\n" +
+				"  \"assets_url\": \"https://api.github.com/spring-projects/spring-security/releases/1/assets\",\n" +
+				"  \"upload_url\": \"https://uploads.github.com/spring-projects/spring-security/releases/1/assets{?name,label}\",\n" +
+				"  \"tarball_url\": \"https://api.github.com/spring-projects/spring-security/tarball/v1.0.0\",\n" +
+				"  \"zipball_url\": \"https://api.github.com/spring-projects/spring-security/zipball/v1.0.0\",\n" +
+				"  \"discussion_url\": \"https://github.com/spring-projects/spring-security/discussions/90\",\n" +
+				"  \"id\": 1,\n" +
+				"  \"node_id\": \"MDc6UmVsZWFzZTE=\",\n" +
+				"  \"tag_name\": \"v1.0.0\",\n" +
+				"  \"target_commitish\": \"main\",\n" +
+				"  \"name\": \"v1.0.0\",\n" +
+				"  \"body\": \"Description of the release\",\n" +
+				"  \"draft\": false,\n" +
+				"  \"prerelease\": false,\n" +
+				"  \"created_at\": \"2013-02-27T19:35:32Z\",\n" +
+				"  \"published_at\": \"2013-02-27T19:35:32Z\",\n" +
+				"  \"author\": {\n" +
+				"    \"login\": \"sjohnr\",\n" +
+				"    \"id\": 1,\n" +
+				"    \"node_id\": \"MDQ6VXNlcjE=\",\n" +
+				"    \"avatar_url\": \"https://github.com/images/avatar.gif\",\n" +
+				"    \"gravatar_id\": \"\",\n" +
+				"    \"url\": \"https://api.github.com/users/sjohnr\",\n" +
+				"    \"html_url\": \"https://github.com/sjohnr\",\n" +
+				"    \"followers_url\": \"https://api.github.com/users/sjohnr/followers\",\n" +
+				"    \"following_url\": \"https://api.github.com/users/sjohnr/following{/other_user}\",\n" +
+				"    \"gists_url\": \"https://api.github.com/users/sjohnr/gists{/gist_id}\",\n" +
+				"    \"starred_url\": \"https://api.github.com/users/sjohnr/starred{/owner}{/repo}\",\n" +
+				"    \"subscriptions_url\": \"https://api.github.com/users/sjohnr/subscriptions\",\n" +
+				"    \"organizations_url\": \"https://api.github.com/users/sjohnr/orgs\",\n" +
+				"    \"repos_url\": \"https://api.github.com/users/sjohnr/repos\",\n" +
+				"    \"events_url\": \"https://api.github.com/users/sjohnr/events{/privacy}\",\n" +
+				"    \"received_events_url\": \"https://api.github.com/users/sjohnr/received_events\",\n" +
+				"    \"type\": \"User\",\n" +
+				"    \"site_admin\": false\n" +
+				"  },\n" +
+				"  \"assets\": [\n" +
+				"    {\n" +
+				"      \"url\": \"https://api.github.com/spring-projects/spring-security/releases/assets/1\",\n" +
+				"      \"browser_download_url\": \"https://github.com/spring-projects/spring-security/releases/download/v1.0.0/example.zip\",\n" +
+				"      \"id\": 1,\n" +
+				"      \"node_id\": \"MDEyOlJlbGVhc2VBc3NldDE=\",\n" +
+				"      \"name\": \"example.zip\",\n" +
+				"      \"label\": \"short description\",\n" +
+				"      \"state\": \"uploaded\",\n" +
+				"      \"content_type\": \"application/zip\",\n" +
+				"      \"size\": 1024,\n" +
+				"      \"download_count\": 42,\n" +
+				"      \"created_at\": \"2013-02-27T19:35:32Z\",\n" +
+				"      \"updated_at\": \"2013-02-27T19:35:32Z\",\n" +
+				"      \"uploader\": {\n" +
+				"        \"login\": \"sjohnr\",\n" +
+				"        \"id\": 1,\n" +
+				"        \"node_id\": \"MDQ6VXNlcjE=\",\n" +
+				"        \"avatar_url\": \"https://github.com/images/avatar.gif\",\n" +
+				"        \"gravatar_id\": \"\",\n" +
+				"        \"url\": \"https://api.github.com/users/sjohnr\",\n" +
+				"        \"html_url\": \"https://github.com/sjohnr\",\n" +
+				"        \"followers_url\": \"https://api.github.com/users/sjohnr/followers\",\n" +
+				"        \"following_url\": \"https://api.github.com/users/sjohnr/following{/other_user}\",\n" +
+				"        \"gists_url\": \"https://api.github.com/users/sjohnr/gists{/gist_id}\",\n" +
+				"        \"starred_url\": \"https://api.github.com/users/sjohnr/starred{/owner}{/repo}\",\n" +
+				"        \"subscriptions_url\": \"https://api.github.com/users/sjohnr/subscriptions\",\n" +
+				"        \"organizations_url\": \"https://api.github.com/users/sjohnr/orgs\",\n" +
+				"        \"repos_url\": \"https://api.github.com/users/sjohnr/repos\",\n" +
+				"        \"events_url\": \"https://api.github.com/users/sjohnr/events{/privacy}\",\n" +
+				"        \"received_events_url\": \"https://api.github.com/users/sjohnr/received_events\",\n" +
+				"        \"type\": \"User\",\n" +
+				"        \"site_admin\": false\n" +
+				"      }\n" +
+				"    }\n" +
+				"  ]\n" +
+				"}";
+		this.server.enqueue(new MockResponse().setBody(responseJson));
+		this.github.publishRelease(this.repository, Release.tag("1.0.0").build());
+
+		RecordedRequest recordedRequest = this.server.takeRequest(1, TimeUnit.SECONDS);
+		assertThat(recordedRequest.getMethod()).isEqualToIgnoringCase("post");
+		assertThat(recordedRequest.getRequestUrl().toString()).isEqualTo(this.baseUrl + "/repos/spring-projects/spring-security/releases");
+		assertThat(recordedRequest.getBody().toString()).isEqualTo("{\"tag_name\":\"1.0.0\"}");
+	}
+
+	@Test
+	public void publishReleaseWhenErrorResponseThenException() throws Exception {
+		this.server.enqueue(new MockResponse().setResponseCode(400));
+		assertThatExceptionOfType(RuntimeException.class)
+				.isThrownBy(() -> this.github.publishRelease(this.repository, Release.tag("1.0.0").build()));
+	}
+}