IEnumerable <ReleaseProposal> IBatchCriterion.GetProposals(ApiCatalog catalog) { var root = DirectoryLayout.DetermineRootDirectory(); using var repo = new Repository(root); var pendingChangesByApi = GitHelpers.GetPendingChangesByApi(repo, catalog); foreach (var api in catalog.Apis) { // Don't even bother proposing package groups at the moment. if (api.PackageGroup is object) { continue; } // Don't propose packages that haven't changed. // Note that this will also not propose a release for APIs that haven't // yet *been* released - which is probably fine. (We don't want to accidentally // launch something due to not paying attention.) if (pendingChangesByApi[api].Commits.Count == 0) { continue; } var newVersion = api.StructuredVersion.AfterIncrement(); yield return(ReleaseProposal.CreateFromHistory(repo, api.Id, newVersion)); } }
/// <summary> /// Loads the service directory from service config files in the "googleapis" /// directory under the root layout. /// </summary> public static ServiceDirectory LoadFromGoogleapis() { var root = DirectoryLayout.DetermineRootDirectory(); var googleapisRoot = Path.Combine(root, "googleapis"); return(LoadFromGoogleapis(googleapisRoot)); }
public static void RewriteReadme(ApiCatalog catalog) { var root = DirectoryLayout.DetermineRootDirectory(); var readmePath = Path.Combine(root, "README.md"); RewriteApiTable(readmePath, catalog, api => $"https://googleapis.dev/dotnet/{api.Id}/{api.Version}"); }
private static void Execute(string id) { var catalog = ApiCatalog.Load(); var api = catalog[id]; if (api.NoVersionHistory) { Console.WriteLine($"Skipping version history update for {id}"); return; } string historyFilePath = HistoryFile.GetPathForPackage(id); var root = DirectoryLayout.DetermineRootDirectory(); using var repo = new Repository(root); var releases = Release.LoadReleases(repo, catalog, api).ToList(); var historyFile = HistoryFile.Load(historyFilePath); var sectionsInserted = historyFile.MergeReleases(releases); if (sectionsInserted.Count != 0) { historyFile.Save(historyFilePath); var relativePath = Path.GetRelativePath(DirectoryLayout.DetermineRootDirectory(), historyFilePath) .Replace('\\', '/'); Console.WriteLine($"Updated version history file: {relativePath}"); Console.WriteLine("New content:"); Console.WriteLine(); foreach (var line in sectionsInserted.SelectMany(section => section.Lines)) { Console.WriteLine(line); } } }
protected override void ExecuteImpl(string[] args) { ValidateCommonHiddenProductionDependencies(); var root = DirectoryLayout.DetermineRootDirectory(); var catalog = ApiCatalog.Load(); ValidateApiCatalog(catalog); Console.WriteLine($"API catalog contains {catalog.Apis.Count} entries"); // Now we know we can parse the API catalog, let's reformat it. ReformatApiCatalog(catalog); RewriteReadme(catalog); RewriteRenovate(catalog); HashSet <string> apiNames = catalog.CreateIdHashSet(); foreach (var api in catalog.Apis) { var path = Path.Combine(root, "apis", api.Id); GenerateProjects(path, api, apiNames); GenerateSolutionFiles(path, api); GenerateDocumentationStub(path, api); GenerateSynthConfiguration(path, api); GenerateOwlBotConfiguration(path, api); GenerateMetadataFile(path, api); } }
static void GenerateNotes(string api, Func <string, bool> pathFilter) { var apiDirectory = $"apis\\{api}\\"; var tagPrefix = $"{api}-"; Console.WriteLine($"Changes for {api}"); var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new Repository(root)) { var diff = repo.Diff; var apiTags = repo.Tags .Where(tag => tag.FriendlyName.StartsWith(tagPrefix)) .ToList(); var idToTagName = apiTags.ToDictionary(tag => tag.Target.Id, tag => tag.FriendlyName.Substring(tagPrefix.Length)); foreach (var commit in repo.Branches["master"].Commits) { if (idToTagName.TryGetValue(commit.Id, out string version)) { Console.WriteLine(); Console.WriteLine($"Release: {version}"); Console.WriteLine($"---------{new string('-', version.Length)}"); } if (CommitContainsApi(diff, commit, pathFilter)) { Console.WriteLine($"https://github.com/googleapis/google-cloud-dotnet/commit/{commit.Sha.Substring(0, 7)}"); Console.WriteLine(commit.Message); Console.WriteLine(); } } } }
protected override void ExecuteImpl(string[] args) { string id = args[0]; var catalog = ApiCatalog.Load(); if (catalog.Apis.Any(api => api.Id == id)) { throw new UserErrorException($"API {id} already exists in the API catalog."); } var root = DirectoryLayout.DetermineRootDirectory(); var googleapis = Path.Combine(root, "googleapis"); var apiIndex = ApiIndex.V1.Index.LoadFromGoogleApis(googleapis); var targetApi = apiIndex.Apis.FirstOrDefault(api => api.DeriveCSharpNamespace() == id); if (targetApi is null) { var lowerWithoutCloud = id.Replace(".Cloud", "").ToLowerInvariant(); var possibilities = apiIndex.Apis .Select(api => api.DeriveCSharpNamespace()) .Where(ns => ns.Replace(".Cloud", "").ToLowerInvariant() == lowerWithoutCloud); throw new UserErrorException( $"No service found for '{id}'.{Environment.NewLine}Similar possibilities (check options?): {string.Join(", ", possibilities)}"); } var api = new ApiMetadata { Id = id, ProtoPath = targetApi.Directory, ProductName = targetApi.Title.EndsWith(" API") ? targetApi.Title[..^ 4] : targetApi.Title,
private static void Execute(string id) { var catalog = ApiCatalog.Load(); var api = catalog[id]; if (api.NoVersionHistory) { Console.WriteLine($"Skipping version history update for {id}"); return; } string historyFilePath = HistoryFile.GetPathForPackage(id); var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new Repository(root)) { var releases = LoadReleases(repo, api).ToList(); if (!File.Exists(historyFilePath)) { File.WriteAllText(historyFilePath, "# Version history\r\n\r\n"); } var historyFile = HistoryFile.Load(historyFilePath); historyFile.MergeReleases(releases); historyFile.Save(historyFilePath); } var relativePath = Path.GetRelativePath(DirectoryLayout.DetermineRootDirectory(), historyFilePath) .Replace('\\', '/'); Console.WriteLine($"Updated version history file: {relativePath}"); }
IEnumerable <ReleaseProposal> IBatchCriterion.GetProposals(ApiCatalog catalog) { var root = DirectoryLayout.DetermineRootDirectory(); using var repo = new Repository(root); var pendingChangesByApi = GitHelpers.GetPendingChangesByApi(repo, catalog); foreach (var api in catalog.Apis) { var pendingChanges = pendingChangesByApi[api]; var pendingCommits = pendingChanges.Commits.Select(commit => commit.HashPrefix); if (!Commits.SetEquals(pendingCommits)) { continue; } var newVersion = api.StructuredVersion.AfterIncrement(); var proposal = ReleaseProposal.CreateFromHistory(repo, api.Id, newVersion); // Potentially replace the natural history with an override if (!string.IsNullOrEmpty(HistoryOverride) && proposal.NewHistorySection is HistoryFile.Section newSection) { var naturalLines = newSection.Lines; var overrideLines = HistoryOverride.Split('\n'); var lines = naturalLines.Take(2).Concat(overrideLines).ToList(); // We always add a blank line at the end of each section. lines.Add(""); proposal.NewHistorySection = new HistoryFile.Section(newVersion, lines); } yield return(proposal); } }
protected override void ExecuteImpl(string[] args) { string configFile = args[0]; var json = File.ReadAllText(configFile); var config = JsonConvert.DeserializeObject <BatchReleaseConfig>(json); var criteria = config.GetCriteria().ToList(); if (criteria.Count != 1) { throw new UserErrorException("Batch release config must specify exactly one criterion."); } if (!config.DryRun) { var root = DirectoryLayout.DetermineRootDirectory(); using var repo = new Repository(root); if (repo.RetrieveStatus().IsDirty) { throw new UserErrorException("In non-dry-run mode, the current branch must not have changes."); } } var catalog = ApiCatalog.Load(); var criterion = criteria[0]; var proposals = criterion.GetProposals(catalog); foreach (var proposal in proposals) { // Note: This takes into account the dry-run flag. proposal.Execute(config); } }
protected override void ExecuteImpl(string[] args) { var catalog = ApiCatalog.Load(); var root = DirectoryLayout.DetermineRootDirectory(); var googleapis = Path.Combine(root, "googleapis"); var apiIndex = ApiIndex.V1.Index.LoadFromGoogleApis(googleapis); int modifiedCount = 0; foreach (var api in catalog.Apis) { var indexEntry = apiIndex.Apis.FirstOrDefault(x => x.DeriveCSharpNamespace() == api.Id); if (indexEntry is null) { continue; } // Change this line when introducing a new field... api.Json.Last.AddAfterSelf(new JProperty("serviceConfigFile", indexEntry.ConfigFile)); modifiedCount++; } Console.WriteLine($"Modified APIs: {modifiedCount}"); string json = catalog.FormatJson(); // Validate that we can still load it, before saving it to disk... ApiCatalog.FromJson(json); File.WriteAllText(ApiCatalog.CatalogPath, json); }
public void Execute(string[] args) { var root = DirectoryLayout.DetermineRootDirectory(); var catalog = ApiCatalog.Load(); HashSet <string> tags; using (var repo = new Repository(root)) { tags = new HashSet <string>(repo.Tags.Select(tag => tag.FriendlyName)); } List <ApiMetadata> apisToCheck = args.Length == 0 ? catalog.Apis.Where(api => !api.Version.EndsWith("00") && !tags.Contains($"{api.Id}-{api.Version}")).ToList() // Note: this basically validates the command line arguments. : args.Select(arg => catalog[arg]).ToList(); foreach (var api in apisToCheck) { if (IgnoredApis.Contains(api.Id)) { Console.WriteLine($"Skipping check for {api.Id} as it doesn't target netstandard2.0"); continue; } Console.WriteLine($"Checking compatibility for {api.Id} version {api.Version}"); var prefix = api.Id + "-"; var previousVersions = tags .Where(tag => tag.StartsWith(prefix)) .Select(tag => tag.Split(new char[] { '-' }, 2)[1]) .Where(v => !v.StartsWith("0")) // We can reasonably ignore old 0.x versions .Select(StructuredVersion.FromString) .OrderBy(v => v) .ToList(); var newVersion = api.StructuredVersion; // First perform a "strict" check, where necessary, failing the build if the difference // is inappropriate. var(requiredVersion, requiredLevel) = GetRequiredCompatibility(api.StructuredVersion); if (requiredVersion != null) { if (!previousVersions.Contains(requiredVersion)) { throw new UserErrorException($"Expected to check compatibility with {requiredVersion}, but no corresponding tag found"); } var actualLevel = CheckCompatibility(api, requiredVersion); if (actualLevel < requiredLevel) { throw new UserErrorException($"Required compatibility level: {requiredLevel}. Actual compatibility level: {actualLevel}."); } } // Next log the changes compared with the previous release (if we haven't already diffed it) // in an informational way. (This can be used to improve or check release notes.) var lastRelease = previousVersions.LastOrDefault(); if (lastRelease != null && !lastRelease.Equals(requiredVersion)) { CheckCompatibility(api, lastRelease); } } }
static int Main() { try { ValidateCommonHiddenProductionDependencies(); var root = DirectoryLayout.DetermineRootDirectory(); var apis = ApiMetadata.LoadApis(); Console.WriteLine($"API catalog contains {apis.Count} entries"); HashSet <string> apiNames = new HashSet <string>(apis.Select(api => api.Id)); foreach (var api in apis) { var path = Path.Combine(root, "apis", api.Id); GenerateProjects(path, api, apiNames); GenerateSolutionFiles(path, api); GenerateDocumentationStub(path, api); GenerateSynthConfiguration(path, api); GenerateMetadataFile(path, api); } return(0); } catch (UserErrorException e) { Console.WriteLine($"Configuration error: {e.Message}"); return(1); } catch (Exception e) { Console.WriteLine($"Failed: {e}"); return(1); } }
static int Main(string[] args) { var root = DirectoryLayout.DetermineRootDirectory(); var apis = ApiMetadata.LoadApis(); HashSet <string> tags; using (var repo = new Repository(root)) { tags = new HashSet <string>(repo.Tags.Select(tag => tag.FriendlyName)); } var changes = apis.Where(api => !api.Version.EndsWith("00") && !tags.Contains($"{api.Id}-{api.Version}")); foreach (var api in changes) { if (IgnoredApis.Contains(api.Id)) { Console.WriteLine($"Skipping check for {api.Id} as it doesn't target netstandard2.0"); continue; } Console.WriteLine($"Checking compatibility for {api.Id} version {api.Version}"); var prefix = api.Id + "-"; var previousVersions = tags .Where(tag => tag.StartsWith(prefix)) .Select(tag => tag.Split(new char[] { '-' }, 2)[1]) .Select(v => new StructuredVersion(v)) .OrderBy(v => v) .ToList(); var newVersion = api.StructuredVersion; // First perform a "strict" check, where necessary, failing the build if the difference // is inappropriate. var(requiredVersion, requiredLevel) = GetRequiredCompatibility(api.StructuredVersion); if (requiredVersion != null) { if (!previousVersions.Contains(requiredVersion)) { Console.WriteLine($"Expected to check compatibility with {requiredVersion}, but no corresponding tag found"); return(1); } var actualLevel = CheckCompatibility(api, requiredVersion); if (actualLevel < requiredLevel) { Console.WriteLine($"Required compatibility level: {requiredLevel}. Actual compatibility level: {actualLevel}."); return(1); } } // Next log the changes compared with the previous release (if we haven't already diffed it) // in an informational way. (This can be used to improve or check release notes.) var lastRelease = previousVersions.LastOrDefault(); if (lastRelease != null && !lastRelease.Equals(requiredVersion)) { CheckCompatibility(api, lastRelease); } } return(0); }
private static List <ApiMetadata> LoadApis() { var root = DirectoryLayout.DetermineRootDirectory(); var json = File.ReadAllText(Path.Combine(root, "apis", "apis.json")); return(JsonConvert.DeserializeObject <List <ApiMetadata> >(json).OrderBy(api => api.Id).ToList()); }
protected override void ExecuteImpl(string[] args) { var diffs = FindChangedVersions(); if (diffs.Count != 1) { throw new UserErrorException($"Can only automate a single-package release commit with exactly 1 release. Found {diffs.Count}. Did you mean 'commit-multiple'?"); } var diff = diffs[0]; if (diff.NewVersion is null) { throw new UserErrorException($"Cannot automate a release commit for a deleted API."); } var historyFilePath = HistoryFile.GetPathForPackage(diff.Id); if (!File.Exists(historyFilePath)) { throw new UserErrorException($"Cannot automate a release commit without a version history file."); } var historyFile = HistoryFile.Load(historyFilePath); var section = historyFile.Sections.FirstOrDefault(s => s.Version?.ToString() == diff.NewVersion); if (section is null) { throw new UserErrorException($"Unable to find history file section for {diff.NewVersion}. Cannot automate a release commit in this state."); } string header = $"Release {diff.Id} version {diff.NewVersion}"; var message = string.Join("\n", new[] { header, "", "Changes in this release:", "" }.Concat(section.Lines.Skip(2))); var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new Repository(root)) { RepositoryStatus status = repo.RetrieveStatus(); // TODO: Work out whether this is enough, and whether we actually need all of these. // We basically want git add --all. AddAll(status.Modified); AddAll(status.Missing); AddAll(status.Untracked); repo.Index.Write(); var signature = repo.Config.BuildSignature(DateTimeOffset.UtcNow); var commit = repo.Commit(message, signature, signature); Console.WriteLine($"Created commit {commit.Sha}. Review the message and amend if necessary."); void AddAll(IEnumerable <StatusEntry> entries) { foreach (var entry in entries) { repo.Index.Add(entry.FilePath); } } } }
public void Execute(string[] args) { var root = DirectoryLayout.DetermineRootDirectory(); var catalog = ApiCatalog.Load(); HashSet <string> tags; using (var repo = new Repository(root)) { tags = new HashSet <string>(repo.Tags.Select(tag => tag.FriendlyName)); } List <ApiMetadata> apisToCheck = args.Length == 0 ? catalog.Apis.Where(api => !api.Version.EndsWith("00") && !tags.Contains($"{api.Id}-{api.Version}")).ToList() // Note: this basically validates the command line arguments. : args.Select(arg => catalog[arg]).ToList(); foreach (var api in apisToCheck) { Console.WriteLine($"Checking compatibility for {api.Id} version {api.Version}"); var prefix = api.Id + "-"; var lastVersion = tags .Where(tag => tag.StartsWith(prefix)) .Select(tag => tag.Split(new char[] { '-' }, 2)[1]) .Where(v => !v.StartsWith("0")) // We can reasonably ignore old 0.x versions .Select(StructuredVersion.FromString) .OrderBy(v => v) .LastOrDefault(); if (lastVersion is null) { Console.WriteLine("No previous versions released; ignoring."); continue; } var newVersion = api.StructuredVersion; // If we're releasing a new version, we should check against the previous one. // (For example, if this PR creates 1.2.0, then check against 1.1.0.) // Otherwise, just expect minor changes. Level requiredLevel = Level.Minor; if (!lastVersion.Equals(newVersion)) { requiredLevel = lastVersion.Major != newVersion.Major ? Level.Major // Major version bump: anything goes : lastVersion.Minor != newVersion.Minor ? Level.Minor // Minor version bump: minor changes are okay : Level.Identical; // Patch version bump: API should be identical } var actualLevel = CheckCompatibility(api, lastVersion); if (actualLevel < requiredLevel) { throw new UserErrorException($"Required compatibility level: {requiredLevel}. Actual compatibility level: {actualLevel}."); } } }
public static void RewriteRenovate(ApiCatalog catalog) { var root = DirectoryLayout.DetermineRootDirectory(); string path = Path.Combine(root, ".github", "renovate.json"); string json = File.ReadAllText(path); JObject jobj = JObject.Parse(json); jobj["ignorePaths"] = new JArray(catalog.Apis.Select(api => $"apis/{api.Id}/{api.Id}/**").ToArray()); json = jobj.ToString(Formatting.Indented); File.WriteAllText(path, json); }
protected override void ExecuteImpl(string[] args) { var root = DirectoryLayout.DetermineRootDirectory(); var googleapis = Path.Combine(root, "googleapis"); var apiIndex = ApiIndex.V1.Index.LoadFromGoogleApis(googleapis); foreach (var api in apiIndex.Apis) { ReportAnomalies(api); } }
private static List <ApiMetadata> ComputeNewReleasesAsync(List <ApiMetadata> allApis) { var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new LibGit2Sharp.Repository(root)) { var tags = repo.Tags.Select(tag => tag.FriendlyName).ToList(); var noChange = allApis.Where(api => tags.Contains($"{api.Id}-{api.Version}") || api.Version.EndsWith("00")).ToList(); return(allApis.Except(noChange).ToList()); } }
protected override void ExecuteImpl(string[] args) { var root = DirectoryLayout.DetermineRootDirectory(); var catalog = ApiCatalog.Load(); using (var repo = new Repository(root)) { var allTags = repo.Tags.OrderByDescending(GitHelpers.GetDate).ToList(); foreach (var api in catalog.Apis) { MaybeShowStale(repo, allTags, api); } } }
private string GetGoogleapisRoot() { if (Environment.GetEnvironmentVariable("SYNTHTOOL_PRECONFIG_FILE") is string preconfigFile && preconfigFile != "") { JObject preconfig = JObject.Parse(File.ReadAllText(preconfigFile)); return((string)preconfig["preclonedRepos"]["https://github.com/googleapis/googleapis.git"]); } if (Environment.GetEnvironmentVariable("SYNTHTOOL_GOOGLEAPIS") is string synthtoolGoogleapis && synthtoolGoogleapis != "") { return(synthtoolGoogleapis); } var root = DirectoryLayout.DetermineRootDirectory(); return(Path.Combine(root, "googleapis")); }
protected override void ExecuteImpl(string[] args) { Console.WriteLine($"Lagging packages (package ID, current version, date range of current version prerelease series):"); var root = DirectoryLayout.DetermineRootDirectory(); var catalog = ApiCatalog.Load(); using (var repo = new Repository(root)) { var allTags = repo.Tags.OrderByDescending(GitHelpers.GetDate).ToList(); foreach (var api in catalog.Apis) { MaybeShowLagging(repo, allTags, api); } } }
protected ApiCatalog LoadPrimaryCatalog() { var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new Repository(root)) { var primary = repo.Branches.FirstOrDefault(b => b.FriendlyName == PrimaryBranch); if (primary == null) { throw new UserErrorException($"Unable to find branch '{PrimaryBranch}'."); } var primaryCatalogJson = primary.Commits.First()["apis/apis.json"].Target.Peel <Blob>().GetContentText(); return(ApiCatalog.FromJson(primaryCatalogJson)); } }
private static List <ApiMetadata> LoadMasterCatalog() { var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new Repository(root)) { var master = repo.Branches.FirstOrDefault(b => b.FriendlyName == MasterBranch); if (master == null) { throw new UserErrorException($"Unable to find branch '{MasterBranch}'."); } var masterCatalogJson = master.Commits.First()["apis/apis.json"].Target.Peel <Blob>().GetContentText(); return(JsonConvert.DeserializeObject <List <ApiMetadata> >(masterCatalogJson)); } }
public IEnumerable <ReleaseProposal> GetProposals(ApiCatalog catalog) { var root = DirectoryLayout.DetermineRootDirectory(); using var repo = new Repository(root); foreach (var api in catalog.Apis) { if (!_apis.Contains(api.Id)) { continue; } var newVersion = api.StructuredVersion.AfterIncrement(); yield return(ReleaseProposal.CreateFromHistory(repo, api.Id, newVersion)); } }
private static void ValidateLocalRepository(GitHubCommit expectedCommit) { var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new LibGit2Sharp.Repository(root)) { string tip = repo.Head.Tip.Sha; if (tip != expectedCommit.Sha) { throw new UserErrorException($"Current local commit: {tip}. Aborting."); } var status = repo.RetrieveStatus("apis/apis.json"); if (status != FileStatus.Unaltered) { throw new UserErrorException($"Expected apis.json to be unaltered. Current status: {status}. Aborting."); } } }
static void Main(string[] args) { bool attentionOnly = args.Contains("--attention"); var root = DirectoryLayout.DetermineRootDirectory(); var catalog = ApiCatalog.Load(); using (var repo = new Repository(root)) { var diff = repo.Diff; var tags = repo.Tags; var shaToTimestamp = repo.Commits.ToDictionary(commit => commit.Sha, commit => commit.Committer.When); foreach (var api in catalog.Apis) { DisplayApi(attentionOnly, api, tags, shaToTimestamp); } } }
private static void Execute(string id) { var catalog = ApiMetadata.LoadApis(); var api = catalog.FirstOrDefault(x => x.Id == id) ?? throw new UserErrorException($"Unknown API: {id}"); string historyFilePath = Path.Combine(DirectoryLayout.ForApi(id).DocsSourceDirectory, MarkdownFile); var root = DirectoryLayout.DetermineRootDirectory(); using (var repo = new Repository(root)) { var releases = LoadReleases(repo, api).ToList(); if (!File.Exists(historyFilePath)) { File.WriteAllText(historyFilePath, "# Version history\r\n\r\n"); } var historyFile = HistoryFile.Load(historyFilePath); historyFile.MergeReleases(releases); historyFile.Save(historyFilePath); } }
public static void RewriteRenovate(ApiCatalog catalog) { var root = DirectoryLayout.DetermineRootDirectory(); var config = new JObject { ["extends"] = new JArray { "config:base" }, ["ignorePaths"] = new JArray(catalog.Apis.Select(api => $"apis/{api.Id}/{api.Id}/**").ToArray()), ["packageRules"] = new JArray { new JObject { ["matchPaths"] = new JArray { "apis/Google.Cloud.Diagnostics.AspNetCore/**" }, ["matchPackagePrefixes"] = new JArray { "Microsoft.AspNetCore.", "Microsoft.Extensions." }, ["allowedVersions"] = "<2.2.0" }, new JObject { ["matchPaths"] = new JArray { "apis/Google.Cloud.Diagnostics.AspNetCore3/**" }, ["matchPackagePrefixes"] = new JArray { "Microsoft.AspNetCore." }, ["allowedVersions"] = "<3.2.0" }, }, ["schedule"] = new JArray { "before 8am" }, ["timezone"] = "Europe/London" }; string json = config.ToString(Formatting.Indented); File.WriteAllText(Path.Combine(root, ".github", "renovate.json"), json); }