Index: Tools/Google.Apis.Release/Program.cs =================================================================== new file mode 100644 --- /dev/null +++ b/Tools/Google.Apis.Release/Program.cs @@ -0,0 +1,651 @@ +/* +Copyright 2013 Google Inc + +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 + + http://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. +*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Framework; +using Microsoft.Build.Logging; + +using CommandLine; +using CommandLine.Text; +using Ionic.Zip; + +using Google.Apis.Release.Repositories; +using Google.Apis.Release.Wiki; +using Google.Apis.Utils; +using Google.Apis.Utils.Trace; + +namespace Google.Apis.Release +{ + /// The main program for creating a new Google.Apis release. + class Program + { + /// The options class which contains the different options to this publish release utility. + public class Options + { + #region HelpText + + const string VersionHelpText = "The version number of this release - only."; + const string OutputHelpText = "Define the output directory for this build. " + + "Notice that it's relative to current directory."; + const string StepHelpText = "Two options: " + + "'1' for building the core library. \n" + + "'2' for compiling samples, updating wiki and push to contrib the new version."; + const string IsBetaHelpText = "Is this release beta?"; + const string NuGetApiKeyHelpText = "Define the NuGet API key to publish to NuGet main repository."; + + [HelpOption] + public string GetHelp() + { + return HelpText.AutoBuild(this, c => HelpText.DefaultParsingErrorsHandler(this, c)); + } + + #endregion + + [Option('v', "version", Required = true, HelpText = VersionHelpText)] + public string Version { get; set; } + + [Option('d', "dir", Required = true, HelpText = OutputHelpText)] + public string OutputDirectory { get; set; } + + [Option('s', "step", Required = true, HelpText = StepHelpText)] + public int Step { get; set; } + + [Option('b', "beta", DefaultValue = true, HelpText = IsBetaHelpText)] + public bool IsBeta { get; set; } + + [Option('k', "nuget_key", HelpText = NuGetApiKeyHelpText)] + public string NuGetApiKey { get; set; } + } + + private static readonly TraceSource TraceSource = new TraceSource("Google.Apis"); + + /// Command line arguments. + private readonly Options options; + + private int MajorVersion { get; set; } + private int MinorVersion { get; set; } + private int BuildVersion { get; set; } + + private string Tag + { + get { return options.Version + (options.IsBeta ? "-beta" : ""); } + } + + /// Gets or sets the "default" repository. + private Hg DefaultRepository { get; set; } + + /// Gets or sets the "samples" repository. + private Hg SamplesRepository { get; set; } + + /// Gets or sets the "wiki" repository. + private Hg WikiRepository { get; set; } + + /// Gets or sets the "contrib" repository. + private Hg ContribRepository { get; set; } + + /// Gets all four repositories. + private IEnumerable AllRepositories + { + get { return new List { DefaultRepository, SamplesRepository, WikiRepository, ContribRepository }; } + } + + /// + /// Clones URL format which expects one parameter of the repository name (the default repository should be + /// empty). + /// + private const string CloneUrlFormat = "https://code.google.com/p/google-api-dotnet-client{0}/"; + + static void Main(string[] args) + { + bool valid = true; + + var options = new Options(); + if (!CommandLine.Parser.Default.ParseArguments(args, options)) + { + Console.ReadKey(); + return; + } + + if (options.Step > 2 || options.Step < 1) + { + TraceSource.TraceEvent(TraceEventType.Error, "Invalid Step. Valid step is '1' or '2'."); + valid = false; + } + + var match = Regex.Match(options.Version, @"^(\d+)\.(\d+)\.(\d)+$"); + if (!match.Success) + { + TraceSource.TraceEvent(TraceEventType.Error, + "Invalid version Number. Version should be in .. form."); + valid = false; + } + + var program = new Program(options) + { + MajorVersion = int.Parse(match.Groups[1].Value), + MinorVersion = int.Parse(match.Groups[2].Value), + BuildVersion = int.Parse(match.Groups[3].Value), + }; + + if (valid) + { + try + { + program.Run(); + } + catch (Exception ex) + { + TraceSource.TraceEvent(TraceEventType.Error, "Exception occurred while running. Exception is: {0}", + ex.Message); + } + } + + Console.WriteLine("Press any key to continue..."); + Console.ReadKey(); + } + + /// The main release logic for creating a new release of Google.Apis. + private void Run() + { + DefaultRepository = new Hg(new Uri(string.Format(CloneUrlFormat, "")), "default"); + + // Step 1 is only for creating Google.Apis and Google.Apis.Authentication packages + if (options.Step == 1) + { + DoStep1(); + } + // Step 2 should be done after the NuGet publisher generated all the APIs and the samples repository was + // updated with the new packages + else if (options.Step == 2) + { + DoStep2(); + } + } + + /// Creates Google.Apis and Google.Apis.Authentication packages. + private void DoStep1() + { + if (BuildVersion != 0) + { + DefaultRepository.Update(string.Format("{0}.{1}", MajorVersion, MinorVersion)); + } + + // if there are incoming changes those changes will be printed, otherwise we can continue in the process + if (!HasIncomingChanges(new List { DefaultRepository })) + { + // in case build fails the method will print its failures + if (BuildDefaultRepository()) + { + CreateCoreNuGetPackages(); + // TODO(peleyal): release notes should be part of the package + } + } + } + + /// + /// Doing the following: + /// + /// Builds samples + /// Creates a release notes + /// Update wiki download page + /// Create a new release in the contrib repository + /// Commits, Tags and Pushes + /// + /// + private void DoStep2() + { + Console.WriteLine(); + Console.WriteLine(); + Console.WriteLine("========================="); + Console.WriteLine("Prerequisites for Step 2:"); + Console.WriteLine("You ran Step 1."); + Console.WriteLine("You upgraded the Google.Apis NuGet packages for each sample in the samples " + + "repository and pushed that change."); + Console.WriteLine("========================="); + if (!CanContinue()) + { + return; + } + + SamplesRepository = new Hg(new Uri(string.Format(CloneUrlFormat, ".samples")), "samples"); + WikiRepository = new Hg(new Uri(string.Format(CloneUrlFormat, ".wiki")), "wiki"); + ContribRepository = new Hg(new Uri(string.Format(CloneUrlFormat, ".contrib")), "contrib"); + + // if there are incoming changes those changes will be printed, otherwise we can continue in the + // process + if (!HasIncomingChanges(AllRepositories)) + { + BuildSamples(); + var notes = CreateContribNewRelease(); + UpdateWiki(notes); + + foreach (var repository in AllRepositories) + { + repository.AddRemoveFiles(); + } + + Console.WriteLine("========================="); + Console.WriteLine("Commit, Tag and Push"); + Console.WriteLine("========================="); + if (!CanContinue()) + { + return; + } + + // commit + CommitAndTag(); + + // push + foreach (Hg repository in AllRepositories) + { + repository.Push(); + } + + // create branch + PrintCreateBranch(); + + // publish core components to NuGet + if (!string.IsNullOrEmpty(options.NuGetApiKey)) + { + PublishPackagesToNuGet(); + Console.WriteLine("Now... you should run the NuGet publisher to publish a new PCL " + + "for each generated Google API. Run: " + + "Google.Apis.NuGet.Publisher --all_apis true -m publisher -k [NUGET_KEY]"); + } + else + { + TraceSource.TraceEvent(TraceEventType.Error, "NuGet API key is empty!"); + } + } + } + + /// Asks the user if he wants to continue in the process. + /// true if the user to press 'y' or 'yes' to continue + private bool CanContinue() + { + var yesOptions = new[] { "y", "yes" }; + var noOptions = new[] { "n", "no" }; + + string input; + do + { + Console.WriteLine("Press 'y' | 'yes' to continue, or 'n' | 'no' to stop"); + input = Console.ReadLine().ToLower(); + } while (!yesOptions.Contains(input) && !noOptions.Contains(input)); + + return yesOptions.Contains(input); + } + + /// Publishes the core packages to NuGet main repository. + private void PublishPackagesToNuGet() + { + var apiNupkgPath = Path.Combine(NuGetUtilities.LocalNuGetPackageFolder, + string.Format("Google.Apis.{0}.nupkg", Tag)); + NuGetUtilities.PublishToNuget(apiNupkgPath, options.NuGetApiKey); + + var authenticationNupkgPath = Path.Combine(NuGetUtilities.LocalNuGetPackageFolder, + string.Format("Google.Apis.Authentication.{0}.nupkg", Tag)); + NuGetUtilities.PublishToNuget(authenticationNupkgPath, options.NuGetApiKey); + } + + /// + /// Displays the user orders how to create a branch (only in case it's a new major or minor release. + /// + private void PrintCreateBranch() + { + if (BuildVersion != 0) + { + // No need to branch in that case + return; + } + + // TODO(peleyal): consider automate this as well + Console.WriteLine("You should create a new branch for this release now:"); + Console.WriteLine("cd " + DefaultRepository.WorkingDirectory); + var branchVersion = string.Format("{0}.{1}", MajorVersion, MinorVersion); + Console.WriteLine("hg branch " + branchVersion); + Console.WriteLine(string.Format("hg commit -m create {0} branch", branchVersion)); + Console.WriteLine("hg push --new-branch"); + } + + /// Commits all changes in all repositories and tags the "default" repository. + private void CommitAndTag() + { + foreach (var repository in AllRepositories) + { + TraceSource.TraceEvent(TraceEventType.Information, "{0} - Committing", repository.Name); + + bool committed = repository.Commit("Release " + Tag); + + // TODO(peleyal): think to remove this if from this function. I don't like a if inside a loop statement + if (repository.Equals(DefaultRepository) && committed) + { + try + { + repository.Tag(Tag, false); + TraceSource.TraceEvent(TraceEventType.Information, "{0} - Was tagged \"{1}\"", + repository.Name, Tag); + } + catch (Exception ex) + { + TraceSource.TraceEvent(TraceEventType.Error, "{0} - Tagging Failed. Exception is: {1}", + repository.Name, ex.Message); + } + } + + TraceSource.TraceEvent(TraceEventType.Information, "{0} - Committed", repository.Name); + } + } + + /// Updates the "Downloads" wiki page with the new release notes. + private void UpdateWiki(string notes) + { + TraceSource.TraceEvent(TraceEventType.Information, "Updating wiki downloads page"); + + // TODO(peleyal): improve. Currently we count on that old release of X.Y.Z is X.Y-1.0 + var oldVersion = string.Format("{0}.{1}.{2}", MajorVersion, MinorVersion - 1, 0); + DownloadsPageUpdater.UpdateWiki(WikiRepository.WorkingDirectory, notes, oldVersion, options.Version); + + TraceSource.TraceEvent(TraceEventType.Information, "wiki downloads page was updated"); + } + + /// Creates a new release in the "contrib" repository. + /// The release notes of this version + private string CreateContribNewRelease() + { + TraceSource.TraceEvent(TraceEventType.Information, "Building Contrib release"); + + string releaseDir = ContribRepository.Combine(Tag); + + // Clear existing directories. + DirectoryUtilities.ClearOrCreateDirectory(releaseDir); + string genDir = Path.Combine(releaseDir, "Generated"); + Directory.CreateDirectory(genDir); + + #region [RELEASE_VERSION]/Generated/Bin + + string binDir = Path.Combine(genDir, "Bin"); + TraceSource.TraceEvent(TraceEventType.Information, "Generating \"{0}\" directory", + DirectoryUtilities.GetRelativePath(binDir, ContribRepository.WorkingDirectory)); + + Directory.CreateDirectory(binDir); + foreach (var project in ReleaseProjects) + { + var releasePath = Path.Combine(project.DirectoryPath, "Bin", "Release"); + foreach (var filePath in Directory.GetFiles(releasePath, "Google.Apis.*")) + { + File.Copy(filePath, + Path.Combine(binDir, filePath.Substring(filePath.LastIndexOf("\\") + 1)), true); + } + } + + // TODO(peleyal): Put also the nuspec and nupkg + + #endregion + + #region [RELEASE_VERSION]/ZipFiles + + string zipFilesDir = Path.Combine(genDir, "ZipFiles"); + TraceSource.TraceEvent(TraceEventType.Information, "Generating \"{0}\" directory", + DirectoryUtilities.GetRelativePath(zipFilesDir, ContribRepository.WorkingDirectory)); + + Directory.CreateDirectory(zipFilesDir); + foreach (var project in ReleaseProjects) + { + project.Build("Clean"); + } + + TraceSource.TraceEvent(TraceEventType.Information, "Release projects were cleaned"); + + // source.zip + var fileNameFormat = "google-api-dotnet-client-{0}.{1}.zip"; + var sourceZipName = string.Format(fileNameFormat, Tag, "source"); + using (var zip = new ZipFile(Path.Combine(zipFilesDir, sourceZipName))) + { + zip.AddDirectory(Path.Combine(DefaultRepository.WorkingDirectory, "Src"), "Src"); + zip.AddFile(Path.Combine(DefaultRepository.WorkingDirectory, "GoogleApisClient.sln"), ""); + zip.AddFile(Path.Combine(DefaultRepository.WorkingDirectory, "LICENSE"), ""); + zip.Save(); + } + TraceSource.TraceEvent(TraceEventType.Information, "{0} was created", sourceZipName); + + // binary.zip + var binaryZipName = string.Format(fileNameFormat, Tag, "binary"); + using (var zip = new ZipFile(Path.Combine(zipFilesDir, binaryZipName))) + { + Directory.GetFiles(binDir).ToList().ForEach(f => zip.AddFile(Path.Combine(binDir, f), "")); + zip.Save(); + } + TraceSource.TraceEvent(TraceEventType.Information, "{0} was created", binaryZipName); + + + // samples.zip + var samplesZipName = string.Format(fileNameFormat, Tag, "samples"); + using (var zip = new ZipFile(Path.Combine(zipFilesDir, samplesZipName))) + { + foreach (var d in Directory.GetDirectories(SamplesRepository.WorkingDirectory)) + { + if (!d.EndsWith(".hg")) + { + var directoryName = d.Substring(d.LastIndexOf("\\") + 1); + zip.AddDirectory(Path.Combine(SamplesRepository.WorkingDirectory, d), directoryName); + } + } + foreach (var f in Directory.GetFiles(SamplesRepository.WorkingDirectory, "*.sln")) + { + zip.AddFile(Path.Combine(SamplesRepository.WorkingDirectory, f), ""); + } + zip.Save(); + } + + TraceSource.TraceEvent(TraceEventType.Information, "{0} was created", samplesZipName); + + #endregion + + #region [RELEASE_VERSION]/ReleaseNotes.txt + + var notes = GetChangelog(); + TraceSource.TraceEvent(TraceEventType.Information, "Creating ReleaseNotes file"); + var noteFilePath = Path.Combine(genDir, "ReleaseNotes.txt"); + File.WriteAllText(noteFilePath, notes); + + #endregion + + // open the created change-log and read again the notes (in case the user had modified the file) + Process.Start(noteFilePath).WaitForExit(); + notes = File.ReadAllText(noteFilePath); + + return notes; + } + + /// + /// Returns the notes list of this release. It contains the change log of all pushes to the "default" + /// repository. + /// + private string GetChangelog() + { + StringBuilder log = new StringBuilder(); + log.AppendLine("Google .NET Client Library"); + log.AppendLine(string.Format("Stable Release '{0}'", Tag)); + log.AppendLine(DateTime.UtcNow.ToLongDateString()); + log.AppendLine("==========================================="); + + log.AppendLine().AppendLine("Changes:"); + foreach (string line in DefaultRepository.CreateChangelist()) + { + log.AppendLine(" " + line); + } + + return log.ToString(); + } + + /// Builds all projects in the "samples" repository. + private void BuildSamples() + { + // build all the samples projects. + TraceSource.TraceEvent(TraceEventType.Information, "Building the samples"); + foreach (string csproj in Directory.GetFiles(SamplesRepository.WorkingDirectory, "*.csproj", + SearchOption.AllDirectories)) + { + Project project = new Project(csproj); + project.SetProperty("Configuration", "Release"); + TraceSource.TraceEvent(TraceEventType.Information, "Building {0}", project.GetName()); + bool success = project.Build("Build", new[] { new ConsoleLogger(LoggerVerbosity.Quiet) }); + if (!success) + { + TraceSource.TraceEvent(TraceEventType.Error, "Building {0} FAILED", project.GetName()); + } + project.Build("Clean"); + TraceSource.TraceEvent(TraceEventType.Information, "Building {0} succeeded", project.GetName()); + } + } + + /// Returns true if one of the repositories has incoming changes. + private bool HasIncomingChanges(IEnumerable repositories) + { + foreach (var repository in repositories) + { + if (repository.HasIncomingChanges) + { + TraceSource.TraceEvent(TraceEventType.Error, + "[{0}] has incoming changes. Run hg pull & update first!", repository.Name); + return true; + } + } + return false; + } + + /// Creates the Google.Apis and Google.Apis.Authentication NuGet packages. + private void CreateCoreNuGetPackages() + { + // create a resource dir in the working folder + var destDirectory = Path.Combine(Environment.CurrentDirectory, "Resources"); + if (Directory.Exists(destDirectory)) + { + Directory.Delete(destDirectory, true); + } + + FileInfo info = new FileInfo(Assembly.GetEntryAssembly().Location); + DirectoryUtilities.CopyDirectory(Path.Combine(info.Directory.FullName, "Resources"), destDirectory); + + var newVersion = options.Version + "-beta"; + + // get the Google.Apis and Google.Apis.Authentication nuspec files and replace the version in it + var apiNuspec = Path.Combine(destDirectory, "Google.Apis.VERSION.nuspec"); + var newApiNuspec = apiNuspec.Replace("VERSION", newVersion); + var authenticationNuspec = Path.Combine(destDirectory, "Google.Apis.Authentication.VERSION.nuspec"); + var newAuthenticationNuspec = authenticationNuspec.Replace("VERSION", newVersion); + + File.Move(apiNuspec, newApiNuspec); + File.Move(authenticationNuspec, newAuthenticationNuspec); + + var allLines = File.ReadAllText(newApiNuspec).Replace("VERSION", newVersion); + File.WriteAllText(newApiNuspec, allLines); + + allLines = File.ReadAllText(newAuthenticationNuspec).Replace("VERSION", newVersion); + File.WriteAllText(newAuthenticationNuspec, allLines); + + NuGetUtilities.CreateLocalNupkgFile(newApiNuspec, Environment.CurrentDirectory); + NuGetUtilities.CreateLocalNupkgFile(newAuthenticationNuspec, Environment.CurrentDirectory); + } + + private IEnumerable releaseProjects; + /// Gets the release projects of from the "default" repository. + private IEnumerable ReleaseProjects + { + get + { + if (releaseProjects != null) + { + return releaseProjects; + } + + var releasePaths = new[] + { + DefaultRepository.Combine("Src", "GoogleApis", "GoogleApis.csproj"), + DefaultRepository.Combine("Src", "GoogleApis.DotNet4", "GoogleApis.DotNet4.csproj"), + DefaultRepository.Combine("Src", "GoogleApis.Authentication.OAuth2", + "GoogleApis.Authentication.OAuth2.csproj") + }; + return releaseProjects = (from path in releasePaths + select new Project(path)).ToList(); + } + } + + /// Builds the "default" repository projects. + /// true if the default repository was build successfully + private bool BuildDefaultRepository() + { + TraceSource.TraceEvent(TraceEventType.Information, "Building projects...."); + + var allProjects = new List(); + allProjects.AddRange(ReleaseProjects); + allProjects.Add(new Project(DefaultRepository.Combine("Src", "GoogleApis.Tests", + "GoogleApis.Tests.csproj"))); + allProjects.Add(new Project(DefaultRepository.Combine("Src", "GoogleApis.Authentication.OAuth2.Tests", + "GoogleApis.Authentication.OAuth2.Tests.csproj"))); + + foreach (var project in allProjects) + { + var name = project.GetName(); + TraceSource.TraceEvent(TraceEventType.Information, "Replacing version for {0}", name); + project.ReplaceVersion(options.Version); + project.SetProperty("Configuration", "Release"); + TraceSource.TraceEvent(TraceEventType.Information, "Building {0}", name); + bool success = project.Build("Build", new[] { new ConsoleLogger(LoggerVerbosity.Quiet) }); + + if (success) + { + TraceSource.TraceEvent(TraceEventType.Information, "Building {0} succeeded!", name); + } + else + { + TraceSource.TraceEvent(TraceEventType.Error, "Building {0} failed!", name); + return false; + } + + if (!ReleaseProjects.Contains(project)) + { + // TODO(peleyal): run unit tests for all test projects + } + } + + return true; + } + + /// Constructs a new program with the given options. + public Program(Options options) + { + this.options = options; + + string path = Path.GetFullPath(options.OutputDirectory); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + + Environment.CurrentDirectory = path; + } + } +}