diff --git a/.gitignore b/.gitignore index 693ba6e3e21..1fea628efd3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,9 @@ src/docfx/Template/*.zip src/docfx.website.themes/default/styles/docfx.vendor.css src/docfx.website.themes/default/styles/docfx.vendor.js src/docfx.website.themes/default/fonts/ + +############### +# deploy # +############### +tools/Deployment/.vscode/ +tools/Deployment/out/ \ No newline at end of file diff --git a/tools/Deployment/config_gulp.json b/tools/Deployment/config_gulp.json index 6ee9bffad18..1aa126eb15e 100644 --- a/tools/Deployment/config_gulp.json +++ b/tools/Deployment/config_gulp.json @@ -1,11 +1,16 @@ { "docfx": { - "home": "../../../docfx/", + "home": "../../", + "repoUrl": "git@github.com-ci:dotnet/docfx.git", "docfxSeedHome": "../../../docfx-seed/", "e2eTestsHome": "../../test/docfx.E2E.Tests/", "targetFolder": "../../target/", "artifactsFolder": "../../artifacts/", - "exe": "../../target/Release/docfx/docfx.exe" + "exe": "../../target/Release/docfx/docfx.exe", + "releaseNotePath": "../../RELEASENOTE.md", + "releaseFolder": "../../target/Release/docfx", + "assetZipPath": "../../Documentation/_site/tutorial/artifacts/docfx.zip", + "siteFolder": "../../Documentation/_site" }, "myget": { "exe": "C:/nuget.exe", @@ -15,5 +20,19 @@ }, "firefox": { "version": "46.0.1" + }, + "choco": { + "homeDir": "../../src/nuspec/chocolatey/docfx/", + "nuspec": "../../src/nuspec/chocolatey/docfx/docfx.nuspec", + "chocoScript": "../../src/nuspec/chocolatey/docfx/tools/chocolateyinstall.ps1" + }, + "git": { + "name": "DocFX CI", + "email": "vscopbld@microsoft.com", + "message": "Update gh-pages" + }, + "sync": { + "fromBranch": "dev", + "targetBranch": "stable" } -} +} \ No newline at end of file diff --git a/tools/Deployment/gulpfile.js b/tools/Deployment/gulpfile.js index c030e286e9d..16c537b11ef 100644 --- a/tools/Deployment/gulpfile.js +++ b/tools/Deployment/gulpfile.js @@ -10,9 +10,15 @@ let del = require("del"); let glob = require("glob"); let gulp = require("gulp"); let nconf = require("nconf"); -let spawn = require("child-process-promise").spawn; -let configFile = path.join(__dirname, "config_gulp.json"); +let Common = require("./out/common").Common; +let Guard = require("./out/common").Guard; +let Myget = require("./out/myget").Myget; +let Github = require("./out/github").Github; +let Chocolatey = require("./out/chocolatey").Chocolatey; + +let configFile = path.resolve("config_gulp.json"); + if (!fs.existsSync(configFile)) { throw new Error("Can't find config file"); } @@ -22,70 +28,29 @@ nconf.add("configuration", { type: "file", file: configFile }); let config = { "docfx": nconf.get("docfx"), "firefox": nconf.get("firefox"), - "myget": nconf.get("myget") + "myget": nconf.get("myget"), + "git": nconf.get("git"), + "choco": nconf.get("choco") }; -if (!config.docfx) { - throw new Error("Can't find docfx configuration."); -} - -if (!config.firefox) { - throw new Error("Can't find firefox configuration."); -} - -if (!config.myget) { - throw new Error("Can't find myget configuration."); -} - -config.myget["apiKey"] = process.env.MGAPIKEY; - -function exec(command, args, workDir) { - let cwd = process.cwd(); - if (workDir) { - process.chdir(path.join(__dirname, workDir)); - } - - let promise = spawn(command, args); - let childProcess = promise.childProcess; - childProcess.stdout.on("data", (data) => { - process.stdout.write(data.toString()); - }); - childProcess.stderr.on("data", (data) => { - process.stderr.write(data.toString()); - }) - return promise.then(() => { - process.chdir(cwd); - }); -} - -function publish(artifactsFolder, mygetCommand, mygetKey, mygetUrl) { - let packages = glob.sync(artifactsFolder + "/**/!(*.symbols).nupkg"); - let promises = packages.map(p => { - return exec(mygetCommand, ["push", p, mygetKey, "-Source", mygetUrl]); - }); - return Promise.all(promises); -} +Guard.argumentNotNull(config.docfx, "config.docfx", "Can't find docfx configuration."); +Guard.argumentNotNull(config.firefox, "config.docfx", "Can't find firefox configuration."); +Guard.argumentNotNull(config.myget, "config.docfx", "Can't find myget configuration."); +Guard.argumentNotNull(config.git, "config.docfx", "Can't find git configuration."); +Guard.argumentNotNull(config.choco, "config.docfx", "Can't find choco configuration."); gulp.task("build", () => { - if (!config.docfx || !config.docfx["home"]) { - throw new Error("Can't find docfx home directory in configuration."); - } + Guard.argumentNotNullOrEmpty(config.docfx.home, "config.docfx.home", "Can't find docfx home directory in configuration."); - return exec("powershell", ["./build.ps1", "-prod"], config.docfx["home"]); + return Common.execAsync("powershell", ["./build.ps1", "-prod"], config.docfx.home); }); gulp.task("clean", () => { - if (!config.docfx["artifactsFolder"]) { - throw new Error("Can't find docfx artifacts folder in configuration."); - } - - let artifactsFolder = path.join(__dirname, config.docfx["artifactsFolder"]); + Guard.argumentNotNullOrEmpty(config.docfx.artifactsFolder, "config.docfx.artifactsFolder", "Can't find docfx artifacts folder in configuration."); + Guard.argumentNotNullOrEmpty(config.docfx.targetFolder, "config.docfx.targetFolder", "Can't find docfx target folder in configuration."); - if (!config.docfx["targetFolder"]) { - throw new Error("Can't find docfx target folder in configuration."); - } - - let targetFolder = path.join(__dirname, config.docfx["targetFolder"]); + let artifactsFolder = path.resolve(config.docfx.artifactsFolder); + let targetFolder = path.resolve(config.docfx["targetFolder"]); return del([artifactsFolder, targetFolder], { force: true }).then((paths) => { if (!paths || paths.length === 0) { @@ -97,108 +62,133 @@ gulp.task("clean", () => { }); gulp.task("e2eTest:installFirefox", () => { - if (!config.firefox["version"]) { - throw new Error("Can't find firefox version in configuration."); - } + Guard.argumentNotNullOrEmpty(config.firefox.version, "config.firefox.version", "Can't find firefox version in configuration."); - return exec("choco", ["install", "firefox", "--version=" + config.firefox["version"], "-y"]); + return Common.execAsync("choco", ["install", "firefox", "--version=" + config.firefox.version, "-y"]); }); gulp.task("e2eTest:buildSeed", () => { - if (!config.docfx["exe"]) { - throw new Error("Can't find docfx.exe in configuration."); - } - - if (!config.docfx["docfxSeedHome"]) { - throw new Error("Can't find docfx-seed in configuration."); - } + Guard.argumentNotNullOrEmpty(config.docfx.exe, "config.docfx.exe", "Can't find docfx.exe in configuration."); + Guard.argumentNotNullOrEmpty(config.docfx.docfxSeedHome, "config.docfx.docfxSeedHome", "Can't find docfx-seed in configuration."); - return exec(path.join(__dirname, config.docfx["exe"]), ["docfx.json"], config.docfx["docfxSeedHome"]); + return Common.execAsync(path.resolve(config.docfx["exe"]), ["docfx.json"], config.docfx.docfxSeedHome); }); gulp.task("e2eTest:restore", () => { - if (!config.docfx["e2eTestsHome"]) { - throw new Error("Can't find E2ETest directory in configuration."); - } + Guard.argumentNotNullOrEmpty(config.docfx.e2eTestsHome, "config.docfx.e2eTestsHome", "Can't find E2ETest directory in configuration."); - return exec("dotnet", ["restore"], config.docfx["e2eTestsHome"]); + return Common.execAsync("dotnet", ["restore"], config.docfx.e2eTestsHome); }); gulp.task("e2eTest:test", () => { - if (!config.docfx["e2eTestsHome"]) { - throw new Error("Can't find E2ETest directory in configuration."); - } + Guard.argumentNotNullOrEmpty(config.docfx.e2eTestsHome, "config.docfx.e2eTestsHome", "Can't find E2ETest directory in configuration."); - return exec("dotnet", ["test"], config.docfx["e2eTestsHome"]); + return Common.execAsync("dotnet", ["test"], config.docfx.e2eTestsHome); }); gulp.task("e2eTest", gulp.series("e2eTest:installFirefox", "e2eTest:buildSeed", "e2eTest:restore", "e2eTest:test")); gulp.task("publish:myget-dev", () => { - if (!config.docfx["artifactsFolder"]) { - throw new Error("Can't find artifacts folder in configuration."); - } - - if (!config.myget["exe"]) { - throw new Error("Can't find nuget command in configuration."); - } - - if (!config.myget["apiKey"]) { - throw new Error("Can't find myget api key in configuration."); - } - - if (!config.myget["devUrl"]) { - throw new Error("Can't find myget url for docfx dev feed in configuration."); - } - - let artifactsFolder = path.join(__dirname, config.docfx["artifactsFolder"]); - return publish(artifactsFolder, config.myget["exe"], config.myget["apiKey"], config.myget["devUrl"]); + Guard.argumentNotNullOrEmpty(config.docfx.artifactsFolder, "config.docfx.artifactsFolder", "Can't find artifacts folder in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.exe, "config.myget.exe", "Can't find nuget command in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.apiKey, "config.myget.apiKey", "Can't find myget api key in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.devUrl, "config.myget.devUrl", "Can't find myget url for docfx dev feed in configuration."); + Guard.argumentNotNullOrEmpty(process.env.MGAPIKEY, "process.env.MGAPIKEY", "Can't find myget key in Environment Variables."); + + let mygetToken = process.env.MGAPIKEY; + let artifactsFolder = path.resolve(config.docfx["artifactsFolder"]); + return Myget.publishToMygetAsync(artifactsFolder, config.myget["exe"], mygetToken, config.myget["devUrl"]); }); gulp.task("publish:myget-test", () => { - if (!config.docfx["artifactsFolder"]) { - throw new Error("Can't find artifacts folder in configuration."); - } - - if (!config.myget["exe"]) { - throw new Error("Can't find nuget command in configuration."); - } - - if (!config.myget["apiKey"]) { - throw new Error("Can't find myget api key in configuration."); - } + Guard.argumentNotNullOrEmpty(config.docfx.artifactsFolder, "config.docfx.artifactsFolder", "Can't find artifacts folder in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.exe, "config.myget.exe", "Can't find nuget command in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.apiKey, "config.myget.apiKey", "Can't find myget api key in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.testUrl, "config.myget.testUrl", "Can't find myget url for docfx test feed in configuration."); + Guard.argumentNotNullOrEmpty(process.env.MGAPIKEY, "process.env.MGAPIKEY", "Can't find myget key in Environment Variables."); + + let artifactsFolder = path.resolve(config.docfx["artifactsFolder"]); + return Myget.publishToMygetAsync(artifactsFolder, config.myget["exe"], mygetToken, config.myget["testUrl"]); +}); - if (!config.myget["testUrl"]) { - throw new Error("Can't find myget url for docfx test feed in configuration."); - } +gulp.task("publish:myget-master", () => { + Guard.argumentNotNullOrEmpty(config.docfx.artifactsFolder, "config.docfx.artifactsFolder", "Can't find artifacts folder in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.exe, "config.myget.exe", "Can't find nuget command in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.apiKey, "config.myget.apiKey", "Can't find myget api key in configuration."); + Guard.argumentNotNullOrEmpty(config.myget.masterUrl, "config.myget.masterUrl", "Can't find myget url for docfx master feed in configuration."); + Guard.argumentNotNullOrEmpty(process.env.MGAPIKEY, "process.env.MGAPIKEY", "Can't find myget key in Environment Variables."); + Guard.argumentNotNullOrEmpty(config.docfx.releaseNotePath, "config.docfx.releaseNotePath", "Can't find RELEASENOTE.md in configuartion."); + + let releaseNotePath = path.resolve(config.docfx["releaseNotePath"]); + let artifactsFolder = path.resolve(config.docfx["artifactsFolder"]); + return Myget.publishToMygetAsync(artifactsFolder, config.myget["exe"], mygetToken, config.myget["masterUrl"], releaseNotePath); +}); - let artifactsFolder = path.join(__dirname, config.docfx["artifactsFolder"]); - return publish(artifactsFolder, config.myget["exe"], config.myget["apiKey"], config.myget["testUrl"]); +gulp.task("updateGhPage", () => { + Guard.argumentNotNullOrEmpty(config.docfx.repoUrl, "config.docfx.repoUrl", "Can't find docfx repo url in configuration."); + Guard.argumentNotNullOrEmpty(config.docfx.siteFolder, "config.docfx.siteFolder", "Can't find docfx site folder in configuration."); + Guard.argumentNotNullOrEmpty(config.git.name, "config.git.name", "Can't find git user name in configuration"); + Guard.argumentNotNullOrEmpty(config.git.email, "config.git.email", "Can't find git user email in configuration"); + Guard.argumentNotNullOrEmpty(config.git.message, "config.git.message", "Can't find git commit message in configuration"); + + let promise = Github.updateGhPagesAsync(config.docfx.repoUrl, config.docfx.siteFolder, config.git.name, config.git.email, config.git.message); + promise.then(() => { + console.log("Update github pages successfully."); + }).catch(err => { + console.log(`Failed to update github pages, ${err}`); + process.exit(1); + }) }); -gulp.task("publish:myget-master", () => { - if (!config.docfx["artifactsFolder"]) { - throw new Error("Can't find artifacts folder in configuration."); - } +gulp.task("publish:gh-release", () => { + Guard.argumentNotNullOrEmpty(config.docfx.releaseNotePath, "config.docfx.releaseNotePath", "Can't find RELEASENOTE.md in configuartion."); + Guard.argumentNotNullOrEmpty(config.docfx.releaseFolder, "config.docfx.releaseFolder", "Can't find zip source folder in configuration."); + Guard.argumentNotNullOrEmpty(config.docfx.assetZipPath, "config.docfx.assetZipPath", "Can't find asset zip destination folder in configuration."); + Guard.argumentNotNullOrEmpty(process.env.TOKEN, "process.env.TOKEN", "No github account token in the environment."); - if (!config.myget["exe"]) { - throw new Error("Can't find nuget command in configuration."); - } + let githubToken = process.env.TOKEN; - if (!config.myget["apiKey"]) { - throw new Error("Can't find myget api key in configuration."); - } + let releaseNotePath = path.resolve(config.docfx["releaseNotePath"]); + let releaseFolder = path.resolve(config.docfx["releaseFolder"]); + let assetZipPath = path.resolve(config.docfx["assetZipPath"]); - if (!config.myget["masterUrl"]) { - throw new Error("Can't find myget url for docfx master feed in configuration."); - } + let promise = Github.updateGithubReleaseAsync(config.docfx["repoUrl"], releaseNotePath, releaseFolder, assetZipPath, githubToken); + promise.then(() => { + console.log("Update github release and assets successfully."); + }).catch(err => { + console.log(`Failed to update github release and assets, ${err}`); + process.exit(1); + }); +}); - let artifactsFolder = path.join(__dirname, config.docfx["artifactsFolder"]); - return publish(artifactsFolder, config.myget["exe"], config.myget["apiKey"], config.myget["masterUrl"]); +gulp.task("publish:chocolatey", () => { + Guard.argumentNotNullOrEmpty(config.choco.homeDir, "config.choco.homeDir", "Can't find homedir for chocolatey in configuration."); + Guard.argumentNotNullOrEmpty(config.choco.nuspec, "config.choco.nuspec", "Can't find nuspec for chocolatey in configuration."); + Guard.argumentNotNullOrEmpty(config.choco.chocoScript, "config.choco.chocoScript", "Can't find script for chocolatey in configuration."); + Guard.argumentNotNullOrEmpty(config.docfx.releaseNotePath, "config.docfx.releaseNotePath", "Can't find RELEASENOTE path in configuration."); + Guard.argumentNotNullOrEmpty(config.docfx.assetZipPath, "config.docfx.assetZipPath", "Can't find released zip path in configuration."); + Guard.argumentNotNullOrEmpty(process.env.CHOCO_TOKEN, "process.env.CHOCO_TOKEN", "No chocolatey.org account token in the environment."); + + let chocoToken = process.env.CHOCO_TOKEN; + + let releaseNotePath = path.resolve(config.docfx["releaseNotePath"]); + let assetZipPath = path.resolve(config.docfx["assetZipPath"]); + + let chocoScript = path.resolve(config.choco["chocoScript"]); + let nuspec = path.resolve(config.choco["nuspec"]); + let homeDir = path.resolve(config.choco["homeDir"]); + + let promise = Chocolatey.publishToChocolateyAsync(releaseNotePath, assetZipPath, chocoScript, nuspec, homeDir, chocoToken); + promise.then(() => { + console.log("Publish to chocolatey successfully."); + }).catch(err => { + console.log(`Failed to publish to chocolatey, ${err}`); + process.exit(1); + }); }); gulp.task("test", gulp.series("clean", "build", "e2eTest", "publish:myget-test")); gulp.task("dev", gulp.series("clean", "build", "e2eTest")); gulp.task("stable", gulp.series("clean", "build", "e2eTest", "publish:myget-dev")); - +gulp.task("master", gulp.series("clean", "build", "e2eTest", "updateGhPage", "publish:gh-release", "publish:chocolatey", "publish:myget-master")); gulp.task("default", gulp.series("dev")); diff --git a/tools/Deployment/lib/chocolatey.ts b/tools/Deployment/lib/chocolatey.ts new file mode 100644 index 00000000000..4fc21092333 --- /dev/null +++ b/tools/Deployment/lib/chocolatey.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import * as fs from "fs"; + +import { Common, Guard } from "./common"; + +export class Chocolatey { + public static async publishToChocolateyAsync( + releaseNotePath: string, + assetZipPath: string, + chocoScriptPath: string, + chocoNuspecPath: string, + chocoHomeDir: string, + chocoToken: string): Promise { + + Guard.argumentNotNullOrEmpty(releaseNotePath, "releaseNotePath"); + Guard.argumentNotNullOrEmpty(assetZipPath, "assetZipPath"); + Guard.argumentNotNullOrEmpty(chocoScriptPath, "chocoScriptPath"); + Guard.argumentNotNullOrEmpty(chocoNuspecPath, "chocoNuspecPath"); + Guard.argumentNotNullOrEmpty(chocoHomeDir, "chocoHomeDir"); + Guard.argumentNotNullOrEmpty(chocoToken, "chocoToken"); + + // Ignore to publish chocolatey package if RELEASENOTE.md hasn't been modified. + let isUpdated = await Common.isReleaseNoteVersionChangedAsync(releaseNotePath); + if (!isUpdated) { + console.log(`${releaseNotePath} hasn't been changed. Ignore to publish package to chocolatey.`); + return Promise.resolve(); + } + + let version = Common.getVersionFromReleaseNote(releaseNotePath); + let sha1 = Common.computeSha1FromZip(assetZipPath); + let nupkgName = `docfx.${version}.nupkg`; + + this.updateChocoConfig(chocoScriptPath, chocoNuspecPath, version, sha1); + await this.chocoPackAsync(chocoHomeDir); + await this.prepareChocoAsync(chocoHomeDir, chocoToken); + + return Common.execAsync("choco", ["push", nupkgName], chocoHomeDir); + } + + private static async chocoPackAsync(homeDir: string): Promise { + return Common.execAsync("choco", ['pack'], homeDir); + } + + private static async prepareChocoAsync(homeDir: string, chocoToken: string): Promise { + return Common.execAsync("choco", ["apiKey", "-k", chocoToken, "-source", "https://chocolatey.org/", homeDir]); + } + + private static updateChocoConfig(scriptPath: string, nuspecPath: string, version: string, sha1: string) { + let chocoScriptContent = fs.readFileSync(scriptPath, "utf8"); + chocoScriptContent = chocoScriptContent + .replace(/v[\d\.]+/, "v" + version) + .replace(/(\$sha1\s*=\s*['"])([\d\w]+)(['"])/, `$1${sha1}$3`); + fs.writeFileSync(scriptPath, chocoScriptContent); + + let nuspecContent = fs.readFileSync(nuspecPath, "utf8"); + nuspecContent = nuspecContent.replace(/()[\d\.]+(<\/version>)/, `$1${version}$2`); + fs.writeFileSync(nuspecPath, nuspecContent); + } +} diff --git a/tools/Deployment/lib/common.ts b/tools/Deployment/lib/common.ts new file mode 100644 index 00000000000..8f580df2a2b --- /dev/null +++ b/tools/Deployment/lib/common.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import * as fs from "fs"; +import * as path from "path"; + +import * as cp from "child-process-promise"; +import * as jszip from "jszip"; +import * as sha1 from "sha1"; + +export class Common { + static async execAsync(command: string, args: Array, workDir = null):Promise { + Guard.argumentNotNullOrEmpty(command, "command"); + Guard.argumentNotNull(args, "args"); + + let cwd = process.cwd(); + if (workDir) { + if (!path.isAbsolute(workDir)) { + workDir = path.join(cwd, workDir); + } + if (!fs.existsSync(workDir)) { + throw new Error(`Can't find ${workDir}.`); + } + + process.chdir(workDir); + } + + let promise = cp.spawn(command, args); + let childProcess = promise.childProcess; + childProcess.stdout.on("data", (data) => { + process.stdout.write(data.toString()); + }); + childProcess.stderr.on("data", (data) => { + process.stderr.write(data.toString()); + }) + return promise.then(() => { + process.chdir(cwd); + }); + } + + static zipAssests(assetFolder: string, targetPath: string) { + Guard.argumentNotNullOrEmpty(assetFolder, "assetFolder"); + Guard.argumentNotNullOrEmpty(targetPath, "targetPath"); + + let zip = new jszip(); + + fs.readdirSync(assetFolder).forEach(file => { + let filePath = path.join(assetFolder, file); + if (fs.lstatSync(filePath).isFile()) { + let ext = path.extname(filePath); + if (ext !== '.xml' && ext !== '.pdb') { + let content = fs.readFileSync(filePath); + zip.file(file, content); + } + } + }); + + let buffer = zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); + + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + } + + fs.writeFileSync(targetPath, buffer); + } + + static computeSha1FromZip(zipPath: string): string { + Guard.argumentNotNullOrEmpty(zipPath, "zipPath"); + + if (!fs.existsSync(zipPath)) { + throw new Error(`${zipPath} doesn't exist.`); + } + + let buffer = fs.readFileSync(zipPath); + return sha1(buffer); + } + + static getVersionFromReleaseNote(releaseNotePath: string): string { + Guard.argumentNotNullOrEmpty(releaseNotePath, "releaseNotePath"); + + if (!fs.existsSync(releaseNotePath)) { + throw new Error(`${releaseNotePath} doesn't exist.`); + } + + let regex = /\(Current\s+Version:\s+v([\d\.]+)\)/i; + let content = fs.readFileSync(releaseNotePath, "utf8"); + + let match = regex.exec(content); + if (!match || match.length < 2) { + throw new Error(`Can't parse version from ${releaseNotePath} in current version part.`); + } + + return match[1].trim(); + } + + static getDescriptionFromReleaseNote(releaseNotePath: string): string { + Guard.argumentNotNullOrEmpty(releaseNotePath, "releaseNotePath"); + + if (!fs.existsSync(releaseNotePath)) { + throw new Error(`${releaseNotePath} doesn't exist.`); + } + + let regex = /\n\s*v[\d\.]+\s*\r?\n-{3,}\r?\n([\s\S]+?)(?:\r?\n\s*v[\d\.]+\s*\r?\n-{3,}|$)/i; + let content = fs.readFileSync(releaseNotePath, "utf8"); + + let match = regex.exec(content); + if (!match || match.length < 2) { + throw new Error(`Can't parse description from ${releaseNotePath} in current version part.`); + } + + return match[1].trim(); + } + + static async isReleaseNoteVersionChangedAsync(releaseNotePath: string): Promise { + let versionFromTag = await this.getCurrentVersionFromGitTag(); + let versionFromReleaseNote = this.getVersionFromReleaseNote(releaseNotePath); + + return `v${versionFromReleaseNote}`.toLowerCase() !== versionFromTag.toLowerCase(); + } + + static async getCurrentVersionFromGitTag(): Promise { + let result = await cp.exec("git describe --abbrev=0 --tags"); + let content = result.stdout.trim(); + if (!content) { + return null; + } + + return content; + } +} + +export class Guard { + static argumentNotNull(argumentValue: Object, argumentName: string, message = null) { + if (argumentValue === null || argumentValue === undefined) { + message = message || `${argumentName} can't be null/undefined.`; + throw new Error(message); + } + } + + static argumentNotNullOrEmpty(stringValue: string, argumentName: string, message = null) { + if (stringValue === null || stringValue == undefined || stringValue === "") { + message = message || `${argumentName} can't be null/undefined or empty string.`; + throw new Error(message); + } + } +} \ No newline at end of file diff --git a/tools/Deployment/lib/github.ts b/tools/Deployment/lib/github.ts new file mode 100644 index 00000000000..d66a3175013 --- /dev/null +++ b/tools/Deployment/lib/github.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import * as fs from "fs-extra"; +import * as path from "path"; + +import { Common, Guard } from "./common"; +import { GithubApi, AssetInfo, ReleaseDescription } from "./githubApi"; + +export class Github { + static async updateGithubReleaseAsync( + repoUrl: string, + releaseNotePath: string, + releaseFolder: string, + assetZipPath: string, + githubToken: string): Promise { + + Guard.argumentNotNullOrEmpty(repoUrl, "repoUrl"); + Guard.argumentNotNullOrEmpty(releaseNotePath, "releaseNotePath"); + Guard.argumentNotNullOrEmpty(releaseFolder, "releaseFolder"); + Guard.argumentNotNullOrEmpty(assetZipPath, "assetZipPath"); + Guard.argumentNotNullOrEmpty(githubToken, "githubToken"); + + let isUpdated = await Common.isReleaseNoteVersionChangedAsync(releaseNotePath); + if (!isUpdated) { + console.log(`${releaseNotePath} hasn't been changed. Ignored to update github release package.`); + return Promise.resolve(); + } + + Common.zipAssests(releaseFolder, assetZipPath); + let githubApi = new GithubApi(repoUrl, githubToken); + let releaseDescription = this.getReleaseDescription(releaseNotePath); + + let data = fs.readFileSync(releaseNotePath); + let arrayBuffer = new Uint8Array(data).buffer; + let assetInfo = this.getAssetZipInfo(assetZipPath); + + return githubApi.publishReleaseAndAssetAsync(releaseDescription, assetInfo); + } + + static async updateGhPagesAsync( + repoUrl: string, + siteFolder: string, + gitUserName: string, + gitUserEmail: string, + gitCommitMessage: string) { + + Guard.argumentNotNullOrEmpty(repoUrl, "repoUrl"); + Guard.argumentNotNullOrEmpty(siteFolder, "siteFolder"); + Guard.argumentNotNullOrEmpty(gitUserName, "gitUserName"); + Guard.argumentNotNullOrEmpty(gitUserEmail, "gitUserEmail"); + Guard.argumentNotNullOrEmpty(gitCommitMessage, "gitCommitMessage"); + + let branch = "gh-pages"; + let targetDir = "docfxsite"; + + this.cleanGitInfo(siteFolder); + + await Common.execAsync("git", ["clone", repoUrl, "-b", branch, targetDir]); + fs.mkdirsSync(path.join(siteFolder, ".git")); + fs.copySync(path.join(targetDir, ".git"), path.join(siteFolder, ".git")); + + await Common.execAsync("git", ["config", "user.name", gitUserName], siteFolder); + await Common.execAsync("git", ["config", "user.email", gitUserEmail], siteFolder); + await Common.execAsync("git", ["add", "."], siteFolder); + await Common.execAsync("git", ["commit", "-m", gitCommitMessage], siteFolder); + return Common.execAsync("git", ["push", "origin", branch], siteFolder); + } + + private static getReleaseDescription(releaseNotePath: string): ReleaseDescription { + let version = Common.getVersionFromReleaseNote(releaseNotePath); + let description = Common.getDescriptionFromReleaseNote(releaseNotePath); + let releaseDescription = { + "tag_name": version, + "target_commitish": "master", + "name": `Version ${version}`, + "body": description + }; + + return releaseDescription; + } + + private static getAssetZipInfo(assetPath: string, assetName = null): AssetInfo { + let data = fs.readFileSync(assetPath); + let arrayBuffer = new Uint8Array(data).buffer; + let assetInfo = { + "contentType": "application/zip", + "name": assetName || path.basename(assetPath), + "data": arrayBuffer + }; + + return assetInfo; + } + + private static cleanGitInfo(repoRootFolder: string) { + let gitFolder = path.join(repoRootFolder, ".git"); + fs.removeSync(gitFolder); + } +} \ No newline at end of file diff --git a/tools/Deployment/lib/githubApi.ts b/tools/Deployment/lib/githubApi.ts new file mode 100644 index 00000000000..8ed620e8eec --- /dev/null +++ b/tools/Deployment/lib/githubApi.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import * as axios from "axios"; +import { Guard } from "./common"; + +export class GithubApi { + private readonly request; + private readonly userAndRepo; + + constructor(repoUrl: string, token: string) { + this.userAndRepo = this.getUserAndRepo(repoUrl); + this.request = axios.create({ + baseURL: "https://api.github.com", + headers: { + 'User-Agent': 'axios', + 'Authorization': `token ${token}` + } + }); + } + + async publishReleaseAndAssetAsync(releaseDescription: ReleaseDescription, assetInfo: AssetInfo) { + Guard.argumentNotNull(releaseDescription, "releaseDescription"); + Guard.argumentNotNull(assetInfo, "assetInfo"); + + await this.publishReleaseAsync(releaseDescription); + return this.publishAssetAsync(assetInfo); + } + + // publish asset to latest release + async publishAssetAsync(info: AssetInfo) { + const latestReleaseInfo = await this.getLatestReleaseAsync(); + if (latestReleaseInfo.data["assets"]) { + const assets = latestReleaseInfo.data["assets"]; + assets.forEach(async item => { + if (item["name"] === info.name) { + await this.deleteAssetByUrlAsync(item["url"]); + } + }); + } + return this.uploadAssetAsync(latestReleaseInfo.data["id"], info); + } + + // publish release + async publishReleaseAsync(description: ReleaseDescription) { + let latestReleaseInfo; + try { + latestReleaseInfo = await this.getLatestReleaseAsync(); + } catch (err) { + // no release exists + if (err.response.status === 404) { + return this.createReleaseAsync(description); + } + } + if (latestReleaseInfo.data["tag_name"] === description.tag_name) { + return this.updateReleaseAsync(latestReleaseInfo.data["id"], description); + } else { + return this.createReleaseAsync(description); + } + } + + async deleteLatestReleaseAsync(releaseId: string) { + const releaseInfo = await this.getLatestReleaseAsync(); + const config = { + url: `/repos/${this.userAndRepo}/releases/${releaseInfo.data["id"]}`, + method: "DELETE", + }; + return this.request(config); + } + + private async createReleaseAsync(releaseInfo: ReleaseDescription) { + const config = { + url: `/repos/${this.userAndRepo}/releases`, + method: "POST", + data: releaseInfo + }; + return this.request(config); + } + + private async getLatestReleaseAsync() { + const config = { + url: `/repos/${this.userAndRepo}/releases/latest`, + method: "GET", + }; + return this.request(config); + } + + private async updateReleaseAsync(releaseId: string, description: ReleaseDescription) { + const config = { + url: `/repos/${this.userAndRepo}/releases/${releaseId}`, + method: "PATCH", + data: description + }; + return this.request(config); + } + + private async deleteReleaseAsync(releaseId: string) { + const config = { + url: `/repos/${this.userAndRepo}/releases/${releaseId}`, + method: "DELETE", + }; + return this.request(config); + } + + private async uploadAssetAsync(releaseId: string, info: AssetInfo) { + const config = { + url: `/repos/${this.userAndRepo}/releases/${releaseId}/assets?name=${info.name}`, + baseURL: "https://uploads.github.com/", + method: "POST", + headers: { + 'Content-Type': info.contentType, + }, + data: info.data + } + return this.request(config); + } + + private async deleteAssetByUrlAsync(assetUrl: string) { + const config = { + url: assetUrl, + method: "DELETE", + } + return this.request(config); + } + + private getUserAndRepo(repoUrl: string): string { + const regex = /^git@(.+):(.+?)(\.git)?$/; + let match = regex.exec(repoUrl); + + if (!match || match.length < 3) { + throw new Error(`Can't parse ${repoUrl}`); + } + + return match[2]; + } +} + +export interface ReleaseDescription { + tag_name: string; + target_commitish?: string; + name?: string; + body?: string; + draft?: string; + prelease?: boolean; +} + +export interface AssetInfo { + contentType: string; + name: string; + data: ArrayBuffer; + lable?: string; +} \ No newline at end of file diff --git a/tools/Deployment/lib/myget.ts b/tools/Deployment/lib/myget.ts new file mode 100644 index 00000000000..6a236a07457 --- /dev/null +++ b/tools/Deployment/lib/myget.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import * as glob from "glob"; + +import { Common, Guard } from "./common"; + +export class Myget { + static async publishToMygetAsync( + artifactsFolder: string, + mygetCommand: string, + mygetKey: string, + mygetUrl: string, + releaseNotePath = null): Promise { + + Guard.argumentNotNullOrEmpty(artifactsFolder, "artifactsFolder"); + Guard.argumentNotNullOrEmpty(mygetCommand, "mygetCommand"); + Guard.argumentNotNullOrEmpty(mygetKey, "mygetKey"); + Guard.argumentNotNullOrEmpty(mygetUrl, "mygetUrl"); + + if (!releaseNotePath) { + // Ignore to publish myget package if RELEASENOTE.md hasn't been modified. + let isUpdated = await Common.isReleaseNoteVersionChangedAsync(releaseNotePath); + if (!isUpdated) { + console.log(`${releaseNotePath} hasn't been changed. Ignore to publish package to myget.org.`); + return Promise.resolve(); + } + } + + let packages = glob.sync(artifactsFolder + "/**/!(*.symbols).nupkg"); + let promises = packages.map(p => { + return Common.execAsync(mygetCommand, ["push", p, mygetKey, "-Source", mygetUrl]); + }); + + await Promise.all(promises); + return Promise.resolve(); + } +} \ No newline at end of file diff --git a/tools/Deployment/package.json b/tools/Deployment/package.json index cd7d1b43162..c014ef55a0e 100644 --- a/tools/Deployment/package.json +++ b/tools/Deployment/package.json @@ -18,6 +18,9 @@ "author": "Microsoft", "license": "MIT", "dependencies": { + "@types/axios": "^0.9.36", + "@types/node": "^7.0.12", + "axios": "^0.16.1", "child-process-promise": "^2.2.0", "colors": "^1.1.2", "commander": "^2.9.0", diff --git a/tools/Deployment/tsconfig.json b/tools/Deployment/tsconfig.json new file mode 100644 index 00000000000..ee933f92e58 --- /dev/null +++ b/tools/Deployment/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2016", + "noImplicitAny": false, + "sourceMap": true, + "sourceRoot": "lib", + "outDir": "out" + } +} \ No newline at end of file