diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 5767bc8e..9a7ae7db 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -29,4 +29,8 @@ jobs: - name: Build env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} + TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }} + TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }} run: ./build.sh ${{ github.event.inputs.target }} diff --git a/tools/build/BuildDefinition.cs b/tools/build/BuildDefinition.cs index 767aa1c7..3839276b 100644 --- a/tools/build/BuildDefinition.cs +++ b/tools/build/BuildDefinition.cs @@ -172,6 +172,20 @@ public static void Publish(Options options, GitVersion version, NuGetPackage pac } } + public static async Task TweetRelease(GitVersion version) + { + var twitterClient = new TwitterProvider( + consumerKey: Environment.GetEnvironmentVariable("TWITTER_CONSUMER_API_KEY") ?? throw new InvalidOperationException("Please set the TWITTER_CONSUMER_API_KEY environment variable."), + consumerKeySecret: Environment.GetEnvironmentVariable("TWITTER_CONSUMER_API_SECRET") ?? throw new InvalidOperationException("Please set the TWITTER_CONSUMER_API_SECRET environment variable."), + accessToken: Environment.GetEnvironmentVariable("TWITTER_ACCESS_TOKEN") ?? throw new InvalidOperationException("Please set the TWITTER_ACCESS_TOKEN environment variable."), + accessTokenSecret: Environment.GetEnvironmentVariable("TWITTER_ACCESS_TOKEN_SECRET") ?? throw new InvalidOperationException("Please set the TWITTER_ACCESS_TOKEN_SECRET environment variable.") + ); + + var message = $"YamlDotNet {version.NuGetVersion} has just been released! https://github.com/aaubry/YamlDotNet/releases/tag/v{version.NuGetVersion}"; + var result = await twitterClient.Tweet(message); + WriteVerbose(result); + } + public static ScaffoldedRelease ScaffoldReleaseNotes(GitVersion version, PreviousReleases releases) { if (version.IsPreRelease) @@ -459,12 +473,18 @@ internal class LoggerHttpHandler : HttpClientHandler protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var requestText = await request.Content.ReadAsStringAsync(); - WriteVerbose($"> {request.Method} {request.RequestUri}\n{requestText}\n".Replace("\n", "\n> ")); + var requestHeaders = request.Headers.Concat(request.Content.Headers) + .Select(h => $"\n{h.Key}: {string.Join(", ", h.Value)}"); + + WriteVerbose($"> {request.Method} {request.RequestUri}{string.Concat(requestHeaders)}\n\n{requestText}\n".Replace("\n", "\n> ")); var response = await base.SendAsync(request, cancellationToken); var responseText = await response.Content.ReadAsStringAsync(); - WriteVerbose($"< {response.StatusCode}\n{responseText}\n".Replace("\n", "\n< ")); + var responseHeaders = response.Headers.Concat(response.Content.Headers) + .Select(h => $"\n{h.Key}: {string.Join(", ", h.Value)}"); + + WriteVerbose($"< {(int)response.StatusCode} {response.ReasonPhrase}{string.Concat(responseHeaders)}\n\n{responseText}\n".Replace("\n", "\n< ")); return response; } diff --git a/tools/build/Program.cs b/tools/build/Program.cs index 14b78c19..a184f238 100644 --- a/tools/build/Program.cs +++ b/tools/build/Program.cs @@ -158,6 +158,7 @@ static int Main(string[] args) WriteInformation("Publishable build detected"); targets.Add(nameof(BuildDefinition.SetBuildVersion)); targets.Add(nameof(BuildDefinition.Publish)); + targets.Add(nameof(BuildDefinition.TweetRelease)); } else { diff --git a/tools/build/TwitterProvider.cs b/tools/build/TwitterProvider.cs new file mode 100644 index 00000000..363331fa --- /dev/null +++ b/tools/build/TwitterProvider.cs @@ -0,0 +1,136 @@ +// Stolen from https://github.com/cake-contrib/Cake.Twitter + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace build +{ + // The code within this TwitterProvider has been based almost exclusively on the work that was done by Danny Tuppeny + // based on this blog post: + // https://blog.dantup.com/2016/07/simplest-csharp-code-to-post-a-tweet-using-oauth/ + /// + /// Contains functionality related to Twitter API + /// + internal sealed class TwitterProvider + { + const string TwitterApiBaseUrl = "https://api.twitter.com/1.1/"; + readonly string consumerKey, consumerKeySecret, accessToken, accessTokenSecret; + readonly HMACSHA1 sigHasher; + readonly DateTime epochUtc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Creates an object for sending tweets to Twitter using Single-user OAuth. + /// + /// Get your access keys by creating an app at apps.twitter.com then visiting the + /// "Keys and Access Tokens" section for your app. They can be found under the + /// "Your Access Token" heading. + /// + public TwitterProvider(string consumerKey, string consumerKeySecret, string accessToken, string accessTokenSecret) + { + this.consumerKey = consumerKey; + this.consumerKeySecret = consumerKeySecret; + this.accessToken = accessToken; + this.accessTokenSecret = accessTokenSecret; + + sigHasher = new HMACSHA1(new ASCIIEncoding().GetBytes(string.Format("{0}&{1}", consumerKeySecret, accessTokenSecret))); + } + + /// + /// Sends a tweet with the supplied text and returns the response from the Twitter API. + /// + public Task Tweet(string text) + { + var data = new Dictionary { + { "status", text }, + { "trim_user", "1" } + }; + + return SendRequest("statuses/update.json", data); + } + + Task SendRequest(string url, Dictionary data) + { + var fullUrl = TwitterApiBaseUrl + url; + + // Timestamps are in seconds since 1/1/1970. + var timestamp = (int)((DateTime.UtcNow - epochUtc).TotalSeconds); + + // Add all the OAuth headers we'll need to use when constructing the hash. + data.Add("oauth_consumer_key", consumerKey); + data.Add("oauth_signature_method", "HMAC-SHA1"); + data.Add("oauth_timestamp", timestamp.ToString()); + data.Add("oauth_nonce", "a"); // Required, but Twitter doesn't appear to use it, so "a" will do. + data.Add("oauth_token", accessToken); + data.Add("oauth_version", "1.0"); + + // Generate the OAuth signature and add it to our payload. + data.Add("oauth_signature", GenerateSignature(fullUrl, data)); + + // Build the OAuth HTTP Header from the data. + string oAuthHeader = GenerateOAuthHeader(data); + + // Build the form data (exclude OAuth stuff that's already in the header). + var formData = new FormUrlEncodedContent(data.Where(kvp => !kvp.Key.StartsWith("oauth_"))); + + return SendRequest(fullUrl, oAuthHeader, formData); + } + + /// + /// Generate an OAuth signature from OAuth header values. + /// + string GenerateSignature(string url, Dictionary data) + { + var sigString = string.Join( + "&", + data + .Union(data) + .Select(kvp => string.Format("{0}={1}", Uri.EscapeDataString(kvp.Key), Uri.EscapeDataString(kvp.Value))) + .OrderBy(s => s) + ); + + var fullSigData = string.Format( + "{0}&{1}&{2}", + "POST", + Uri.EscapeDataString(url), + Uri.EscapeDataString(sigString.ToString()) + ); + + return Convert.ToBase64String(sigHasher.ComputeHash(new ASCIIEncoding().GetBytes(fullSigData.ToString()))); + } + + /// + /// Generate the raw OAuth HTML header from the values (including signature). + /// + string GenerateOAuthHeader(Dictionary data) + { + return "OAuth " + string.Join( + ", ", + data + .Where(kvp => kvp.Key.StartsWith("oauth_")) + .Select(kvp => string.Format("{0}=\"{1}\"", Uri.EscapeDataString(kvp.Key), Uri.EscapeDataString(kvp.Value))) + .OrderBy(s => s) + ); + } + + /// + /// Send HTTP Request and return the response. + /// + async Task SendRequest(string fullUrl, string oAuthHeader, FormUrlEncodedContent formData) + { + using (var http = new HttpClient()) + { + http.DefaultRequestHeaders.Add("Authorization", oAuthHeader); + + var httpResp = await http.PostAsync(fullUrl, formData); + var respBody = await httpResp.Content.ReadAsStringAsync(); + + return respBody; + } + } + } +}