diff --git a/eng/common/TestResources/New-TestResources.ps1 b/eng/common/TestResources/New-TestResources.ps1 index 29c404528bf3..9e281d04eb66 100644 --- a/eng/common/TestResources/New-TestResources.ps1 +++ b/eng/common/TestResources/New-TestResources.ps1 @@ -33,6 +33,10 @@ param ( [ValidateNotNullOrEmpty()] [string] $TenantId, + [Parameter(ParameterSetName = 'Provisioner')] + [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] + [string] $SubscriptionId, + [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)] [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] [string] $ProvisionerApplicationId, @@ -48,18 +52,20 @@ param ( [string] $Location = '', [Parameter()] - [ValidateNotNullOrEmpty()] + [ValidateSet('AzureCloud', 'AzureUSGovernment', 'AzureChinaCloud')] [string] $Environment = 'AzureCloud', [Parameter()] - [ValidateNotNullOrEmpty()] [hashtable] $AdditionalParameters, [Parameter()] [switch] $CI = ($null -ne $env:SYSTEM_TEAMPROJECTID), [Parameter()] - [switch] $Force + [switch] $Force, + + [Parameter()] + [switch] $OutFile ) # By default stop for any error. @@ -105,7 +111,8 @@ trap { } # Enumerate test resources to deploy. Fail if none found. -$root = [System.IO.Path]::Combine("$PSScriptRoot/../sdk", $ServiceDirectory) | Resolve-Path +$repositoryRoot = "$PSScriptRoot/../../.." | Resolve-Path +$root = [System.IO.Path]::Combine($repositoryRoot, "sdk", $ServiceDirectory) | Resolve-Path $templateFileName = 'test-resources.json' $templateFiles = @() @@ -126,17 +133,11 @@ if (!$templateFiles) { # environment. If no matching environment is found $Location remains an empty # string. if (!$Location) { - $defaultLocations = @{ + $Location = @{ 'AzureCloud' = 'westus2'; 'AzureUSGovernment' = 'usgovvirginia'; 'AzureChinaCloud' = 'chinaeast2'; - } - - if ($defaultLocations.ContainsKey($Environment)) { - $Location = $defaultLocations[$Environment] - } else { - Write-Error "Location cannot be empty and there is no default location for Environment: '$Environment'" - } + }[$Environment] Write-Verbose "Location was not set. Using default location for environment: '$Location'" } @@ -149,8 +150,13 @@ if ($ProvisionerApplicationId) { $provisionerSecret = ConvertTo-SecureString -String $ProvisionerApplicationSecret -AsPlainText -Force $provisionerCredential = [System.Management.Automation.PSCredential]::new($ProvisionerApplicationId, $provisionerSecret) + # Use the given subscription ID if provided. + $subscriptionArgs = if ($SubscriptionId) { + @{SubscriptionId = $SubscriptionId} + } + $provisionerAccount = Retry { - Connect-AzAccount -Tenant $TenantId -Credential $provisionerCredential -ServicePrincipal -Environment $Environment + Connect-AzAccount -Force:$Force -Tenant $TenantId -Credential $provisionerCredential -ServicePrincipal -Environment $Environment @subscriptionArgs } $exitActions += { @@ -170,19 +176,20 @@ if ($TestApplicationId -and !$TestApplicationOid) { } } + +# If the ServiceDirectory is an absolute path use the last directory name +# (e.g. D:\foo\bar\ -> bar) +$serviceName = if (Split-Path -IsAbsolute $ServiceDirectory) { + Split-Path -Leaf $ServiceDirectory +} else { + $ServiceDirectory +} + # Format the resource group name based on resource group naming recommendations and limitations. $resourceGroupName = if ($CI) { $BaseName = 't' + (New-Guid).ToString('n').Substring(0, 16) Write-Verbose "Generated base name '$BaseName' for CI build" - # If the ServiceDirectory is an absolute path use the last directory name - # (e.g. D:\foo\bar\ -> bar) - $serviceName = if (Split-Path -IsAbsolute $ServiceDirectory) { - Split-Path -Leaf $ServiceDirectory - } else { - $ServiceDirectory - } - "rg-{0}-$BaseName" -f ($serviceName -replace '[\\\/:]', '-').Substring(0, [Math]::Min($serviceName.Length, 90 - $BaseName.Length - 4)).Trim('-') } else { "rg-$BaseName" @@ -279,12 +286,21 @@ foreach ($templateFile in $templateFiles) { Write-Verbose "Successfully deployed template '$templateFile' to resource group '$($resourceGroup.ResourceGroupName)'" } - if ($deployment.Outputs.Count -and !$CI) { - # Write an extra new line to isolate the environment variables for easy reading. - Log "Persist the following environment variables based on your detected shell ($shell):`n" + $serviceDirectoryPrefix = $serviceName.ToUpperInvariant() + "_" + + $context = Get-AzContext; + + # Add default values + $deploymentOutputs = @{ + "$($serviceDirectoryPrefix)CLIENT_ID" = $TestApplicationId; + "$($serviceDirectoryPrefix)CLIENT_SECRET" = $TestApplicationSecret; + "$($serviceDirectoryPrefix)TENANT_ID" = $context.Tenant.Id; + "$($serviceDirectoryPrefix)SUBSCRIPTION_ID" = $context.Subscription.Id; + "$($serviceDirectoryPrefix)RESOURCE_GROUP" = $resourceGroup.ResourceGroupName; + "$($serviceDirectoryPrefix)LOCATION" = $resourceGroup.Location; + "$($serviceDirectoryPrefix)ENVIRONMENT" = $context.Environment.Name; } - $deploymentOutputs = @{} foreach ($key in $deployment.Outputs.Keys) { $variable = $deployment.Outputs[$key] @@ -293,23 +309,54 @@ foreach ($templateFile in $templateFiles) { if ($variable.Type -eq 'String' -or $variable.Type -eq 'SecureString') { $deploymentOutputs[$key] = $variable.Value + } + } + + if ($OutFile) + { + if (!$IsWindows) + { + Write-Host "File option is supported only on Windows" + } + + $outputFile = "$templateFile.env" + + $environmentText = $deploymentOutputs | ConvertTo-Json; + $bytes = ([System.Text.Encoding]::UTF8).GetBytes($environmentText) + $protectedBytes = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser) + Set-Content $outputFile -Value $protectedBytes -AsByteStream -Force + + Write-Host "Test environment settings`n $environmentText`nstored into encrypted $outputFile" + } + else + { + + if (!$CI) { + # Write an extra new line to isolate the environment variables for easy reading. + Log "Persist the following environment variables based on your detected shell ($shell):`n" + } + + foreach ($key in $deploymentOutputs.Keys) + { + $value = $deploymentOutputs[$key] + if ($CI) { # Treat all ARM template output variables as secrets since "SecureString" variables do not set values. # In order to mask secrets but set environment variables for any given ARM template, we set variables twice as shown below. Write-Host "Setting variable '$key': ***" - Write-Host "##vso[task.setvariable variable=_$key;issecret=true;]$($variable.Value)" - Write-Host "##vso[task.setvariable variable=$key;]$($variable.Value)" + Write-Host "##vso[task.setvariable variable=_$key;issecret=true;]$($value)" + Write-Host "##vso[task.setvariable variable=$key;]$($value)" } else { - Write-Host ($shellExportFormat -f $key, $variable.Value) + Write-Host ($shellExportFormat -f $key, $value) } } - } - if ($key) { - # Isolate the environment variables for easy reading. - Write-Host "`n" - $key = $null + if ($key) { + # Isolate the environment variables for easy reading. + Write-Host "`n" + $key = $null + } } $postDeploymentScript = $templateFile | Split-Path | Join-Path -ChildPath 'test-resources-post.ps1' @@ -385,6 +432,10 @@ The tenant ID of a service principal when a provisioner is specified. The same Tenant ID is used for Test Application and Provisioner Application. This value is passed to the ARM template as 'tenantId'. +.PARAMETER SubscriptionId +Optional subscription ID to use for new resources when logging in as a +provisioner. You can also use Set-AzContext if not provisioning. + .PARAMETER ProvisionerApplicationId The AAD Application ID used to provision test resources when a provisioner is specified. @@ -415,16 +466,20 @@ timestamp is less than the current time. This isused for CI automation. .PARAMETER Location -Optional location where resources should be created. By default this is -'westus2'. +Optional location where resources should be created. If left empty, the default +is based on the cloud to which the template is being deployed: -.PARAMETER AdditionalParameters -Optional key-value pairs of parameters to pass to the ARM template(s). +* AzureCloud -> 'westus2' +* AzureUSGovernment -> 'usgovvirginia' +* AzureChinaCloud -> 'chinaeast2' .PARAMETER Environment Name of the cloud environment. The default is the Azure Public Cloud ('PublicCloud') +.PARAMETER AdditionalParameters +Optional key-value pairs of parameters to pass to the ARM template(s). + .PARAMETER CI Indicates the script is run as part of a Continuous Integration / Continuous Deployment (CI/CD) build (only Azure Pipelines is currently supported). @@ -432,12 +487,15 @@ Deployment (CI/CD) build (only Azure Pipelines is currently supported). .PARAMETER Force Force creation of resources instead of being prompted. +.PARAMETER OutFile +Save test environment settings into a test-resources.json.env file next to test-resources.json. File is protected via DPAPI. Supported only on windows. +The environment file would be scoped to the current repository directory. + .EXAMPLE -$subscriptionId = "REPLACE_WITH_SUBSCRIPTION_ID" -Connect-AzAccount -Subscription $subscriptionId +Connect-AzAccount -Subscription "REPLACE_WITH_SUBSCRIPTION_ID" $testAadApp = New-AzADServicePrincipal -Role Owner -DisplayName 'azure-sdk-live-test-app' -.\eng\common\LiveTestResources\New-TestResources.ps1 ` - -BaseName 'myalias' ` +New-TestResources.ps1 ` + -BaseName 'uuid123' ` -ServiceDirectory 'keyvault' ` -TestApplicationId $testAadApp.ApplicationId.ToString() ` -TestApplicationSecret (ConvertFrom-SecureString $testAadApp.Secret -AsPlainText) @@ -449,7 +507,7 @@ Requires PowerShell 7 to use ConvertFrom-SecureString -AsPlainText or convert the SecureString to plaintext by another means. .EXAMPLE -eng/New-TestResources.ps1 ` +New-TestResources.ps1 ` -BaseName 'Generated' ` -ServiceDirectory '$(ServiceDirectory)' ` -TenantId '$(TenantId)' ` @@ -466,14 +524,6 @@ Run this in an Azure DevOps CI (with approrpiate variables configured) before executing live tests. The script will output variables as secrets (to enable log redaction). -.OUTPUTS -Entries from the ARM templates' "output" section in environment variable syntax -(e.g. $env:RESOURCE_NAME='<< resource name >>') that can be used for running -live tests. - -If run in -CI mode the environment variables will be output in syntax that Azure -DevOps can consume. - .LINK Remove-TestResources.ps1 #> diff --git a/eng/common/TestResources/New-TestResources.ps1.md b/eng/common/TestResources/New-TestResources.ps1.md index 4c978512519b..c9a462aae3a8 100644 --- a/eng/common/TestResources/New-TestResources.ps1.md +++ b/eng/common/TestResources/New-TestResources.ps1.md @@ -23,7 +23,7 @@ New-TestResources.ps1 [-BaseName] -ServiceDirectory -TestAppli ### Provisioner ``` New-TestResources.ps1 [-BaseName] -ServiceDirectory -TestApplicationId - [-TestApplicationSecret ] [-TestApplicationOid ] -TenantId + [-TestApplicationSecret ] [-TestApplicationOid ] -TenantId [-SubscriptionId ] -ProvisionerApplicationId -ProvisionerApplicationSecret [-DeleteAfterHours ] [-Location ] [-Environment ] [-AdditionalParameters ] [-CI] [-Force] [-WhatIf] [-Confirm] [] @@ -53,11 +53,10 @@ specified in $ProvisionerApplicationId and $ProvisionerApplicationSecret. ### EXAMPLE 1 ``` -$subscriptionId = "REPLACE_WITH_SUBSCRIPTION_ID" -Connect-AzAccount -Subscription $subscriptionId +Connect-AzAccount -Subscription "REPLACE_WITH_SUBSCRIPTION_ID" $testAadApp = New-AzADServicePrincipal -Role Owner -DisplayName 'azure-sdk-live-test-app' -.\eng\common\LiveTestResources\New-TestResources.ps1 ` - -BaseName 'myalias' ` +New-TestResources.ps1 ` + -BaseName 'uuid123' ` -ServiceDirectory 'keyvault' ` -TestApplicationId $testAadApp.ApplicationId.ToString() ` -TestApplicationSecret (ConvertFrom-SecureString $testAadApp.Secret -AsPlainText) @@ -71,7 +70,7 @@ the SecureString to plaintext by another means. ### EXAMPLE 2 ``` -eng/New-TestResources.ps1 ` +New-TestResources.ps1 ` -BaseName 'Generated' ` -ServiceDirectory '$(ServiceDirectory)' ` -TenantId '$(TenantId)' ` @@ -214,6 +213,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -SubscriptionId +Optional subscription ID to use for new resources when logging in as a +provisioner. +You can also use Set-AzContext if not provisioning. + +```yaml +Type: String +Parameter Sets: Provisioner +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProvisionerApplicationId The AAD Application ID used to provision test resources when a provisioner is specified. @@ -283,8 +299,12 @@ Accept wildcard characters: False ### -Location Optional location where resources should be created. -By default this is -'westus2'. +If left empty, the default +is based on the cloud to which the template is being deployed: + +* AzureCloud -\> 'westus2' +* AzureUSGovernment -\> 'usgovvirginia' +* AzureChinaCloud -\> 'chinaeast2' ```yaml Type: String @@ -293,7 +313,7 @@ Aliases: Required: False Position: Named -Default value: Westus2 +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` @@ -392,19 +412,32 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -OutFile +save test environment settings into a test-resources.json.env file next to test-resources.json. +The file is protected via DPAPI. The environment file would be scoped to the current repository directory. +Note: Supported only on Windows. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). +## INPUTS + ## OUTPUTS -### Entries from the ARM templates' "output" section in environment variable syntax -### (e.g. $env:RESOURCE_NAME='<< resource name >>') that can be used for running -### live tests. -### If run in -CI mode the environment variables will be output in syntax that Azure -### DevOps can consume. ## NOTES ## RELATED LINKS -[Remove-TestResources.ps1](./New-TestResources.ps1.md) - +[Remove-TestResources.ps1](./Remove-TestResources.ps1.md) diff --git a/eng/common/TestResources/README.md b/eng/common/TestResources/README.md index 8eb911419d8d..3c0cd7c2207e 100644 --- a/eng/common/TestResources/README.md +++ b/eng/common/TestResources/README.md @@ -1,29 +1,132 @@ # Live Test Resource Management -Live test runs require pre-existing resources in Azure. This set of PowerShell -commands automates creation and teardown of live test resources for Desktop and -CI scenarios. +Running and recording live tests often requires first creating some resources +in Azure. Service directories that include a test-resources.json file require +running [New-TestResources.ps1][] to create these resources and output +environment variables you must set. -* [New-TestResources.ps1](./New-TestResources.ps1.md) - Create new test resources -for the given service. -* [Remove-TestResources.ps1](./New-TestResources.ps1.md) - Deletes resources +The following scripts can be used both in on your desktop for developer +scenarios as well as on hosted agents for continuous integration testing. + +* [New-TestResources.ps1][] - Creates new test resources for a given service. +* [Remove-TestResources.ps1][] - Deletes previously created resources. + +## Prerequisites + +1. Install [PowerShell][] version 7.0 or newer. +2. Install the [Azure PowerShell][PowerShellAz]. ## On the Desktop -Run `New-TestResources.ps1` on your desktop to create live test resources for a -given service (e.g. Storage, Key Vault, etc.). The command will output -environment variables that need to be set when running the live tests. +To set up your Azure account to run live tests, you'll need to log into Azure, +create a service principal, and set up your resources defined in +test-resources.json as shown in the following example using Azure Search. + +Note that `-Subscription` is an optional parameter but recommended if your account +is a member of multiple subscriptions. + +```powershell +Connect-AzAccount -Subscription 'YOUR SUBSCRIPTION ID' +$sp = New-AzADServicePrincipal -Role Owner +eng\common\TestResources\New-TestResources.ps1 ` + -BaseName 'myusername' ` + -ServiceDirectory 'search' ` + -TestApplicationId $sp.ApplicationId ` + -TestApplicationSecret (ConvertFrom-SecureString $sp.Secret -AsPlainText) +``` + +If you are running this for a .NET project on Windows, the recommended method is to +add the `-OutFile` switch to the above command. This will save test environment settings +into a test-resources.json.env file next to test-resources.json. The file is protected via DPAPI. +The environment file would be scoped to the current repository directory and avoids the need to +set environment variables or restart your IDE to recognize them. + +Along with some log messages, this will output environment variables based on +your current shell like in the following example: + +```powershell +$env:AZURE_TENANT_ID = '<>' +$env:AZURE_CLIENT_ID = '<>' +$env:AZURE_CLIENT_SECRET = '<>' +$env:AZURE_SUBSCRIPTION_ID = 'YOUR SUBSCRIPTION ID' +$env:AZURE_RESOURCE_GROUP = 'rg-myusername' +$env:AZURE_LOCATION = 'westus2' +$env:AZURE_SEARCH_STORAGE_NAME = 'myusernamestg' +$env:AZURE_SEARCH_STORAGE_KEY = '<>' +``` + +For security reasons we do not set these environment variables automatically +for either the current process or persistently for future sessions. You must +do that yourself based on your current platform and shell. + +If your current shell was detected properly, you should be able to copy and +paste the output directly in your terminal and add to your profile script. +For example, in PowerShell on Windows you can copy the output above and paste +it back into the terminal to set those environment variables for the current +process. To persist these variables for future terminal sessions or for +applications started outside the terminal, you could copy and paste the +following commands: + +```powershell +setx AZURE_TENANT_ID $env:AZURE_TENANT_ID +setx AZURE_CLIENT_ID $env:AZURE_CLIENT_ID +setx AZURE_CLIENT_SECRET $env:AZURE_CLIENT_SECRET +setx AZURE_SUBSCRIPTION_ID $env:AZURE_SUBSCRIPTION_ID +setx AZURE_RESOURCE_GROUP $env:AZURE_RESOURCE_GROUP +setx AZURE_LOCATION $env:AZURE_LOCATION +setx AZURE_SEARCH_STORAGE_NAME $env:AZURE_SEARCH_STORAGE_NAME +setx AZURE_SEARCH_STORAGE_KEY $env:AZURE_SEARCH_STORAGE_KEY +``` + +After running or recording live tests, if you do not plan on further testing +you can remove the test resources you created above by running: +[Remove-TestResources.ps1][]: -See examples for how to create the needed Service Principals and execute live -tests. +```powershell +Remove-TestResources.ps1 -BaseName 'myusername' -Force +``` + +If you created a new service principal as shown above, you might also remove it: + +```powershell +Remove-AzADServicePrincipal -ApplicationId $sp.ApplicationId -Force + +``` + +If you persisted environment variables, you should also remove those as well. ## In CI -The `New-TestResources.ps1` script is invoked on each test job to create an -isolated environment for live tests. Test resource isolation makes it easier to -parallelize test runs. +Test pipelines should include deploy-test-resources.yml and +remove-test-resources.yml like in the following examples: + +```yml +- template: /eng/common/TestResources/deploy-test-resources.yml + parameters: + ServiceDirectory: '${{ parameters.ServiceDirectory }}' + +# Run tests + +- template: /eng/common/TestResources/remove-test-resources.yml +``` + +Be sure to link the **Secrets for Resource Provisioner** variable group +into the test pipeline for these scripts to work. + +## Documentation + +To regenerate documentation for scripts within this directory, you can install +[platyPS][] and run it like in the following example: + +```powershell +Install-Module platyPS -Scope CurrentUser -Force +New-MarkdownHelp -Command .\New-TestResources.ps1 -OutputFolder . -Force +``` -## Other +PowerShell markdown documentation created with [platyPS][]. -PowerShell markdown documentation created with -[PlatyPS](https://github.com/PowerShell/platyPS) \ No newline at end of file + [New-TestResources.ps1]: ./New-TestResources.ps1.md + [Remove-TestResources.ps1]: ./Remove-TestResources.ps1.md + [PowerShell]: https://github.com/PowerShell/PowerShell + [PowerShellAz]: https://docs.microsoft.com/powershell/azure/install-az-ps + [platyPS]: https://github.com/PowerShell/platyPS diff --git a/eng/common/TestResources/Remove-TestResources.ps1 b/eng/common/TestResources/Remove-TestResources.ps1 index 494fe1c873d1..0c5c464bc870 100644 --- a/eng/common/TestResources/Remove-TestResources.ps1 +++ b/eng/common/TestResources/Remove-TestResources.ps1 @@ -25,6 +25,11 @@ param ( [ValidateNotNullOrEmpty()] [string] $TenantId, + [Parameter(ParameterSetName = 'Default+Provisioner')] + [Parameter(ParameterSetName = 'ResourceGroup+Provisioner')] + [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] + [string] $SubscriptionId, + [Parameter(ParameterSetName = 'Default+Provisioner', Mandatory = $true)] [Parameter(ParameterSetName = 'ResourceGroup+Provisioner', Mandatory = $true)] [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')] @@ -33,13 +38,20 @@ param ( [Parameter(ParameterSetName = 'Default+Provisioner', Mandatory = $true)] [Parameter(ParameterSetName = 'ResourceGroup+Provisioner', Mandatory = $true)] [string] $ProvisionerApplicationSecret, + + [Parameter()] + [string] $ServiceDirectory, [Parameter()] - [ValidateNotNullOrEmpty()] + [ValidateSet('AzureCloud', 'AzureUSGovernment', 'AzureChinaCloud')] [string] $Environment = 'AzureCloud', [Parameter()] - [switch] $Force + [switch] $Force, + + # Captures any arguments not declared here (no parameter errors) + [Parameter(ValueFromRemainingArguments = $true)] + $RemoveTestResourcesRemainingArguments ) # By default stop for any error. @@ -90,8 +102,14 @@ if ($ProvisionerApplicationId) { Log "Logging into service principal '$ProvisionerApplicationId'" $provisionerSecret = ConvertTo-SecureString -String $ProvisionerApplicationSecret -AsPlainText -Force $provisionerCredential = [System.Management.Automation.PSCredential]::new($ProvisionerApplicationId, $provisionerSecret) + + # Use the given subscription ID if provided. + $subscriptionArgs = if ($SubscriptionId) { + @{SubscriptionId = $SubscriptionId} + } + $provisionerAccount = Retry { - Connect-AzAccount -Tenant $TenantId -Credential $provisionerCredential -ServicePrincipal -Environment $Environment + Connect-AzAccount -Force:$Force -Tenant $TenantId -Credential $provisionerCredential -ServicePrincipal -Environment $Environment @subscriptionArgs } $exitActions += { @@ -105,6 +123,20 @@ if (!$ResourceGroupName) { $ResourceGroupName = "rg-$BaseName" } +if (![string]::IsNullOrWhiteSpace($ServiceDirectory)) { + $root = [System.IO.Path]::Combine("$PSScriptRoot/../../../sdk", $ServiceDirectory) | Resolve-Path + $preRemovalScript = Join-Path -Path $root -ChildPath 'remove-test-resources-pre.ps1' + if (Test-Path $preRemovalScript) { + Log "Invoking pre resource removal script '$preRemovalScript'" + + if (!$PSCmdlet.ParameterSetName.StartsWith('ResourceGroup')) { + $PSBoundParameters.Add('ResourceGroupName', $ResourceGroupName); + } + + &$preRemovalScript @PSBoundParameters + } +} + Log "Deleting resource group '$ResourceGroupName'" if (Retry { Remove-AzResourceGroup -Name "$ResourceGroupName" -Force:$Force }) { Write-Verbose "Successfully deleted resource group '$ResourceGroupName'" @@ -115,59 +147,51 @@ $exitActions.Invoke() <# .SYNOPSIS Deletes the resource group deployed for a service directory from Azure. - .DESCRIPTION Removes a resource group and all its resources previously deployed using New-TestResources.ps1. - If you are not currently logged into an account in the Az PowerShell module, you will be asked to log in with Connect-AzAccount. Alternatively, you (or a build pipeline) can pass $ProvisionerApplicationId and $ProvisionerApplicationSecret to authenticate a service principal with access to create resources. - .PARAMETER BaseName A name to use in the resource group and passed to the ARM template as 'baseName'. This will delete the resource group named 'rg-' - .PARAMETER ResourceGroupName The name of the resource group to delete. - .PARAMETER TenantId The tenant ID of a service principal when a provisioner is specified. - +.PARAMETER SubscriptionId +Optional subscription ID to use for new resources when logging in as a +provisioner. You can also use Set-AzContext if not provisioning. .PARAMETER ProvisionerApplicationId A service principal ID to provision test resources when a provisioner is specified. - .PARAMETER ProvisionerApplicationSecret A service principal secret (password) to provision test resources when a provisioner is specified. - +.PARAMETER ServiceDirectory +A directory under 'sdk' in the repository root - optionally with subdirectories +specified - in which to discover pre removal script named 'remove-test-resources-pre.json'. .PARAMETER Environment Name of the cloud environment. The default is the Azure Public Cloud ('PublicCloud') - .PARAMETER Force Force removal of resource group without asking for user confirmation - .EXAMPLE -./Remove-TestResources.ps1 -BaseName uuid123 -Force - +Remove-TestResources.ps1 -BaseName 'uuid123' -Force Use the currently logged-in account to delete the resource group by the name of 'rg-uuid123' - .EXAMPLE -eng/Remove-TestResources.ps1 ` +Remove-TestResources.ps1 ` -ResourceGroupName "${env:AZURE_RESOURCEGROUP_NAME}" ` -TenantId '$(TenantId)' ` -ProvisionerApplicationId '$(AppId)' ` -ProvisionerApplicationSecret '$(AppSecret)' ` -Force ` -Verbose ` - When run in the context of an Azure DevOps pipeline, this script removes the resource group whose name is stored in the environment variable AZURE_RESOURCEGROUP_NAME. - .LINK New-TestResources.ps1 -#> +#> \ No newline at end of file diff --git a/eng/common/TestResources/Remove-TestResources.ps1.md b/eng/common/TestResources/Remove-TestResources.ps1.md index 03803d8e591c..f9bc1803ae32 100644 --- a/eng/common/TestResources/Remove-TestResources.ps1.md +++ b/eng/common/TestResources/Remove-TestResources.ps1.md @@ -20,16 +20,16 @@ Remove-TestResources.ps1 [-BaseName] [-Environment ] [-Force] [ ### Default+Provisioner ``` -Remove-TestResources.ps1 [-BaseName] -TenantId -ProvisionerApplicationId - -ProvisionerApplicationSecret [-Environment ] [-Force] [-WhatIf] [-Confirm] - [] +Remove-TestResources.ps1 [-BaseName] -TenantId [-SubscriptionId ] + -ProvisionerApplicationId -ProvisionerApplicationSecret [-Environment ] [-Force] + [-WhatIf] [-Confirm] [] ``` ### ResourceGroup+Provisioner ``` -Remove-TestResources.ps1 -ResourceGroupName -TenantId -ProvisionerApplicationId - -ProvisionerApplicationSecret [-Environment ] [-Force] [-WhatIf] [-Confirm] - [] +Remove-TestResources.ps1 -ResourceGroupName -TenantId [-SubscriptionId ] + -ProvisionerApplicationId -ProvisionerApplicationSecret [-Environment ] [-Force] + [-WhatIf] [-Confirm] [] ``` ### ResourceGroup @@ -53,7 +53,7 @@ create resources. ### EXAMPLE 1 ``` -./Remove-TestResources.ps1 -BaseName uuid123 -Force +Remove-TestResources.ps1 -BaseName 'uuid123' -Force ``` Use the currently logged-in account to delete the resource group by the name of @@ -61,7 +61,7 @@ Use the currently logged-in account to delete the resource group by the name of ### EXAMPLE 2 ``` -eng/Remove-TestResources.ps1 ` +Remove-TestResources.ps1 ` -ResourceGroupName "${env:AZURE_RESOURCEGROUP_NAME}" ` -TenantId '$(TenantId)' ` -ProvisionerApplicationId '$(AppId)' ` @@ -122,6 +122,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -SubscriptionId +Optional subscription ID to use for new resources when logging in as a +provisioner. +You can also use Set-AzContext if not provisioning. + +```yaml +Type: String +Parameter Sets: Default+Provisioner, ResourceGroup+Provisioner +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProvisionerApplicationId A service principal ID to provision test resources when a provisioner is specified. @@ -152,6 +169,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ServiceDirectory +A directory under 'sdk' in the repository root - optionally with subdirectories +specified - specified - in which to discover pre removal script named 'remove-test-resources-pre.json'. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Environment Name of the cloud environment. The default is the Azure Public Cloud @@ -218,7 +251,12 @@ Accept wildcard characters: False ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). +## INPUTS + +## OUTPUTS + +## NOTES + ## RELATED LINKS [New-TestResources.ps1](./New-TestResources.ps1.md) - diff --git a/eng/common/TestResources/deploy-test-resources.yml b/eng/common/TestResources/deploy-test-resources.yml index c2445a4c4daa..b580d73f1406 100644 --- a/eng/common/TestResources/deploy-test-resources.yml +++ b/eng/common/TestResources/deploy-test-resources.yml @@ -1,73 +1,40 @@ -# Deploys resources to a cloud type specified by the variable (not parameter) -# 'CloudType'. Use this as part of a matrix to deploy resources to a particular -# cloud instance. Normally we would use template parameters instead of variables -# but matrix variables are not available during template expansion so any -# benefits of parameters are lost. - parameters: ServiceDirectory: not-set ArmTemplateParameters: '@{}' DeleteAfterHours: 24 Location: '' + SubscriptionConfiguration: $(sub-config-azure-cloud-test-resources) + +# SubscriptionConfiguration will be splat into the parameters of the test +# resources script. It should be JSON in the form: +# { +# "SubscriptionId": "", +# "TenantId": "", +# "TestApplicationId": "", +# "TestApplicationSecret": "", +# "ProvisionerApplicationId": "", +# "ProvisoinerApplicationSecret": "", +# "Environment": "AzureCloud | AzureGov | AzureChina | " +# } steps: # New-TestResources command requires Az module - pwsh: Install-Module -Name Az -Scope CurrentUser -AllowClobber -Force -Verbose displayName: Install Azure PowerShell module - - pwsh: > - eng/common/TestResources/New-TestResources.ps1 - -BaseName 'Generated' - -ServiceDirectory '${{ parameters.ServiceDirectory }}' - -TenantId '$(aad-azure-sdk-test-tenant-id)' - -TestApplicationId '$(aad-azure-sdk-test-client-id)' - -TestApplicationSecret '$(aad-azure-sdk-test-client-secret)' - -ProvisionerApplicationId '$(aad-azure-sdk-test-client-id)' - -ProvisionerApplicationSecret '$(aad-azure-sdk-test-client-secret)' - -AdditionalParameters ${{ parameters.ArmTemplateParameters }} - -DeleteAfterHours ${{ parameters.DeleteAfterHours }} - -Location '${{ parameters.Location }}' - -Environment 'AzureCloud' - -CI - -Force - -Verbose - displayName: Deploy test resources (AzureCloud) - condition: and(succeeded(), eq(variables['CloudType'], 'AzureCloud')) - - - pwsh: > - eng/common/TestResources/New-TestResources.ps1 - -BaseName 'Generated' - -ServiceDirectory '${{ parameters.ServiceDirectory }}' - -TenantId '$(aad-azure-sdk-test-tenant-id-gov)' - -TestApplicationId '$(aad-azure-sdk-test-client-id-gov)' - -TestApplicationSecret '$(aad-azure-sdk-test-client-secret-gov)' - -ProvisionerApplicationId '$(aad-azure-sdk-test-client-id-gov)' - -ProvisionerApplicationSecret '$(aad-azure-sdk-test-client-secret-gov)' - -AdditionalParameters ${{ parameters.ArmTemplateParameters }} - -DeleteAfterHours ${{ parameters.DeleteAfterHours }} - -Location '${{ parameters.Location }}' - -Environment 'AzureUSGovernment' - -CI - -Force - -Verbose - displayName: Deploy test resources (AzureUSGovernment) - condition: and(succeeded(), eq(variables['CloudType'], 'AzureUSGovernment')) + - pwsh: | + $subscriptionConfiguration = @" + ${{ parameters.SubscriptionConfiguration }} + "@ | ConvertFrom-Json -AsHashtable; - - pwsh: > - eng/common/TestResources/New-TestResources.ps1 - -BaseName 'Generated' - -ServiceDirectory '${{ parameters.ServiceDirectory }}' - -TenantId '$(aad-azure-sdk-test-tenant-id-cn)' - -TestApplicationId '$(aad-azure-sdk-test-client-id-cn)' - -TestApplicationSecret '$(aad-azure-sdk-test-client-secret-cn)' - -ProvisionerApplicationId '$(aad-azure-sdk-test-client-id-cn)' - -ProvisionerApplicationSecret '$(aad-azure-sdk-test-client-secret-cn)' - -AdditionalParameters ${{ parameters.ArmTemplateParameters }} - -DeleteAfterHours ${{ parameters.DeleteAfterHours }} - -Location '${{ parameters.Location }}' - -Environment 'AzureChinaCloud' - -CI - -Force - -Verbose - displayName: Deploy test resources (AzureChinaCloud) - condition: and(succeeded(), eq(variables['CloudType'], 'AzureChinaCloud')) \ No newline at end of file + eng/common/TestResources/New-TestResources.ps1 ` + -BaseName 'Generated' ` + -ServiceDirectory ${{ parameters.ServiceDirectory }} ` + -Location '${{ parameters.Location }}' ` + -DeleteAfterHours ${{ parameters.DeleteAfterHours }} ` + -AdditionalParameters ${{ parameters.ArmTemplateParameters }} ` + @subscriptionConfiguration ` + -CI ` + -Force ` + -Verbose + displayName: Deploy test resources diff --git a/eng/common/TestResources/remove-test-resources.yml b/eng/common/TestResources/remove-test-resources.yml index fec875c0a6b8..1565e7d4bd0f 100644 --- a/eng/common/TestResources/remove-test-resources.yml +++ b/eng/common/TestResources/remove-test-resources.yml @@ -1,48 +1,36 @@ -# Removes resources from a cloud type specified by the variable (not parameter) -# 'CloudType'. Use this as part of a matrix to remove resources from a -# particular cloud instance. Normally we would use template variables instead of -# parameters but matrix variables are not available during template expansion -# so any benefits of parameters are lost. - # Assumes steps in deploy-test-resources.yml was run previously. Requires # environment variable: AZURE_RESOURCEGROUP_NAME and Az PowerShell module -steps: - - pwsh: > - eng/common/TestResources/Remove-TestResources.ps1 - -ResourceGroupName "${env:AZURE_RESOURCEGROUP_NAME}" - -TenantId '$(aad-azure-sdk-test-tenant-id)' - -ProvisionerApplicationId '$(aad-azure-sdk-test-client-id)' - -ProvisionerApplicationSecret '$(aad-azure-sdk-test-client-secret)' - -Environment 'AzureCloud' - -Force - -Verbose - displayName: Remove test resources (AzureCloud) - condition: and(ne(variables['AZURE_RESOURCEGROUP_NAME'], ''), eq(variables['CloudType'], 'AzureCloud')) - continueOnError: true +parameters: + ServiceDirectory: '' + SubscriptionConfiguration: $(sub-config-azure-cloud-test-resources) - - pwsh: > - eng/common/TestResources/Remove-TestResources.ps1 - -ResourceGroupName "${env:AZURE_RESOURCEGROUP_NAME}" - -TenantId '$(aad-azure-sdk-test-tenant-id-gov)' - -ProvisionerApplicationId '$(aad-azure-sdk-test-client-id-gov)' - -ProvisionerApplicationSecret '$(aad-azure-sdk-test-client-secret-gov)' - -Environment 'AzureUSGovernment' - -Force - -Verbose - displayName: Remove test resources (AzureUSGovernment) - condition: and(ne(variables['AZURE_RESOURCEGROUP_NAME'], ''), eq(variables['CloudType'], 'AzureUSGovernment')) - continueOnError: true +# SubscriptionConfiguration will be splat into the parameters of the test +# resources script. It should be JSON in the form: +# { +# "SubscriptionId": "", +# "TenantId": "", +# "TestApplicationId": "", +# "TestApplicationSecret": "", +# "ProvisionerApplicationId": "", +# "ProvisoinerApplicationSecret": "", +# "Environment": "AzureCloud | AzureGov | AzureChina | " +# } +# The Remove-TestResources.ps1 script accommodates extra parameters so it will +# not error when parameters are provided which the script doesn't use. + +steps: + - pwsh: | + $subscriptionConfiguration = @" + ${{ parameters.SubscriptionConfiguration }} + "@ | ConvertFrom-Json -AsHashtable; - - pwsh: > - eng/common/TestResources/Remove-TestResources.ps1 - -ResourceGroupName "${env:AZURE_RESOURCEGROUP_NAME}" - -TenantId '$(aad-azure-sdk-test-tenant-id-cn)' - -ProvisionerApplicationId '$(aad-azure-sdk-test-client-id-cn)' - -ProvisionerApplicationSecret '$(aad-azure-sdk-test-client-secret-cn)' - -Environment 'AzureChinaCloud' - -Force - -Verbose - displayName: Remove test resources (AzureChinaCloud) - condition: and(ne(variables['AZURE_RESOURCEGROUP_NAME'], ''), eq(variables['CloudType'], 'AzureChinaCloud')) + eng/common/TestResources/Remove-TestResources.ps1 ` + -ResourceGroupName "${env:AZURE_RESOURCEGROUP_NAME}" ` + -ServiceDirectory ${{ parameters.ServiceDirectory }} ` + @subscriptionConfiguration ` + -Force ` + -Verbose + displayName: Remove test resources + condition: and(ne(variables['AZURE_RESOURCEGROUP_NAME'], ''), succeededOrFailed()) continueOnError: true diff --git a/eng/common/Update-Change-Log.ps1 b/eng/common/Update-Change-Log.ps1 index bc48cbd63c7f..d50316f223b7 100644 --- a/eng/common/Update-Change-Log.ps1 +++ b/eng/common/Update-Change-Log.ps1 @@ -63,11 +63,27 @@ function Get-NewChangeLog( [System.Collections.ArrayList]$ChangelogLines, $Versi # find index of current version $Index = 0 $CurrentTitle = "" + $CurrentIndex = 0 + # Version increment tool passes replaceversion as False and Unreleased as True + $is_version_increment = $ReplaceVersion -eq $False -and $Unreleased -eq $True + for(; $Index -lt $ChangelogLines.Count; $Index++){ if (Version-Matches($ChangelogLines[$Index])){ - $CurrentTitle = $ChangelogLines[$Index] - Write-Host "Current Version title: $CurrentTitle" - break + # Find current title in change log + if( -not $CurrentTitle){ + $CurrentTitle = $ChangelogLines[$Index] + $CurrentIndex = $Index + Write-Host "Current Version title: $CurrentTitle" + } + + # Ensure change log doesn't have new version when incrementing version + # update change log script is triggered for all packages with current version for Java ( or any language where version is maintained in common file) + # and this can cause an issue if someone changes changelog manually to prepare for release without updating actual version in central version file + # Do not add new line or replace existing title when version is already present and script is triggered to add new line + if ($is_version_increment -and $ChangelogLines[$Index].Contains($Version)){ + Write-Host "Version is already present in change log." + exit(0) + } } } @@ -79,12 +95,7 @@ function Get-NewChangeLog( [System.Collections.ArrayList]$ChangelogLines, $Versi exit(0) } - # update change log script is triggered for all packages with current version for Java ( or any language where version is maintained in common file) - # Do not add new line or replace existing title when version is already present and script is triggered to add new line - if (($ReplaceVersion -eq $False) -and ($Unreleased -eq $True) -and $CurrentTitle.Contains($Version)){ - Write-Host "Version is already present in change log." - exit(0) - } + if (($ReplaceVersion -eq $True) -and ($Unreleased -eq $False) -and (-not $CurrentTitle.Contains($UNRELEASED_TAG))){ Write-Host "Version is already present in change log with a release date." @@ -100,14 +111,14 @@ function Get-NewChangeLog( [System.Collections.ArrayList]$ChangelogLines, $Versi # if version is already found and not replacing then nothing to do if ($ReplaceVersion -eq $False){ Write-Host "Adding version title $newVersionTitle" - $ChangelogLines.insert($Index, "") - $ChangelogLines.insert($Index, "") - $ChangelogLines.insert($Index, $newVersionTitle) + $ChangelogLines.insert($CurrentIndex, "") + $ChangelogLines.insert($CurrentIndex, "") + $ChangelogLines.insert($CurrentIndex, $newVersionTitle) } else{ # Script is executed to replace an existing version title Write-Host "Replacing current version title to $newVersionTitle" - $ChangelogLines[$index] = $newVersionTitle + $ChangelogLines[$CurrentIndex] = $newVersionTitle } return $ChangelogLines diff --git a/eng/common/pipelines/templates/steps/bypass-local-dns.yml b/eng/common/pipelines/templates/steps/bypass-local-dns.yml new file mode 100644 index 000000000000..54f49c33714b --- /dev/null +++ b/eng/common/pipelines/templates/steps/bypass-local-dns.yml @@ -0,0 +1,6 @@ + +steps: + # https://github.com/actions/virtual-environments/issues/798 + - script: sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf + displayName: Bypass local DNS server to workaround issue resolving cognitiveservices names + condition: and(succeededOrFailed(), eq(variables['OSVmImage'], 'ubuntu-18.04')) \ No newline at end of file diff --git a/eng/common/pipelines/templates/steps/create-pull-request.yml b/eng/common/pipelines/templates/steps/create-pull-request.yml index f807b3e5d165..790de92bcbe5 100644 --- a/eng/common/pipelines/templates/steps/create-pull-request.yml +++ b/eng/common/pipelines/templates/steps/create-pull-request.yml @@ -11,12 +11,13 @@ parameters: PushArgs: WorkingDirectory: $(System.DefaultWorkingDirectory) PRTitle: not-specified + ScriptDirectory: eng/common/scripts steps: - pwsh: | - echo "git add ." - git add . + echo "git add -A" + git add -A echo "git diff --name-status --cached --exit-code" git diff --name-status --cached --exit-code @@ -34,27 +35,31 @@ steps: workingDirectory: ${{ parameters.WorkingDirectory }} ignoreLASTEXITCODE: true -- pwsh: | - eng/common/scripts/git-branch-push.ps1 ` - -PRBranchName "${{ parameters.PRBranchName }}" ` - -CommitMsg "${{ parameters.CommitMsg }}" ` - -GitUrl "https://$(azuresdk-github-pat)@github.com/${{ parameters.PROwner }}/${{ parameters.RepoName }}.git" ` - -PushArgs "${{ parameters.PushArgs }}" - +- task: PowerShell@2 displayName: Push changes - workingDirectory: ${{ parameters.WorkingDirectory }} condition: and(succeeded(), eq(variables['HasChanges'], 'true')) + inputs: + pwsh: true + workingDirectory: ${{ parameters.WorkingDirectory }} + filePath: ${{ parameters.ScriptDirectory }}/git-branch-push.ps1 + arguments: > + -PRBranchName "${{ parameters.PRBranchName }}" + -CommitMsg "${{ parameters.CommitMsg }}" + -GitUrl "https://$(azuresdk-github-pat)@github.com/${{ parameters.PROwner }}/${{ parameters.RepoName }}.git" + -PushArgs "${{ parameters.PushArgs }}" -- pwsh: | - eng/common/scripts/Submit-PullRequest.ps1 ` - -RepoOwner "${{ parameters.RepoOwner }}" ` - -RepoName "${{ parameters.RepoName }}" ` - -BaseBranch "${{ parameters.BaseBranchName }}" ` - -PROwner "${{ parameters.PROwner }}" ` - -PRBranch "${{ parameters.PRBranchName }}" ` - -AuthToken "$(azuresdk-github-pat)" ` - -PRTitle "${{ parameters.PRTitle }}" - +- task: PowerShell@2 displayName: Create pull request - workingDirectory: ${{ parameters.WorkingDirectory }} - condition: and(succeeded(), eq(variables['HasChanges'], 'true')) \ No newline at end of file + condition: and(succeeded(), eq(variables['HasChanges'], 'true')) + inputs: + pwsh: true + workingDirectory: ${{ parameters.WorkingDirectory }} + filePath: ${{ parameters.ScriptDirectory }}/Submit-PullRequest.ps1 + arguments: > + -RepoOwner "${{ parameters.RepoOwner }}" + -RepoName "${{ parameters.RepoName }}" + -BaseBranch "${{ parameters.BaseBranchName }}" + -PROwner "${{ parameters.PROwner }}" + -PRBranch "${{ parameters.PRBranchName }}" + -AuthToken "$(azuresdk-github-pat)" + -PRTitle "${{ parameters.PRTitle }}" diff --git a/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml b/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml index 78d91abe8eb3..8fb57243b0fd 100644 --- a/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml +++ b/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml @@ -4,14 +4,19 @@ parameters: ReleaseSha: 'not-specified' RepoId: 'not-specified' WorkingDirectory: '' + ScriptDirectory: eng/common/scripts steps: - task: PowerShell@2 displayName: 'Verify Package Tags and Create Git Releases' inputs: - targetType: filePath - filePath: eng/common/scripts/create-tags-and-git-release.ps1 - arguments: -artifactLocation ${{parameters.ArtifactLocation}} -packageRepository ${{parameters.PackageRepository}} -releaseSha ${{parameters.ReleaseSha}} -repoId ${{parameters.RepoId}} -workingDirectory '${{parameters.WorkingDirectory}}' + filePath: ${{ parameters.ScriptDirectory }}/create-tags-and-git-release.ps1 + arguments: > + -artifactLocation ${{ parameters.ArtifactLocation }} + -packageRepository ${{ parameters.PackageRepository }} + -releaseSha ${{ parameters.ReleaseSha }} + -repoId ${{ parameters.RepoId }} + -workingDirectory '${{ parameters.WorkingDirectory }}' pwsh: true timeoutInMinutes: 5 env: diff --git a/eng/common/pipelines/templates/steps/docs-metadata-release.yml b/eng/common/pipelines/templates/steps/docs-metadata-release.yml new file mode 100644 index 000000000000..6ff9f84ff3ee --- /dev/null +++ b/eng/common/pipelines/templates/steps/docs-metadata-release.yml @@ -0,0 +1,58 @@ +# intended to be used as part of a release process +parameters: + ArtifactLocation: 'not-specified' + PackageRepository: 'not-specified' + ReleaseSha: 'not-specified' + RepoId: 'not-specified' + WorkingDirectory: '' + ScriptDirectory: eng/common/scripts + TargetDocRepoName: '' + TargetDocRepoOwner: '' + PRBranchName: 'smoke-test-rdme' + ArtifactName: '' + Language: '' + DocRepoDestinationPath: '' #usually docs-ref-services/ + +steps: +- pwsh: | + git clone https://github.com/${{ parameters.TargetDocRepoOwner }}/${{ parameters.TargetDocRepoName }} ${{ parameters.WorkingDirectory }}/repo + + try { + Push-Location ${{ parameters.WorkingDirectory }}/repo + + Write-Host "git checkout smoke-test" + git checkout smoke-test + } finally { + Pop-Location + } + displayName: Clone Documentation Repository + ignoreLASTEXITCODE: false + +- task: PowerShell@2 + displayName: 'Apply Documentation Updates From Artifact' + inputs: + targetType: filePath + filePath: ${{ parameters.ScriptDirectory }}/update-docs-metadata.ps1 + arguments: > + -ArtifactLocation ${{ parameters.ArtifactLocation }} + -Repository ${{ parameters.PackageRepository }} + -ReleaseSHA ${{ parameters.ReleaseSha }} + -RepoId ${{ parameters.RepoId }} + -WorkDirectory "${{ parameters.WorkingDirectory }}" + -DocRepoLocation "${{ parameters.WorkingDirectory }}/repo" + -Language "${{parameters.Language}}" + -DocRepoContentLocation ${{ parameters.DocRepoDestinationPath }} + pwsh: true + env: + GH_TOKEN: $(azuresdk-github-pat) + +- template: /eng/common/pipelines/templates/steps/create-pull-request.yml + parameters: + RepoName: ${{ parameters.TargetDocRepoName }} + RepoOwner: ${{ parameters.TargetDocRepoOwner }} + PRBranchName: ${{ parameters.PRBranchName }} + CommitMsg: "Update readme content for ${{ parameters.ArtifactName }}" + PRTitle: "Docs.MS Readme Update." + BaseBranchName: smoke-test + WorkingDirectory: ${{ parameters.WorkingDirectory }}/repo + ScriptDirectory: ${{ parameters.WorkingDirectory }}/${{ parameters.ScriptDirectory }} diff --git a/eng/common/pipelines/templates/steps/mashup-doc-index.yml b/eng/common/pipelines/templates/steps/mashup-doc-index.yml new file mode 100644 index 000000000000..7185297b6122 --- /dev/null +++ b/eng/common/pipelines/templates/steps/mashup-doc-index.yml @@ -0,0 +1,81 @@ +parameters: + TargetFolder: '' + +steps: + - task: PythonScript@0 + displayName: MashUp Generated Index Site so its served from default site location + inputs: + scriptSource: inline + script: | + import argparse + import os + import logging + import re + import shutil + from io import open + + SITE_INDEX = r'${{ parameters.SourceDirectory }}\docfx_project\_site' + TOC_HTML_REGEX = r"\.\./toc.html" + NAV_TOC_HTML_REGEX = r"api/" + PREV_DIR_REGEX = r"\.\./" + + def locate_htmlfiles(directory): + html_set = [] + for root, dirs, files in os.walk(directory): + for file in files: + html_set.append(os.path.join(root, file)) + return html_set + + def process_html(content): + content = re.sub(TOC_HTML_REGEX, 'navtoc.html', content) + content = re.sub(PREV_DIR_REGEX, '', content) + return content + + def process_navtoc(content): + content = re.sub(NAV_TOC_HTML_REGEX, '', content) + return content + + if __name__ == "__main__": + html_files = locate_htmlfiles(os.path.join(SITE_INDEX, 'api')) + navtoc_location = os.path.join(SITE_INDEX, 'toc.html') + + # Process the main toc.html and rename it to navtoc.html + try: + logging.info( + "Process {}.".format(navtoc_location) + ) + with open(navtoc_location, "r", encoding="utf8") as navtoc_stream: + navtoc_content = navtoc_stream.read() + new_navtoc_content = process_navtoc(navtoc_content) + logging.info("Process {}.".format(navtoc_content)) + with open(navtoc_location, "w", encoding="utf8") as html_stream: + html_stream.write(new_navtoc_content) + except Exception as e: + logging.error(e) + exit(1) + + # Rename main toc.html to navtoc.html + os.rename(navtoc_location, os.path.join(SITE_INDEX, 'navtoc.html')) + + # Process all html in api directory + for html_location in html_files: + try: + logging.info( + "Process {}.".format(html_location) + ) + with open(html_location, "r", encoding="utf8") as html_stream: + html_content = html_stream.read() + new_content = process_html(html_content) + logging.info("Process {}.".format(html_location)) + with open(html_location, "w", encoding="utf8") as html_stream: + html_stream.write(new_content) + except Exception as e: + logging.error(e) + exit(1) + + # Move all files from api to main site home directory + for html_location in html_files: + shutil.copy(html_location, SITE_INDEX) + + # Delete API Directory + shutil.rmtree(os.path.join(SITE_INDEX, 'api')) \ No newline at end of file diff --git a/eng/common/pipelines/templates/steps/publish-blobs.yml b/eng/common/pipelines/templates/steps/publish-blobs.yml index ce0d9f708cca..682cc4d4f7c8 100644 --- a/eng/common/pipelines/templates/steps/publish-blobs.yml +++ b/eng/common/pipelines/templates/steps/publish-blobs.yml @@ -7,16 +7,20 @@ parameters: steps: - pwsh: | - Invoke-WebRequest -MaximumRetryCount 10 -Uri "https://aka.ms/downloadazcopy-v10-windows" ` - -OutFile "azcopy.zip" | Wait-Process; Expand-Archive -Path "azcopy.zip" -DestinationPath "$(Build.BinariesDirectory)/azcopy/" + Invoke-WebRequest -MaximumRetryCount 10 -Uri "https://aka.ms/downloadazcopy-v10-windows" -OutFile "azcopy.zip" | Wait-Process; + Expand-Archive -Path "azcopy.zip" -DestinationPath "$(Build.BinariesDirectory)/azcopy/" workingDirectory: $(Build.BinariesDirectory) displayName: Download and Extract azcopy Zip - task: Powershell@2 inputs: - targetType: 'filePath' filePath: ${{ parameters.ScriptPath }} - arguments: -AzCopy $(Resolve-Path "$(Build.BinariesDirectory)/azcopy/azcopy_windows_amd64_*/azcopy.exe")[0] -DocLocation "${{ parameters.FolderForUpload }}" -SASKey "${{ parameters.BlobSASKey }}" -Language "${{ parameters.TargetLanguage }}" -BlobName "${{ parameters.BlobName }}" + arguments: > + -AzCopy $(Resolve-Path "$(Build.BinariesDirectory)/azcopy/azcopy_windows_amd64_*/azcopy.exe")[0] + -DocLocation "${{ parameters.FolderForUpload }}" + -SASKey "${{ parameters.BlobSASKey }}" + -Language "${{ parameters.TargetLanguage }}" + -BlobName "${{ parameters.BlobName }}" pwsh: true workingDirectory: $(Pipeline.Workspace) displayName: Copy Docs to Blob diff --git a/eng/common/pipelines/templates/steps/replace-relative-links.yml b/eng/common/pipelines/templates/steps/replace-relative-links.yml new file mode 100644 index 000000000000..7ab2d76bc3f6 --- /dev/null +++ b/eng/common/pipelines/templates/steps/replace-relative-links.yml @@ -0,0 +1,217 @@ +parameters: + TargetFolder: '' + RootFolder: '' + BuildSHA: '' + RepoId: '' + +steps: + - task: PythonScript@0 + displayName: Replace Relative Readme Links with Absolute References + inputs: + scriptSource: inline + script: | + import argparse + import sys + import os + import logging + import glob + import re + import fnmatch + from io import open + try: + from pathlib import Path + except: + from pathlib2 import Path + + # This script is intended to be run against a single folder. All readme.md files (regardless of casing) will have the relative links + # updated with appropriate full reference links. This is a recursive update.. + + logging.getLogger().setLevel(logging.INFO) + + RELATIVE_LINK_REPLACEMENT_SYNTAX = ( + "https://github.com/{repo_id}/tree/{build_sha}/{target_resource_path}" + ) + + LINK_DISCOVERY_REGEX = r"\[([^\]]*)\]\(([^)]+)\)" + PREDEFINED_LINK_DISCOVERY_REGEX = r"(\[[^\]]+]\:)\s*([^\s]+)" + + IMAGE_FILE_EXTENSIONS = ['.jpeg', '.jpg', '.png', '.gif', '.tiff'] + RELATIVE_LINK_REPLACEMENT_SYNTAX_FOR_IMAGE = ( + "https://github.com/{repo_id}/raw/{build_sha}/{target_resource_path}" + ) + + def locate_readmes(directory): + readme_set = [] + + for root, dirs, files in os.walk(directory): + for file in files: + if file.lower() == "readme.md": + readme_set.append(os.path.join(root, file)) + return readme_set + + + def is_relative_link(link_value, readme_location): + link_without_location = link_value + if link_without_location.find('#') > 0: + link_without_location = link_without_location[0:link_without_location.find('#')] + + try: + return os.path.exists( + os.path.abspath(os.path.join(os.path.dirname(readme_location), link_without_location)) + ) + except: + return False + + + def replace_relative_link(match, readme_location, root_folder, build_sha, repo_id): + link_path = match.group(2).strip() + + if is_relative_link(link_path, readme_location): + # if it is a relative reference, we need to find the path from the root of the repository + resource_absolute_path = os.path.abspath( + os.path.join(os.path.dirname(readme_location), link_path) + ) + placement_from_root = os.path.relpath(resource_absolute_path, root_folder) + + suffix = Path(placement_from_root).suffix + if (suffix in IMAGE_FILE_EXTENSIONS): + updated_link = RELATIVE_LINK_REPLACEMENT_SYNTAX_FOR_IMAGE.format( + repo_id=repo_id, + build_sha=build_sha, + target_resource_path=placement_from_root, + ).replace("\\", "/") + else: + updated_link = RELATIVE_LINK_REPLACEMENT_SYNTAX.format( + repo_id=repo_id, + build_sha=build_sha, + target_resource_path=placement_from_root, + ).replace("\\", "/") + + return "[{}]({})".format(match.group(1), updated_link) + else: + return match.group(0) + + def replace_prefined_relative_links(match, readme_location, root_folder, build_sha, repo_id): + link_path = match.group(2).strip() + + if is_relative_link(link_path, readme_location): + # if it is a relative reference, we need to find the path from the root of the repository + resource_absolute_path = os.path.abspath( + os.path.join(os.path.dirname(readme_location), link_path) + ) + placement_from_root = os.path.relpath(resource_absolute_path, root_folder) + + suffix = Path(placement_from_root).suffix + if (suffix in IMAGE_FILE_EXTENSIONS): + updated_link = RELATIVE_LINK_REPLACEMENT_SYNTAX_FOR_IMAGE.format( + repo_id=repo_id, + build_sha=build_sha, + target_resource_path=placement_from_root, + ).replace("\\", "/") + else: + updated_link = RELATIVE_LINK_REPLACEMENT_SYNTAX.format( + repo_id=repo_id, + build_sha=build_sha, + target_resource_path=placement_from_root, + ).replace("\\", "/") + + return "{} {}".format(match.group(1), updated_link) + else: + return match.group(0) + + + def transfer_content_to_absolute_references( + root_folder, build_sha, repo_id, readme_location, content + ): + content = re.sub( + LINK_DISCOVERY_REGEX, + lambda match, readme_location=readme_location, root_folder=root_folder, build_sha=build_sha, repo_id=repo_id: replace_relative_link( + match, readme_location, root_folder, build_sha, repo_id + ), + content, + ) + + content = re.sub( + PREDEFINED_LINK_DISCOVERY_REGEX, + lambda match, readme_location=readme_location, root_folder=root_folder, build_sha=build_sha, repo_id=repo_id: replace_prefined_relative_links( + match, readme_location, root_folder, build_sha, repo_id + ), + content, + ) + + return content + + + if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Replaces relative links for any README.md under the target folder. Given any discovered relative link, will replace with the provided repoId and SHA. Case insensitive" + ) + + parser.add_argument( + "-t", + "--target", + dest="target_folder", + help="The target folder that contains a README ", + default="${{ parameters.TargetFolder }}", + ) + + parser.add_argument( + "-i", + "--repoid", + dest="repo_id", + help='The target repository used as the base for the path replacement. Full Id, example: "Azure/azure-sdk-for-net"', + default="${{ parameters.RepoId }}", + ) + + parser.add_argument( + "-r", + "--root", + dest="root_folder", + help="The root directory of the repository. This gives us the ability to rationalize links in situations where a relative link traverses UPWARDS from the readme.", + default="${{ parameters.RootFolder }}", + ) + + parser.add_argument( + "-s", + "--sha", + dest="build_sha", + help="The commit hash associated with this change. Using this will mean that links will never be broken.", + default="${{ parameters.BuildSHA }}", + ) + + args = parser.parse_args() + + logging.info("Root Folder: {}".format(args.root_folder)) + logging.info("Target Folder: {}".format(args.target_folder)) + logging.info("Repository Id: {}".format(args.repo_id)) + logging.info("Build SHA: {}".format(args.build_sha)) + + readme_files = locate_readmes(args.target_folder) + + for readme_location in readme_files: + try: + logging.info( + "Running Relative Link Replacement on {}.".format(readme_location) + ) + + with open(readme_location, "r", encoding="utf-8") as readme_stream: + readme_content = readme_stream.read() + + new_content = transfer_content_to_absolute_references( + args.root_folder, + args.build_sha, + args.repo_id, + readme_location, + readme_content, + ) + + with open(readme_location, "w", encoding="utf-8") as readme_stream: + readme_stream.write(new_content) + + except Exception as e: + logging.error(e) + exit(1) + + - script: | + git diff -U0 + displayName: Highlight Readme Updates \ No newline at end of file diff --git a/eng/common/pipelines/templates/steps/verify-agent-os.yml b/eng/common/pipelines/templates/steps/verify-agent-os.yml new file mode 100644 index 000000000000..b221583bc6da --- /dev/null +++ b/eng/common/pipelines/templates/steps/verify-agent-os.yml @@ -0,0 +1,37 @@ +parameters: + OSVmImage: $(OSVmImage) + +steps: + - task: PythonScript@0 + displayName: Verify Agent OS + inputs: + scriptSource: inline + script: | + # Script verifies the operating system for the platform on which it is being run + # Used in build pipelines to verify the build agent os + # Variable: The friendly name or image name of the os to verfy against + from __future__ import print_function + import sys + import platform + + os_parameter = "${{ parameters.OSVmImage }}".lower() + if os_parameter.startswith('mac') or os_parameter.startswith('darwin'): + os_parameter = 'macOS' + elif os_parameter.startswith('ubuntu') or os_parameter.startswith('linux'): + os_parameter = 'Linux' + elif os_parameter.startswith('vs') or os_parameter.startswith('win'): + os_parameter = 'Windows' + else: + raise Exception('Variable OSVmImage is empty or has an unexpected value [${{ parameters.OSVmImage }}]') + + + print("Job requested to run on OS: %s" % (os_parameter)) + + agent_os = platform.system() + agent_os = 'macOS' if agent_os == 'Darwin' else agent_os + + if (agent_os.lower() == os_parameter.lower()): + print('Job ran on OS: %s' % (agent_os)) + print('##vso[task.setvariable variable=OSName]%s' % (agent_os)) + else: + raise Exception('Job ran on the wrong OS: %s' % (agent_os)) diff --git a/eng/common/pipelines/templates/steps/verify-changelog.yml b/eng/common/pipelines/templates/steps/verify-changelog.yml new file mode 100644 index 000000000000..f90238471d95 --- /dev/null +++ b/eng/common/pipelines/templates/steps/verify-changelog.yml @@ -0,0 +1,25 @@ +parameters: +- name: PackageName + type: string + default: 'not-specified' +- name: ServiceName + type: string + default: 'not-specified' +- name: ForRelease + type: boolean + default: false + +steps: + - task: Powershell@2 + inputs: + filePath: /eng/common/scripts/Verify-ChangeLog.ps1 + arguments: > + -PackageName ${{ parameters.PackageName }} + -ServiceName ${{ parameters.ServiceName }} + -RepoRoot $(Build.SourcesDirectory) + -RepoName $(Build.Repository.Name) + -ForRelease ${{ parameters.ForRelease }} + pwsh: true + workingDirectory: $(Pipeline.Workspace) + displayName: Verify ChangeLog / Release Notes + continueOnError: false \ No newline at end of file diff --git a/eng/common/pipelines/templates/steps/verify-links.yml b/eng/common/pipelines/templates/steps/verify-links.yml new file mode 100644 index 000000000000..1a99350f0161 --- /dev/null +++ b/eng/common/pipelines/templates/steps/verify-links.yml @@ -0,0 +1,12 @@ +parameters: + Directory: 'not-specified' + +steps: + - task: PowerShell@2 + displayName: Link verification check + inputs: + pwsh: true + workingDirectory: $(Build.SourcesDirectory)/${{ parameters.Directory }} + filePath: eng/common/scripts/Verify-Links.ps1 + arguments: > + -urls $(dir -r -i *.md) -rootUrl "file://$(Build.SourcesDirectory)/${{ parameters.Directory }}" diff --git a/eng/common/pipelines/templates/steps/verify-path-length.yml b/eng/common/pipelines/templates/steps/verify-path-length.yml new file mode 100644 index 000000000000..79c598035e31 --- /dev/null +++ b/eng/common/pipelines/templates/steps/verify-path-length.yml @@ -0,0 +1,59 @@ +# Template for all Python Scripts in this repository +parameters: + SourceDirectory: '' + BasePathLength: 49 + +steps: + - task: PythonScript@0 + displayName: Analyze Path Lengths + inputs: + scriptSource: inline + script: | + # Verifies Length of file path for all files in the SourceDirectory. + # File paths and directory paths must be less than 260 and 248 characters respectively on windows OS + # Repo users get a limited number of characters for the repo clone path. As Specified by the BasePathLength parameter. + # Script makes sure that paths in the repo are less than 260 and 248 for files and directories respectively after adding the BasePathLength. + import os + import sys + + source_directory = r'${{ parameters.SourceDirectory }}' + longest_file_path = '' + longest_file_path_length = 0 + longest_dir_path = '' + longest_dir_path_length = 0 + break_switch = False + long_file_paths = [] + long_dir_paths = [] + + def pluralize(string, plural_string, count): + return plural_string if count > 1 else string + + print('Analyzing length of paths...') + for root, dirs, files in os.walk('{0}'.format(source_directory)): + for file in files: + file_path = os.path.relpath(os.path.join(root, file), source_directory) + if ((len(file_path) + ${{ parameters.BasePathLength }}) > longest_file_path_length): + longest_file_path_length = len(file_path) + ${{ parameters.BasePathLength }} + longest_file_path = file_path + if (longest_file_path_length >= 260): + long_file_paths.append(longest_file_path) + dir_path = os.path.relpath(root, source_directory) + if ((len(dir_path) + ${{ parameters.BasePathLength }}) > longest_dir_path_length): + longest_dir_path_length = len(dir_path) + ${{ parameters.BasePathLength }} + longest_dir_path = dir_path + if (longest_dir_path_length >= 248): + long_dir_paths.append(longest_dir_path) + + if (len(long_file_paths) > 0): + print('With a base path length of {0} the following file path{1} exceed the allow path length of 260 characters'.format(${{ parameters.BasePathLength }}, pluralize('', 's', len(long_file_paths)))) + print(*long_file_paths, sep = "\n") + break_switch = True + + if (len(long_dir_paths) > 0): + print('With a base path length of {0} the following directory path{1} exceed the allow path length of 248 characters'.format(${{ parameters.BasePathLength }}, pluralize('', 's', len(long_dir_paths)))) + print(*long_dir_paths, sep = "\n") + break_switch = True + + if break_switch == True: + print("Some file paths are too long. Please reduce path lengths") + exit(1) diff --git a/eng/common/scripts/SemVer.ps1 b/eng/common/scripts/SemVer.ps1 new file mode 100644 index 000000000000..402c9fb1f57d --- /dev/null +++ b/eng/common/scripts/SemVer.ps1 @@ -0,0 +1,105 @@ +<# +.DESCRIPTION +Parses a semver version string into its components and supports operations around it that we use for versioning our packages. + +See https://azure.github.io/azure-sdk/policies_releases.html#package-versioning + +Example: 1.2.3-preview.4 +Components: Major.Minor.Patch-PrereleaseLabel.PrereleaseNumber + +Note: A builtin Powershell version of SemVer exists in 'System.Management.Automation'. At this time, it does not parsing of PrereleaseNumber. It's name is also type accelerated to 'SemVer'. +#> + +class AzureEngSemanticVersion { + [int] $Major + [int] $Minor + [int] $Patch + [string] $PrereleaseLabel + [int] $PrereleaseNumber + [bool] $IsPrerelease + [string] $RawVersion + # Regex inspired but simplified from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + static [string] $SEMVER_REGEX = "(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-?(?[a-zA-Z-]*)(?:\.?(?0|[1-9]\d*)))?" + + static [AzureEngSemanticVersion] ParseVersionString([string] $versionString) + { + try { + return [AzureEngSemanticVersion]::new($versionString) + } + catch { + return $null + } + } + + AzureEngSemanticVersion([string] $versionString){ + if ($versionString -match "^$([AzureEngSemanticVersion]::SEMVER_REGEX)$") { + if ($null -eq $matches['prelabel']) { + # artifically provide these values for non-prereleases to enable easy sorting of them later than prereleases. + $prelabel = "zzz" + $prenumber = 999; + $isPre = $false; + } + else { + $prelabel = $matches["prelabel"] + $prenumber = [int]$matches["prenumber"] + $isPre = $true; + } + + $this.Major = [int]$matches.Major + $this.Minor = [int]$matches.Minor + $this.Patch = [int]$matches.Patch + $this.PrereleaseLabel = $prelabel + $this.PrereleaseNumber = $prenumber + $this.IsPrerelease = $isPre + $this.RawVersion = $versionString + } + else + { + throw "Invalid version string: '$versionString'" + } + } + + # If a prerelease label exists, it must be 'preview', and similar semantics used in our release guidelines + # See https://azure.github.io/azure-sdk/policies_releases.html#package-versioning + [bool] HasValidPrereleaseLabel(){ + if ($this.IsPrerelease -eq $true) { + if ($this.PrereleaseLabel -ne 'preview') { + Write-Error "Unexpected pre-release identifier '$this.PrereleaseLabel', should be 'preview'" + return $false; + } + if ($this.PrereleaseNumber -lt 1) + { + Write-Error "Unexpected pre-release version '$this.PrereleaseNumber', should be >= '1'" + return $false; + } + } + return $true; + } + + [string] ToString(){ + if ($this.IsPrerelease -eq $false) + { + $versionString = "{0}.{1}.{2}" -F $this.Major, $this.Minor, $this.Patch + } + else + { + $versionString = "{0}.{1}.{2}-{3}.{4}" -F $this.Major, $this.Minor, $this.Patch, $this.PrereleaseLabel, $this.PrereleaseNumber + } + return $versionString; + } + + [void] IncrementAndSetToPrerelease(){ + if ($this.IsPrerelease -eq $false) + { + $this.PrereleaseLabel = 'preview' + $this.PrereleaseNumber = 1 + $this.Minor++ + $this.Patch = 0 + $this.IsPrerelease = $true + } + else + { + $this.PrereleaseNumber++ + } + } +} diff --git a/eng/common/scripts/Submit-PullRequest.ps1 b/eng/common/scripts/Submit-PullRequest.ps1 index 70edecbcaa83..ef2a6f545061 100644 --- a/eng/common/scripts/Submit-PullRequest.ps1 +++ b/eng/common/scripts/Submit-PullRequest.ps1 @@ -41,19 +41,25 @@ param( $PRBody = $PRTitle ) +$headers = @{ + Authorization = "bearer $AuthToken" +} + $query = "state=open&head=${PROwner}:${PRBranch}&base=${BaseBranch}" -$resp = Invoke-RestMethod "https://api.github.com/repos/$RepoOwner/$RepoName/pulls?$query" +try { + $resp = Invoke-RestMethod -Headers $headers "https://api.github.com/repos/$RepoOwner/$RepoName/pulls?$query" +} +catch { + Write-Error "Invoke-RestMethod [https://api.github.com/repos/$RepoOwner/$RepoName/pulls?$query] failed with exception:`n$_" + exit 1 +} $resp | Write-Verbose if ($resp.Count -gt 0) { Write-Host -f green "Pull request already exists $($resp[0].html_url)" } else { - $headers = @{ - Authorization = "bearer $AuthToken" - } - $data = @{ title = $PRTitle head = "${PROwner}:${PRBranch}" @@ -62,9 +68,16 @@ else { maintainer_can_modify = $true } - $resp = Invoke-RestMethod -Method POST -Headers $headers ` - https://api.github.com/repos/$RepoOwner/$RepoName/pulls ` - -Body ($data | ConvertTo-Json) + try { + $resp = Invoke-RestMethod -Method POST -Headers $headers ` + "https://api.github.com/repos/$RepoOwner/$RepoName/pulls" ` + -Body ($data | ConvertTo-Json) + } + catch { + Write-Error "Invoke-RestMethod [https://api.github.com/repos/$RepoOwner/$RepoName/pulls] failed with exception:`n$_" + exit 1 + } + $resp | Write-Verbose Write-Host -f green "Pull request created https://github.com/$RepoOwner/$RepoName/pull/$($resp.number)" } diff --git a/eng/common/scripts/Verify-ChangeLog.ps1 b/eng/common/scripts/Verify-ChangeLog.ps1 new file mode 100644 index 000000000000..1f9991bfd668 --- /dev/null +++ b/eng/common/scripts/Verify-ChangeLog.ps1 @@ -0,0 +1,22 @@ +# Wrapper Script for ChangeLog Verification +param ( + [Parameter(Mandatory=$true)] + [string]$PackageName, + [Parameter(Mandatory=$true)] + [string]$ServiceName, + [string]$RepoRoot, + [ValidateSet("net","java","js","python")] + [string]$Language, + [string]$RepoName, + [boolean]$ForRelease=$False +) + +Import-Module "${PSScriptRoot}/modules/common-manifest.psd1" + +if ([System.String]::IsNullOrEmpty($Language)) +{ + $Language = $RepoName.Substring($RepoName.LastIndexOf('-') + 1) +} + +$PackageProp = Get-PkgProperties -PackageName $PackageName -ServiceName $ServiceName -Language $Language -RepoRoot $RepoRoot +Confirm-ChangeLog -ChangeLogLocation $PackageProp.pkgChangeLogPath -VersionString $PackageProp.pkgReadMePath -ForRelease $ForRelease \ No newline at end of file diff --git a/eng/common/scripts/Verify-Links.ps1 b/eng/common/scripts/Verify-Links.ps1 new file mode 100644 index 000000000000..c78ce9020ebb --- /dev/null +++ b/eng/common/scripts/Verify-Links.ps1 @@ -0,0 +1,244 @@ +param ( + # url list to verify links. Can either be a http address or a local file request. Local file paths support md and html files. + [string[]] $urls, + # file that contains a set of links to ignore when verifying + [string] $ignoreLinksFile = "$PSScriptRoot/ignore-links.txt", + # switch that will enable devops specific logging for warnings + [switch] $devOpsLogging = $false, + # check the links recurisvely based on recursivePattern + [switch] $recursive = $true, + # recusiving check links for all links verified that begin with this baseUrl, defaults to the folder the url is contained in + [string] $baseUrl = "", + # path to the root of the site for resolving rooted relative links, defaults to host root for http and file directory for local files + [string] $rootUrl = "", + # list of http status codes count as broken links. Defaults to 404. + [array] $errorStatusCodes = @(404), + # flag to allow resolving relative paths or not + [bool] $resolveRelativeLinks = $true +) + +$ProgressPreference = "SilentlyContinue"; # Disable invoke-webrequest progress dialog + +function NormalizeUrl([string]$url){ + if (Test-Path $url) { + $url = "file://" + (Resolve-Path $url).ToString(); + } + + $uri = [System.Uri]$url; + + if ($script:baseUrl -eq "") { + # for base url default to containing directory + $script:baseUrl = (new-object System.Uri($uri, ".")).ToString(); + } + + if ($script:rootUrl -eq "") { + if ($uri.IsFile) { + # for files default to the containing directory + $script:rootUrl = $script:baseUrl; + } + else { + # for http links default to the root path + $script:rootUrl = new-object System.Uri($uri, "/"); + } + } + return $uri +} + +function LogWarning +{ + if ($devOpsLogging) + { + Write-Host "##vso[task.LogIssue type=warning;]$args" + } + else + { + Write-Warning "$args" + } +} + +function ResolveUri ([System.Uri]$referralUri, [string]$link) +{ + # If the link is mailto, skip it. + if ($link.StartsWith("mailto:")) { + Write-Verbose "Skipping $link because it is a mailto link." + return $null + } + + $linkUri = [System.Uri]$link; + if($resolveRelativeLinks){ + if (!$linkUri.IsAbsoluteUri) { + # For rooted paths resolve from the baseUrl + if ($link.StartsWith("/")) { + echo "rooturl = $rootUrl" + $linkUri = new-object System.Uri([System.Uri]$rootUrl, ".$link"); + } + else { + $linkUri = new-object System.Uri($referralUri, $link); + } + } + } + + $linkUri = [System.Uri]$linkUri.GetComponents([System.UriComponents]::HttpRequestUrl, [System.UriFormat]::SafeUnescaped) + Write-Verbose "ResolvedUri $link to $linkUri" + + # If the link is not a web request, like mailto, skip it. + if (!$linkUri.Scheme.StartsWith("http") -and !$linkUri.IsFile) { + Write-Verbose "Skipping $linkUri because it is not http or file based." + return $null + } + + if ($null -ne $ignoreLinks -and $ignoreLinks.Contains($link)) { + Write-Verbose "Ignoring invalid link $linkUri because it is in the ignore file." + return $null + } + + return $linkUri; +} + +function ParseLinks([string]$baseUri, [string]$htmlContent) +{ + $hrefRegex = "]+href\s*=\s*[""']?(?[^""']*)[""']?" + $regexOptions = [System.Text.RegularExpressions.RegexOptions]"Singleline, IgnoreCase"; + + $hrefs = [RegEx]::Matches($htmlContent, $hrefRegex, $regexOptions); + + #$hrefs | Foreach-Object { Write-Host $_ } + + Write-Verbose "Found $($hrefs.Count) raw href's in page $baseUri"; + $links = $hrefs | ForEach-Object { ResolveUri $baseUri $_.Groups["href"].Value } | Sort-Object -Unique + + #$links | Foreach-Object { Write-Host $_ } + + return $links +} + +function CheckLink ([System.Uri]$linkUri) +{ + if ($checkedLinks.ContainsKey($linkUri)) { return } + + Write-Verbose "Checking link $linkUri..." + if ($linkUri.IsFile) { + if (!(Test-Path $linkUri.LocalPath)) { + LogWarning "Link to file does not exist $($linkUri.LocalPath)" + $script:badLinks += $linkUri + } + } + else { + try { + $response = Invoke-WebRequest -Uri $linkUri + $statusCode = $response.StatusCode + if ($statusCode -ne 200) { + Write-Host "[$statusCode] while requesting $linkUri" + } + } + catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + + if ($statusCode -in $errorStatusCodes) { + LogWarning "[$statusCode] broken link $linkUri" + $script:badLinks += $linkUri + } + else { + if ($null -ne $statusCode) { + Write-Host "[$statusCode] while requesting $linkUri" + } + else { + Write-Host "Exception while requesting $linkUri" + Write-Host $_.Exception.ToString() + } + } + } + } + $checkedLinks[$linkUri] = $true; +} + +function GetLinks([System.Uri]$pageUri) +{ + if ($pageUri.Scheme.StartsWith("http")) { + try { + $response = Invoke-WebRequest -Uri $pageUri + $content = $response.Content + } + catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + Write-Error "Invalid page [$statusCode] $pageUri" + } + } + elseif ($pageUri.IsFile -and (Test-Path $pageUri.LocalPath)) { + $file = $pageUri.LocalPath + if ($file.EndsWith(".md")) { + $content = (ConvertFrom-MarkDown $file).html + } + elseif ($file.EndsWith(".html")) { + $content = Get-Content $file + } + else { + if (Test-Path ($file + "index.html")) { + $content = Get-Content ($file + "index.html") + } + else { + # Fallback to just reading the content directly + $content = Get-Content $file + } + } + } + else { + Write-Error "Don't know how to process uri $pageUri" + } + + $links = ParseLinks $pageUri $content + + return $links; +} + +if ($urls) { + if ($urls.Count -eq 0) { + Write-Host "Usage $($MyInvocation.MyCommand.Name) "; + exit 1; + } +} + +if ($PSVersionTable.PSVersion.Major -lt 6) +{ + LogWarning "Some web requests will not work in versions of PS earlier then 6. You are running version $($PSVersionTable.PSVersion)." +} + +$badLinks = @(); +$ignoreLinks = @(); +if (Test-Path $ignoreLinksFile) +{ + $ignoreLinks = [Array](Get-Content $ignoreLinksFile | ForEach-Object { ($_ -replace "#.*", "").Trim() } | Where-Object { $_ -ne "" }) +} + +$checkedPages = @{}; +$checkedLinks = @{}; +$pageUrisToCheck = new-object System.Collections.Queue + +foreach ($url in $urls) { + $uri = NormalizeUrl $url + $pageUrisToCheck.Enqueue($uri); +} + +while ($pageUrisToCheck.Count -ne 0) +{ + $pageUri = $pageUrisToCheck.Dequeue(); + if ($checkedPages.ContainsKey($pageUri)) { continue } + $checkedPages[$pageUri] = $true; + + $linkUris = GetLinks $pageUri + Write-Host "Found $($linkUris.Count) links on page $pageUri"; + + foreach ($linkUri in $linkUris) { + CheckLink $linkUri + if ($recursive) { + if ($linkUri.ToString().StartsWith($baseUrl) -and !$checkedPages.ContainsKey($linkUri)) { + $pageUrisToCheck.Enqueue($linkUri); + } + } + } +} + +Write-Host "Found $($checkedLinks.Count) links with $($badLinks.Count) broken" +$badLinks | ForEach-Object { Write-Host " $_" } + +exit $badLinks.Count diff --git a/eng/common/scripts/artifact-metadata-parsing.ps1 b/eng/common/scripts/artifact-metadata-parsing.ps1 new file mode 100644 index 000000000000..e48a526fe9e5 --- /dev/null +++ b/eng/common/scripts/artifact-metadata-parsing.ps1 @@ -0,0 +1,514 @@ +Import-Module "${PSScriptRoot}/modules/ChangeLog-Operations.psm1" +. (Join-Path $PSScriptRoot SemVer.ps1) + +$SDIST_PACKAGE_REGEX = "^(?.*)\-(?$([AzureEngSemanticVersion]::SEMVER_REGEX))" + +# Posts a github release for each item of the pkgList variable. SilentlyContinue +function CreateReleases($pkgList, $releaseApiUrl, $releaseSha) { + foreach ($pkgInfo in $pkgList) { + Write-Host "Creating release $($pkgInfo.Tag)" + + $releaseNotes = "" + if ($pkgInfo.ReleaseNotes -ne $null) { + $releaseNotes = $pkgInfo.ReleaseNotes + } + + $isPrerelease = $False + + $parsedSemver = [AzureEngSemanticVersion]::ParseVersionString($pkgInfo.PackageVersion) + + if ($parsedSemver) { + $isPrerelease = $parsedSemver.IsPrerelease + } + + $url = $releaseApiUrl + $body = ConvertTo-Json @{ + tag_name = $pkgInfo.Tag + target_commitish = $releaseSha + name = $pkgInfo.Tag + draft = $False + prerelease = $isPrerelease + body = $releaseNotes + } + + $headers = @{ + "Content-Type" = "application/json" + "Authorization" = "token $($env:GH_TOKEN)" + } + + Invoke-WebRequest-WithHandling -url $url -body $body -headers $headers -method "Post" + } +} + +function Invoke-WebRequest-WithHandling($url, $method, $body = $null, $headers = $null) { + $attempts = 1 + + while ($attempts -le 3) { + try { + return Invoke-RestMethod -Method $method -Uri $url -Body $body -Headers $headers + } + catch { + $response = $_.Exception.Response + + $statusCode = $response.StatusCode.value__ + $statusDescription = $response.StatusDescription + + if ($statusCode) { + Write-Host "API request attempt number $attempts to $url failed with statuscode $statusCode" + Write-Host $statusDescription + + Write-Host "Rate Limit Details:" + Write-Host "Total: $($response.Headers.GetValues("X-RateLimit-Limit"))" + Write-Host "Remaining: $($response.Headers.GetValues("X-RateLimit-Remaining"))" + Write-Host "Reset Epoch: $($response.Headers.GetValues("X-RateLimit-Reset"))" + } + else { + Write-Host "API request attempt number $attempts to $url failed with no statuscode present, exception follows:" + Write-Host $_.Exception.Response + Write-Host $_.Exception + } + + if ($attempts -ge 3) { + Write-Host "Abandoning Request $url after 3 attempts." + exit(1) + } + + Start-Sleep -s 10 + } + + $attempts += 1 + } +} + +# Parse out package publishing information given a maven POM file +function ParseMavenPackage($pkg, $workingDirectory) { + [xml]$contentXML = Get-Content $pkg + + $pkgId = $contentXML.project.artifactId + $pkgVersion = $contentXML.project.version + $groupId = if ($contentXML.project.groupId -eq $null) { $contentXML.project.parent.groupId } else { $contentXML.project.groupId } + $releaseNotes = "" + $readmeContent = "" + + # if it's a snapshot. return $null (as we don't want to create tags for this, but we also don't want to fail) + if ($pkgVersion.Contains("SNAPSHOT")) { + return $null + } + + $changeLogLoc = @(Get-ChildItem -Path $pkg.DirectoryName -Recurse -Include "$($pkg.Basename)-changelog.md")[0] + if ($changeLogLoc) { + $releaseNotes = Get-ChangeLogEntryAsString -ChangeLogLocation $changeLogLoc -VersionString $pkgVersion + } + + $readmeContentLoc = @(Get-ChildItem -Path $pkg.DirectoryName -Recurse -Include "$($pkg.Basename)-readme.md")[0] + if ($readmeContentLoc) { + $readmeContent = Get-Content -Raw $readmeContentLoc + } + + return New-Object PSObject -Property @{ + PackageId = $pkgId + PackageVersion = $pkgVersion + Deployable = $forceCreate -or !(IsMavenPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion -groupId $groupId.Replace(".", "/")) + ReleaseNotes = $releaseNotes + ReadmeContent = $readmeContent + } +} + +# Returns the maven (really sonatype) publish status of a package id and version. +function IsMavenPackageVersionPublished($pkgId, $pkgVersion, $groupId) { + try { + + $uri = "https://oss.sonatype.org/content/repositories/releases/$groupId/$pkgId/$pkgVersion/$pkgId-$pkgVersion.pom" + $pomContent = Invoke-RestMethod -MaximumRetryCount 3 -Method "GET" -uri $uri + + if ($pomContent -ne $null -or $pomContent.Length -eq 0) { + return $true + } + else { + return $false + } + } + catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + $statusDescription = $_.Exception.Response.StatusDescription + + # if this is 404ing, then this pkg has never been published before + if ($statusCode -eq 404) { + return $false + } + + Write-Host "VersionCheck to maven for packageId $pkgId failed with statuscode $statusCode" + Write-Host $statusDescription + exit(1) + } +} + +# make certain to always take the package json closest to the top +function ResolvePkgJson($workFolder) { + $pathsWithComplexity = @() + foreach ($file in (Get-ChildItem -Path $workFolder -Recurse -Include "package.json")) { + $complexity = ($file.FullName -Split { $_ -eq "/" -or $_ -eq "\" }).Length + $pathsWithComplexity += New-Object PSObject -Property @{ + Path = $file + Complexity = $complexity + } + } + + return ($pathsWithComplexity | Sort-Object -Property Complexity)[0].Path +} + +# Parse out package publishing information given a .tgz npm artifact +function ParseNPMPackage($pkg, $workingDirectory) { + $workFolder = "$workingDirectory$($pkg.Basename)" + $origFolder = Get-Location + $releaseNotes = "" + $readmeContent = "" + + New-Item -ItemType Directory -Force -Path $workFolder + cd $workFolder + + tar -xzf $pkg + + $packageJSON = ResolvePkgJson -workFolder $workFolder | Get-Content | ConvertFrom-Json + $pkgId = $packageJSON.name + $pkgVersion = $packageJSON.version + + $changeLogLoc = @(Get-ChildItem -Path $workFolder -Recurse -Include "CHANGELOG.md")[0] + if ($changeLogLoc) { + $releaseNotes = Get-ChangeLogEntryAsString -ChangeLogLocation $changeLogLoc -VersionString $pkgVersion + } + + $readmeContentLoc = @(Get-ChildItem -Path $workFolder -Recurse -Include "README.md") | Select-Object -Last 1 + if ($readmeContentLoc) { + $readmeContent = Get-Content -Raw $readmeContentLoc + } + + cd $origFolder + Remove-Item $workFolder -Force -Recurse -ErrorAction SilentlyContinue + + $resultObj = New-Object PSObject -Property @{ + PackageId = $pkgId + PackageVersion = $pkgVersion + Deployable = $forceCreate -or !(IsNPMPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion) + ReleaseNotes = $releaseNotes + ReadmeContent = $readmeContent + } + + return $resultObj +} + +# Returns the npm publish status of a package id and version. +function IsNPMPackageVersionPublished($pkgId, $pkgVersion) { + $npmVersions = (npm show $pkgId versions) + + if ($LastExitCode -ne 0) { + npm ping + + if ($LastExitCode -eq 0) { + return $False + } + + Write-Host "Could not find a deployed version of $pkgId, and NPM connectivity check failed." + exit(1) + } + + $npmVersionList = $npmVersions.split(",") | % { return $_.replace("[", "").replace("]", "").Trim() } + return $npmVersionList.Contains($pkgVersion) +} + +# Parse out package publishing information given a nupkg ZIP format. +function ParseNugetPackage($pkg, $workingDirectory) { + $workFolder = "$workingDirectory$($pkg.Basename)" + $origFolder = Get-Location + $zipFileLocation = "$workFolder/$($pkg.Basename).zip" + $releaseNotes = "" + $readmeContent = "" + + New-Item -ItemType Directory -Force -Path $workFolder + + Copy-Item -Path $pkg -Destination $zipFileLocation + Expand-Archive -Path $zipFileLocation -DestinationPath $workFolder + [xml] $packageXML = Get-ChildItem -Path "$workFolder/*.nuspec" | Get-Content + $pkgId = $packageXML.package.metadata.id + $pkgVersion = $packageXML.package.metadata.version + + $changeLogLoc = @(Get-ChildItem -Path $workFolder -Recurse -Include "CHANGELOG.md")[0] + if ($changeLogLoc) { + $releaseNotes = Get-ChangeLogEntryAsString -ChangeLogLocation $changeLogLoc -VersionString $pkgVersion + } + + $readmeContentLoc = @(Get-ChildItem -Path $workFolder -Recurse -Include "README.md")[0] + if ($readmeContentLoc) { + $readmeContent = Get-Content -Raw $readmeContentLoc + } + + Remove-Item $workFolder -Force -Recurse -ErrorAction SilentlyContinue + + return New-Object PSObject -Property @{ + PackageId = $pkgId + PackageVersion = $pkgVersion + Deployable = $forceCreate -or !(IsNugetPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion) + ReleaseNotes = $releaseNotes + ReadmeContent = $readmeContent + } +} + +# Returns the nuget publish status of a package id and version. +function IsNugetPackageVersionPublished($pkgId, $pkgVersion) { + + $nugetUri = "https://api.nuget.org/v3-flatcontainer/$($pkgId.ToLowerInvariant())/index.json" + + try { + $nugetVersions = Invoke-RestMethod -MaximumRetryCount 3 -uri $nugetUri -Method "GET" + + return $nugetVersions.versions.Contains($pkgVersion) + } + catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + $statusDescription = $_.Exception.Response.StatusDescription + + # if this is 404ing, then this pkg has never been published before + if ($statusCode -eq 404) { + return $False + } + + Write-Host "Nuget Invocation failed:" + Write-Host "StatusCode:" $statusCode + Write-Host "StatusDescription:" $statusDescription + exit(1) + } + +} + +# Parse out package publishing information given a python sdist of ZIP format. +function ParsePyPIPackage($pkg, $workingDirectory) { + $pkg.Basename -match $SDIST_PACKAGE_REGEX | Out-Null + + $pkgId = $matches["package"] + $pkgVersion = $matches["versionstring"] + + $workFolder = "$workingDirectory$($pkg.Basename)" + $origFolder = Get-Location + $releaseNotes = "" + $readmeContent = "" + + New-Item -ItemType Directory -Force -Path $workFolder + Expand-Archive -Path $pkg -DestinationPath $workFolder + + $changeLogLoc = @(Get-ChildItem -Path $workFolder -Recurse -Include "CHANGELOG.md")[0] + if ($changeLogLoc) { + $releaseNotes = Get-ChangeLogEntryAsString -ChangeLogLocation $changeLogLoc -VersionString $pkgVersion + } + + $readmeContentLoc = @(Get-ChildItem -Path $workFolder -Recurse -Include "README.md") | Select-Object -Last 1 + + if ($readmeContentLoc) { + $readmeContent = Get-Content -Raw $readmeContentLoc + } + + Remove-Item $workFolder -Force -Recurse -ErrorAction SilentlyContinue + + return New-Object PSObject -Property @{ + PackageId = $pkgId + PackageVersion = $pkgVersion + Deployable = $forceCreate -or !(IsPythonPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion) + ReleaseNotes = $releaseNotes + ReadmeContent = $readmeContent + } +} + +function ParseCArtifact($pkg, $workingDirectory) { + $packageInfo = Get-Content -Raw -Path $pkg | ConvertFrom-JSON + $packageArtifactLocation = (Get-ItemProperty $pkg).Directory.FullName + $releaseNotes = "" + $readmeContent = "" + + $pkgVersion = $packageInfo.version + + $changeLogLoc = @(Get-ChildItem -Path $packageArtifactLocation -Recurse -Include "CHANGELOG.md")[0] + if ($changeLogLoc) + { + $releaseNotes = Get-ChangeLogEntryAsString -ChangeLogLocation $changeLogLoc -VersionString $pkgVersion + } + + $readmeContentLoc = @(Get-ChildItem -Path $packageArtifactLocation -Recurse -Include "README.md")[0] + if ($readmeContentLoc) { + $readmeContent = Get-Content -Raw $readmeContentLoc + } + + return New-Object PSObject -Property @{ + PackageId = 'azure-sdk-for-c' + PackageVersion = $pkgVersion + # Artifact info is always considered deployable for C becasue it is not + # deployed anywhere. Dealing with duplicate tags happens downstream in + # CheckArtifactShaAgainstTagsList + Deployable = $true + ReleaseNotes = $releaseNotes + } +} + +# Returns the pypi publish status of a package id and version. +function IsPythonPackageVersionPublished($pkgId, $pkgVersion) { + try { + $existingVersion = (Invoke-RestMethod -MaximumRetryCount 3 -Method "Get" -uri "https://pypi.org/pypi/$pkgId/$pkgVersion/json").info.version + + # if existingVersion exists, then it's already been published + return $True + } + catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + $statusDescription = $_.Exception.Response.StatusDescription + + # if this is 404ing, then this pkg has never been published before + if ($statusCode -eq 404) { + return $False + } + + Write-Host "PyPI Invocation failed:" + Write-Host "StatusCode:" $statusCode + Write-Host "StatusDescription:" $statusDescription + exit(1) + } +} + +# Retrieves the list of all tags that exist on the target repository +function GetExistingTags($apiUrl) { + try { + return (Invoke-WebRequest-WithHandling -Method "GET" -url "$apiUrl/git/refs/tags" ) | % { $_.ref.Replace("refs/tags/", "") } + } + catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + $statusDescription = $_.Exception.Response.StatusDescription + + Write-Host "Failed to retrieve tags from repository." + Write-Host "StatusCode:" $statusCode + Write-Host "StatusDescription:" $statusDescription + + # Return an empty list if there are no tags in the repo + if ($statusCode -eq 404) { + return @() + } + + exit(1) + } +} + +# Walk across all build artifacts, check them against the appropriate repository, return a list of tags/releases +function VerifyPackages($pkgRepository, $artifactLocation, $workingDirectory, $apiUrl, $releaseSha, $continueOnError = $false) { + $pkgList = [array]@() + $ParsePkgInfoFn = "" + $packagePattern = "" + + switch ($pkgRepository) { + "Maven" { + $ParsePkgInfoFn = "ParseMavenPackage" + $packagePattern = "*.pom" + break + } + "Nuget" { + $ParsePkgInfoFn = "ParseNugetPackage" + $packagePattern = "*.nupkg" + break + } + "NPM" { + $ParsePkgInfoFn = "ParseNPMPackage" + $packagePattern = "*.tgz" + break + } + "PyPI" { + $ParsePkgInfoFn = "ParsePyPIPackage" + $packagePattern = "*.zip" + break + } + "C" { + $ParsePkgInfoFn = "ParseCArtifact" + $packagePattern = "*.json" + } + default { + Write-Host "Unrecognized Language: $language" + exit(1) + } + } + + $pkgs = (Get-ChildItem -Path $artifactLocation -Include $packagePattern -Recurse -File) + + foreach ($pkg in $pkgs) { + try { + $parsedPackage = &$ParsePkgInfoFn -pkg $pkg -workingDirectory $workingDirectory + + if ($parsedPackage -eq $null) { + continue + } + + if ($parsedPackage.Deployable -ne $True -and !$continueOnError) { + Write-Host "Package $($parsedPackage.PackageId) is marked with version $($parsedPackage.PackageVersion), the version $($parsedPackage.PackageVersion) has already been deployed to the target repository." + Write-Host "Maybe a pkg version wasn't updated properly?" + exit(1) + } + + $tag = if ($parsedPackage.packageId) { + "$($parsedPackage.packageId)_$($parsedPackage.PackageVersion)" + } else { + $parsedPackage.PackageVersion + } + + $pkgList += New-Object PSObject -Property @{ + PackageId = $parsedPackage.PackageId + PackageVersion = $parsedPackage.PackageVersion + Tag = $tag + ReleaseNotes = $parsedPackage.ReleaseNotes + ReadmeContent = $parsedPackage.ReadmeContent + } + } + catch { + Write-Host $_.Exception.Message + exit(1) + } + } + + $results = @([array]$pkgList | Sort-Object -Property Tag -uniq) + + $existingTags = GetExistingTags($apiUrl) + + $intersect = $results | % { $_.Tag } | ? { $existingTags -contains $_ } + + if ($intersect.Length -gt 0 -and !$continueOnError) { + CheckArtifactShaAgainstTagsList -priorExistingTagList $intersect -releaseSha $releaseSha -apiUrl $apiUrl -continueOnError $continueOnError + + # all the tags are clean. remove them from the list of releases we will publish. + $results = $results | ? { -not ($intersect -contains $_.Tag ) } + } + + return $results +} + +# given a set of tags that we want to release, we need to ensure that if they already DO exist. +# if they DO exist, quietly exit if the commit sha of the artifact matches that of the tag +# if the commit sha does not match, exit with error and report both problem shas +function CheckArtifactShaAgainstTagsList($priorExistingTagList, $releaseSha, $apiUrl, $continueOnError) { + $headers = @{ + "Content-Type" = "application/json" + "Authorization" = "token $($env:GH_TOKEN)" + } + + $unmatchedTags = @() + + foreach ($tag in $priorExistingTagList) { + $tagSha = (Invoke-WebRequest-WithHandling -Method "Get" -Url "$apiUrl/git/refs/tags/$tag" -Headers $headers)."object".sha + + if ($tagSha -eq $releaseSha) { + Write-Host "This package has already been released. The existing tag commit SHA $releaseSha matches the artifact SHA being processed. Skipping release step for this tag." + } + else { + Write-Host "The artifact SHA $releaseSha does not match that of the currently existing tag." + Write-Host "Tag with issues is $tag with commit SHA $tagSha" + + $unmatchedTags += $tag + } + } + + if ($unmatchedTags.Length -gt 0 -and !$continueOnError) { + Write-Host "Tags already existing with different SHA versions. Exiting." + exit(1) + } +} \ No newline at end of file diff --git a/eng/common/scripts/copy-docs-to-blobstorage.ps1 b/eng/common/scripts/copy-docs-to-blobstorage.ps1 index b454a1606eb5..0093c38e7f57 100644 --- a/eng/common/scripts/copy-docs-to-blobstorage.ps1 +++ b/eng/common/scripts/copy-docs-to-blobstorage.ps1 @@ -1,13 +1,15 @@ -# Note, due to how `Expand-Archive` is leveraged in this script, -# powershell core is a requirement for successful execution. +# Note, due to how `Expand-Archive` is leveraged in this script, +# powershell core is a requirement for successful execution. param ( $AzCopy, $DocLocation, $SASKey, $Language, $BlobName, - $ExitOnError=1 + $ExitOnError=1, + $UploadLatest=1 ) + $Language = $Language.ToLower() # Regex inspired but simplified from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string @@ -50,14 +52,14 @@ function ToSemVer($version){ { throw "Unable to convert $version to valid semver and hard exit on error is enabled. Exiting." } - else + else { return $null } } } -function SortSemVersions($versions) +function SortSemVersions($versions) { return $versions | Sort -Property Major, Minor, Patch, PrereleaseLabel, PrereleaseNumber -Descending } @@ -67,7 +69,7 @@ function Sort-Versions Param ( [Parameter(Mandatory=$true)] [string[]]$VersionArray ) - + # standard init and sorting existing $versionsObject = New-Object PSObject -Property @{ OriginalVersionArray = $VersionArray @@ -77,16 +79,16 @@ function Sort-Versions LatestPreviewPackage = "" } - if ($VersionArray.Count -eq 0) - { - return $versionsObject + if ($VersionArray.Count -eq 0) + { + return $versionsObject } - $versionsObject.SortedVersionArray = SortSemVersions -versions ($VersionArray | % { ToSemVer $_}) + $versionsObject.SortedVersionArray = @(SortSemVersions -versions ($VersionArray | % { ToSemVer $_})) $versionsObject.RawVersionsList = $versionsObject.SortedVersionArray | % { $_.RawVersion } # handle latest and preview - # we only want to hold onto the latest preview if its NEWER than the latest GA. + # we only want to hold onto the latest preview if its NEWER than the latest GA. # this means that the latest preview package either A) has to be the latest value in the VersionArray # or B) set to nothing. We'll handle the set to nothing case a bit later. $versionsObject.LatestPreviewPackage = $versionsObject.SortedVersionArray[0].RawVersion @@ -99,7 +101,7 @@ function Sort-Versions $versionsObject.LatestGAPackage = $gaVersions[0].RawVersion # in the case where latest preview == latestGA (because of our default selection earlier) - if ($versionsObject.LatestGAPackage -eq $versionsObject.LatestPreviewPackage) + if ($versionsObject.LatestGAPackage -eq $versionsObject.LatestPreviewPackage) { # latest is newest, unset latest preview $versionsObject.LatestPreviewPackage = "" @@ -151,7 +153,7 @@ function Update-Existing-Versions $existingVersions += $PkgVersion Write-Host "No existing versions. Adding $PkgVersion." } - else + else { $existingVersions += $pkgVersion Write-Host "Already Existing Versions. Adding $PkgVersion." @@ -166,7 +168,7 @@ function Update-Existing-Versions Write-Host $sortedVersionObj.LatestGAPackage Write-Host $sortedVersionObj.LatestPreviewPackage - # write to file. to get the correct performance with "actually empty" files, we gotta do the newline + # write to file. to get the correct performance with "actually empty" files, we gotta do the newline # join ourselves. This way we have absolute control over the trailing whitespace. $sortedVersionObj.RawVersionsList -join "`n" | Out-File -File "$($DocLocation)/versions" -Force -NoNewLine $sortedVersionObj.LatestGAPackage | Out-File -File "$($DocLocation)/latest-ga" -Force -NoNewLine @@ -175,6 +177,8 @@ function Update-Existing-Versions & $($AzCopy) cp "$($DocLocation)/versions" "$($DocDest)/$($PkgName)/versioning/versions$($SASKey)" & $($AzCopy) cp "$($DocLocation)/latest-preview" "$($DocDest)/$($PkgName)/versioning/latest-preview$($SASKey)" & $($AzCopy) cp "$($DocLocation)/latest-ga" "$($DocDest)/$($PkgName)/versioning/latest-ga$($SASKey)" + + return $sortedVersionObj } function Upload-Blobs @@ -197,7 +201,16 @@ function Upload-Blobs & $($AzCopy) cp "$($DocDir)/**" "$($DocDest)/$($PkgName)/$($DocVersion)$($SASKey)" --recursive=true Write-Host "Handling versioning files under $($DocDest)/$($PkgName)/versioning/" - Update-Existing-Versions -PkgName $PkgName -PkgVersion $DocVersion -DocDest $DocDest + $versionsObj = (Update-Existing-Versions -PkgName $PkgName -PkgVersion $DocVersion -DocDest $DocDest) + + # we can safely assume we have AT LEAST one version here. Reason being we just completed Update-Existing-Versions + $latestVersion = ($versionsObj.SortedVersionArray | Select-Object -First 1).RawVersion + + if ($UploadLatest -and ($latestVersion -eq $DocVersion)) + { + Write-Host "Uploading $($PkgName) to latest folder in $($DocDest)..." + & $($AzCopy) cp "$($DocDir)/**" "$($DocDest)/$($PkgName)/latest$($SASKey)" --recursive=true + } } @@ -210,7 +223,7 @@ if ($Language -eq "javascript") Write-Host $PkgName Expand-Archive -Force -Path "$($DocLocation)/documentation/$($Item.Name)" -DestinationPath "$($DocLocation)/documentation/$($Item.BaseName)" $dirList = Get-ChildItem "$($DocLocation)/documentation/$($Item.BaseName)/$($Item.BaseName)" -Attributes Directory - + if($dirList.Length -eq 1){ $DocVersion = $dirList[0].Name Write-Host "Uploading Doc for $($PkgName) Version:- $($DocVersion)..." @@ -231,7 +244,7 @@ if ($Language -eq "dotnet") $PkgName = $Item.Name.Remove(0, 5) $PkgFullName = $PublishedPkgs | Where-Object -FilterScript {$_.Name -match "$($PkgName).\d"} - if (($PkgFullName | Measure-Object).count -eq 1) + if (($PkgFullName | Measure-Object).count -eq 1) { $DocVersion = $PkgFullName[0].BaseName.Remove(0, $PkgName.Length + 1) @@ -252,12 +265,12 @@ if ($Language -eq "dotnet") if ($Language -eq "python") { $PublishedDocs = Get-ChildItem "$DocLocation" | Where-Object -FilterScript {$_.Name.EndsWith(".zip")} - + foreach ($Item in $PublishedDocs) { $PkgName = $Item.BaseName $ZippedDocumentationPath = Join-Path -Path $DocLocation -ChildPath $Item.Name $UnzippedDocumentationPath = Join-Path -Path $DocLocation -ChildPath $PkgName - $VersionFileLocation = Join-Path -Path $UnzippedDocumentationPath -ChildPath "version.txt" + $VersionFileLocation = Join-Path -Path $UnzippedDocumentationPath -ChildPath "version.txt" Expand-Archive -Force -Path $ZippedDocumentationPath -DestinationPath $UnzippedDocumentationPath @@ -289,6 +302,17 @@ if ($Language -eq "java") jar -xf "$($Item.FullName)" Set-Location $CurrentLocation + # If javadocs are produced for a library with source, there will always be an + # index.html. If this file doesn't exist in the UnjarredDocumentationPath then + # this is a sourceless library which means there are no javadocs and nothing + # should be uploaded to blob storage. + $IndexHtml = Join-Path -Path $UnjarredDocumentationPath -ChildPath "index.html" + if (!(Test-Path -path $IndexHtml)) + { + Write-Host "$($PkgName) does not have an index.html file, skippping." + continue + } + # Get the POM file for the artifact we're processing $PomFile = $Item.FullName.Substring(0,$Item.FullName.LastIndexOf(("-javadoc.jar"))) + ".pom" Write-Host "PomFile $($PomFile)" @@ -319,14 +343,11 @@ if ($Language -eq "java") if ($Language -eq "c") { # The documentation publishing process for C differs from the other - # langauges in this file because this script is invoked once per library + # langauges in this file because this script is invoked for the whole SDK # publishing. It is not, for example, invoked once per service publishing. - # This is also the case for other langauge publishing steps above... Those - # loops are left over from previous versions of this script which were used - # to publish multiple docs packages in a single invocation. + # There is a similar situation for other langauge publishing steps above... + # Those loops are left over from previous versions of this script which were + # used to publish multiple docs packages in a single invocation. $pkgInfo = Get-Content $DocLocation/package-info.json | ConvertFrom-Json - $pkgName = $pkgInfo.name - $pkgVersion = $pkgInfo.version - - Upload-Blobs -DocDir $DocLocation -PkgName $pkgName -DocVersion $pkgVersion + Upload-Blobs -DocDir $DocLocation -PkgName 'docs' -DocVersion $pkgInfo.version } \ No newline at end of file diff --git a/eng/common/scripts/create-tags-and-git-release.ps1 b/eng/common/scripts/create-tags-and-git-release.ps1 index 99d2c6dc4344..cec45536b704 100644 --- a/eng/common/scripts/create-tags-and-git-release.ps1 +++ b/eng/common/scripts/create-tags-and-git-release.ps1 @@ -6,6 +6,7 @@ param ( # used by VerifyPackages $artifactLocation, # the root of the artifact folder. DevOps $(System.ArtifactsDirectory) $workingDirectory, # directory that package artifacts will be extracted into for examination (if necessary) + [ValidateSet("Nuget","NPM","PyPI","Maven")] $packageRepository, # used to indicate destination against which we will check the existing version. # valid options: PyPI, Nuget, NPM, Maven, C # used by CreateTags @@ -15,456 +16,16 @@ param ( $repoOwner = "", # the owning organization of the repository. EG "Azure" $repoName = "", # the name of the repository. EG "azure-sdk-for-java" $repoId = "$repoOwner/$repoName", # full repo id. EG azure/azure-sdk-for-net DevOps: $(Build.Repository.Id), - [switch]$forceCreate = $false + [switch]$continueOnError = $false ) -$VERSION_REGEX = "(?\d+)(\.(?\d+))?(\.(?\d+))?((?
[^0-9][^\s]+))?"
-$SDIST_PACKAGE_REGEX = "^(?.*)\-(?$VERSION_REGEX$)"
-
-# Posts a github release for each item of the pkgList variable. SilentlyContinue
-function CreateReleases($pkgList, $releaseApiUrl, $releaseSha) {
-  foreach ($pkgInfo in $pkgList) {
-    Write-Host "Creating release $($pkgInfo.Tag)"
-
-    $releaseNotes = ""
-    if ($pkgInfo.ReleaseNotes[$pkgInfo.PackageVersion].ReleaseContent -ne $null) {
-      $releaseNotes = $pkgInfo.ReleaseNotes[$pkgInfo.PackageVersion].ReleaseContent
-    }
-
-    $isPrerelease = $False
-    if ($pkgInfo.PackageVersion -match $VERSION_REGEX) {
-      $preReleaseLabel = $matches["pre"]
-      $isPrerelease = ![string]::IsNullOrEmpty($preReleaseLabel)
-    }
-
-    $url = $releaseApiUrl
-    $body = ConvertTo-Json @{
-      tag_name         = $pkgInfo.Tag
-      target_commitish = $releaseSha
-      name             = $pkgInfo.Tag
-      draft            = $False
-      prerelease       = $isPrerelease
-      body             = $releaseNotes
-    }
-
-    $headers = @{
-      "Content-Type"  = "application/json"
-      "Authorization" = "token $($env:GH_TOKEN)"
-    }
-
-    FireAPIRequest -url $url -body $body -headers $headers -method "Post"
-  }
-}
-
-function FireAPIRequest($url, $method, $body = $null, $headers = $null) {
-  $attempts = 1
-
-  while ($attempts -le 3) {
-    try {
-      return Invoke-RestMethod -Method $method -Uri $url -Body $body -Headers $headers
-    }
-    catch {
-      $response = $_.Exception.Response
-
-      $statusCode = $response.StatusCode.value__
-      $statusDescription = $response.StatusDescription
-
-      if ($statusCode) {
-        Write-Host "API request attempt number $attempts to $url failed with statuscode $statusCode"
-        Write-Host $statusDescription
-
-        Write-Host "Rate Limit Details:"
-        Write-Host "Total: $($response.Headers.GetValues("X-RateLimit-Limit"))"
-        Write-Host "Remaining: $($response.Headers.GetValues("X-RateLimit-Remaining"))"
-        Write-Host "Reset Epoch: $($response.Headers.GetValues("X-RateLimit-Reset"))"
-      }
-      else {
-        Write-Host "API request attempt number $attempts to $url failed with no statuscode present, exception follows:"
-        Write-Host $_.Exception.Response
-        Write-Host $_.Exception
-      }
-
-      if ($attempts -ge 3) {
-        Write-Host "Abandoning Request $url after 3 attempts."
-        exit(1)
-      }
-
-      Start-Sleep -s 10
-    }
-
-    $attempts += 1
-  }
-}
-
-# Parse out package publishing information given a maven POM file
-function ParseMavenPackage($pkg, $workingDirectory) {
-  [xml]$contentXML = Get-Content $pkg
-
-  $pkgId = $contentXML.project.artifactId
-  $pkgVersion = $contentXML.project.version
-  $groupId = if ($contentXML.project.groupId -eq $null) { $contentXML.project.parent.groupId } else { $contentXML.project.groupId }
-
-  # if it's a snapshot. return $null (as we don't want to create tags for this, but we also don't want to fail)
-  if ($pkgVersion.Contains("SNAPSHOT")) {
-    return $null
-  }
-
-  $releaseNotes = &"${PSScriptRoot}/../Extract-ReleaseNotes.ps1" -ChangeLogLocation @(Get-ChildItem -Path $pkg.DirectoryName -Recurse -Include "$($pkg.Basename)-changelog.md")[0]
-
-  return New-Object PSObject -Property @{
-    PackageId      = $pkgId
-    PackageVersion = $pkgVersion
-    Deployable     = $forceCreate -or !(IsMavenPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion -groupId $groupId.Replace(".", "/"))
-    ReleaseNotes   = $releaseNotes
-  }
-}
-
-# Returns the maven (really sonatype) publish status of a package id and version.
-function IsMavenPackageVersionPublished($pkgId, $pkgVersion, $groupId) {
-  try {
-
-    $uri = "https://oss.sonatype.org/content/repositories/releases/$groupId/$pkgId/$pkgVersion/$pkgId-$pkgVersion.pom"
-    $pomContent = Invoke-RestMethod -Method "GET" -Uri $uri
-
-    if ($pomContent -ne $null -or $pomContent.Length -eq 0) {
-      return $true
-    }
-    else {
-      return $false
-    }
-  }
-  catch {
-    $statusCode = $_.Exception.Response.StatusCode.value__
-    $statusDescription = $_.Exception.Response.StatusDescription
-
-    # if this is 404ing, then this pkg has never been published before
-    if ($statusCode -eq 404) {
-      return $false
-    }
-
-    Write-Host "VersionCheck to maven for packageId $pkgId failed with statuscode $statusCode"
-    Write-Host $statusDescription
-    exit(1)
-  }
-}
-
-# make certain to always take the package json closest to the top
-function ResolvePkgJson($workFolder) {
-  $pathsWithComplexity = @()
-  foreach ($file in (Get-ChildItem -Path $workFolder -Recurse -Include "package.json")) {
-    $complexity = ($file.FullName -Split { $_ -eq "/" -or $_ -eq "\" }).Length
-    $pathsWithComplexity += New-Object PSObject -Property @{
-      Path       = $file
-      Complexity = $complexity
-    }
-  }
-
-  return ($pathsWithComplexity | Sort-Object -Property Complexity)[0].Path
-}
-
-# Parse out package publishing information given a .tgz npm artifact
-function ParseNPMPackage($pkg, $workingDirectory) {
-  $workFolder = "$workingDirectory$($pkg.Basename)"
-  $origFolder = Get-Location
-  mkdir $workFolder
-  cd $workFolder
-
-  tar -xzf $pkg
-
-  $packageJSON = ResolvePkgJson -workFolder $workFolder | Get-Content | ConvertFrom-Json
-  $releaseNotes = &"${PSScriptRoot}/../Extract-ReleaseNotes.ps1" -ChangeLogLocation @(Get-ChildItem -Path $workFolder -Recurse -Include "CHANGELOG.md")[0]
-
-  cd $origFolder
-  Remove-Item $workFolder -Force  -Recurse -ErrorAction SilentlyContinue
-
-  $pkgId = $packageJSON.name
-  $pkgVersion = $packageJSON.version
-
-  $resultObj = New-Object PSObject -Property @{
-    PackageId      = $pkgId
-    PackageVersion = $pkgVersion
-    Deployable     = $forceCreate -or !(IsNPMPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion)
-    ReleaseNotes   = $releaseNotes
-  }
-
-  return $resultObj
-}
-
-# Returns the npm publish status of a package id and version.
-function IsNPMPackageVersionPublished($pkgId, $pkgVersion) {
-  $npmVersions = (npm show $pkgId versions)
-
-  if ($LastExitCode -ne 0) {
-    npm ping
-
-    if ($LastExitCode -eq 0) {
-      return $False
-    }
-
-    Write-Host "Could not find a deployed version of $pkgId, and NPM connectivity check failed."
-    exit(1)
-  }
-
-  $npmVersionList = $npmVersions.split(",") | % { return $_.replace("[", "").replace("]", "").Trim() }
-  return $npmVersionList.Contains($pkgVersion)
-}
-
-# Parse out package publishing information given a nupkg ZIP format.
-function ParseNugetPackage($pkg, $workingDirectory) {
-  $workFolder = "$workingDirectory$($pkg.Basename)"
-  $origFolder = Get-Location
-  $zipFileLocation = "$workFolder/$($pkg.Basename).zip"
-  mkdir $workFolder
-
-  Copy-Item -Path $pkg -Destination $zipFileLocation
-  Expand-Archive -Path $zipFileLocation -DestinationPath $workFolder
-  [xml] $packageXML = Get-ChildItem -Path "$workFolder/*.nuspec" | Get-Content
-  $releaseNotes = &"${PSScriptRoot}/../Extract-ReleaseNotes.ps1" -ChangeLogLocation @(Get-ChildItem -Path $workFolder -Recurse -Include "CHANGELOG.md")[0]
-
-  Remove-Item $workFolder -Force  -Recurse -ErrorAction SilentlyContinue
-  $pkgId = $packageXML.package.metadata.id
-  $pkgVersion = $packageXML.package.metadata.version
-
-  return New-Object PSObject -Property @{
-    PackageId      = $pkgId
-    PackageVersion = $pkgVersion
-    Deployable     = $forceCreate -or !(IsNugetPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion)
-    ReleaseNotes   = $releaseNotes
-  }
-}
-
-# Returns the nuget publish status of a package id and version.
-function IsNugetPackageVersionPublished($pkgId, $pkgVersion) {
-
-  $nugetUri = "https://api.nuget.org/v3-flatcontainer/$($pkgId.ToLowerInvariant())/index.json"
-
-  try {
-    $nugetVersions = Invoke-RestMethod -Method "GET" -Uri $nugetUri
-
-    return $nugetVersions.versions.Contains($pkgVersion)
-  }
-  catch {
-    $statusCode = $_.Exception.Response.StatusCode.value__
-    $statusDescription = $_.Exception.Response.StatusDescription
-
-    # if this is 404ing, then this pkg has never been published before
-    if ($statusCode -eq 404) {
-      return $False
-    }
-
-    Write-Host "Nuget Invocation failed:"
-    Write-Host "StatusCode:" $statusCode
-    Write-Host "StatusDescription:" $statusDescription
-    exit(1)
-  }
-
-}
-
-# Parse out package publishing information given a python sdist of ZIP format.
-function ParsePyPIPackage($pkg, $workingDirectory) {
-  $pkg.Basename -match $SDIST_PACKAGE_REGEX | Out-Null
-
-  $pkgId = $matches["package"]
-  $pkgVersion = $matches["versionstring"]
-
-  $workFolder = "$workingDirectory$($pkg.Basename)"
-  $origFolder = Get-Location
-  mkdir $workFolder
-
-  Expand-Archive -Path $pkg -DestinationPath $workFolder
-  $releaseNotes = &"${PSScriptRoot}/../Extract-ReleaseNotes.ps1" -ChangeLogLocation @(Get-ChildItem -Path $workFolder -Recurse -Include "CHANGELOG.md")[0]
-  Remove-Item $workFolder -Force  -Recurse -ErrorAction SilentlyContinue
-
-  return New-Object PSObject -Property @{
-    PackageId      = $pkgId
-    PackageVersion = $pkgVersion
-    Deployable     = $forceCreate -or !(IsPythonPackageVersionPublished -pkgId $pkgId -pkgVersion $pkgVersion)
-    ReleaseNotes   = $releaseNotes
-  }
-}
-
-function ParseCArtifact($pkg, $workingDirectory) {
-  $packageInfo = Get-Content -Raw -Path $pkg | ConvertFrom-JSON
-  $packageArtifactLocation = (Get-ItemProperty $pkg).Directory.FullName
-
-  $releaseNotes = ExtractReleaseNotes -changeLogLocation @(Get-ChildItem -Path $packageArtifactLocation -Recurse -Include "CHANGELOG.md")[0]
-
-  return New-Object PSObject -Property @{
-    PackageId      = $packageInfo.name
-    PackageVersion = $packageInfo.version
-    # Artifact info is always considered deployable for C becasue it is not
-    # deployed anywhere. Dealing with duplicate tags happens downstream in
-    # CheckArtifactShaAgainstTagsList
-    Deployable     = $true
-    ReleaseNotes   = $releaseNotes
-  }
-}
-
-# Returns the pypi publish status of a package id and version.
-function IsPythonPackageVersionPublished($pkgId, $pkgVersion) {
-  try {
-    $existingVersion = (Invoke-RestMethod -Method "Get" -Uri "https://pypi.org/pypi/$pkgId/$pkgVersion/json").info.version
-
-    # if existingVersion exists, then it's already been published
-    return $True
-  }
-  catch {
-    $statusCode = $_.Exception.Response.StatusCode.value__
-    $statusDescription = $_.Exception.Response.StatusDescription
-
-    # if this is 404ing, then this pkg has never been published before
-    if ($statusCode -eq 404) {
-      return $False
-    }
-
-    Write-Host "PyPI Invocation failed:"
-    Write-Host "StatusCode:" $statusCode
-    Write-Host "StatusDescription:" $statusDescription
-    exit(1)
-  }
-}
-
-# Retrieves the list of all tags that exist on the target repository
-function GetExistingTags($apiUrl) {
-  try {
-    return (Invoke-RestMethod -Method "GET" -Uri "$apiUrl/git/refs/tags"  ) | % { $_.ref.Replace("refs/tags/", "") }
-  }
-  catch {
-    $statusCode = $_.Exception.Response.StatusCode.value__
-    $statusDescription = $_.Exception.Response.StatusDescription
-
-    Write-Host "Failed to retrieve tags from repository."
-    Write-Host "StatusCode:" $statusCode
-    Write-Host "StatusDescription:" $statusDescription
-
-    # Return an empty list if there are no tags in the repo
-    if ($statusCode -eq 404) {
-      return @()
-    }
-
-    exit(1)
-  }
-}
-
-# Walk across all build artifacts, check them against the appropriate repository, return a list of tags/releases
-function VerifyPackages($pkgRepository, $artifactLocation, $workingDirectory, $apiUrl, $releaseSha) {
-  $pkgList = [array]@()
-  $ParsePkgInfoFn = ""
-  $packagePattern = ""
-
-  switch ($pkgRepository) {
-    "Maven" {
-      $ParsePkgInfoFn = "ParseMavenPackage"
-      $packagePattern = "*.pom"
-      break
-    }
-    "Nuget" {
-      $ParsePkgInfoFn = "ParseNugetPackage"
-      $packagePattern = "*.nupkg"
-      break
-    }
-    "NPM" {
-      $ParsePkgInfoFn = "ParseNPMPackage"
-      $packagePattern = "*.tgz"
-      break
-    }
-    "PyPI" {
-      $ParsePkgInfoFn = "ParsePyPIPackage"
-      $packagePattern = "*.zip"
-      break
-    }
-    "C" {
-      $ParsePkgInfoFn = "ParseCArtifact"
-      $packagePattern = "*.json"
-    }
-    default {
-      Write-Host "Unrecognized Language: $language"
-      exit(1)
-    }
-  }
-
-  $pkgs = (Get-ChildItem -Path $artifactLocation -Include $packagePattern -Recurse -File)
-
-  Write-Host $pkgs
-
-  foreach ($pkg in $pkgs) {
-    try {
-      $parsedPackage = &$ParsePkgInfoFn -pkg $pkg -workingDirectory $workingDirectory
-
-      if ($parsedPackage -eq $null) {
-        continue
-      }
-
-      if ($parsedPackage.Deployable -ne $True) {
-        Write-Host "Package $($parsedPackage.PackageId) is marked with version $($parsedPackage.PackageVersion), the version $($parsedPackage.PackageVersion) has already been deployed to the target repository."
-        Write-Host "Maybe a pkg version wasn't updated properly?"
-        exit(1)
-      }
-
-      $pkgList += New-Object PSObject -Property @{
-        PackageId      = $parsedPackage.PackageId
-        PackageVersion = $parsedPackage.PackageVersion
-        Tag            = ($parsedPackage.PackageId + "_" + $parsedPackage.PackageVersion)
-        ReleaseNotes   = $parsedPackage.ReleaseNotes
-      }
-    }
-    catch {
-      Write-Host $_.Exception.Message
-      exit(1)
-    }
-  }
-
-  $results = ([array]$pkgList | Sort-Object -Property Tag -uniq)
-
-  $existingTags = GetExistingTags($apiUrl)
-  $intersect = $results | % { $_.Tag } | ? { $existingTags -contains $_ }
-
-  if ($intersect.Length -gt 0) {
-    CheckArtifactShaAgainstTagsList -priorExistingTagList $intersect -releaseSha $releaseSha -apiUrl $apiUrl
-
-    # all the tags are clean. remove them from the list of releases we will publish.
-    $results = $results | ? { -not ($intersect -contains $_.Tag ) }
-  }
-
-  return $results
-}
-
-# given a set of tags that we want to release, we need to ensure that if they already DO exist.
-# if they DO exist, quietly exit if the commit sha of the artifact matches that of the tag
-# if the commit sha does not match, exit with error and report both problem shas
-function CheckArtifactShaAgainstTagsList($priorExistingTagList, $releaseSha, $apiUrl) {
-  $headers = @{
-    "Content-Type"  = "application/json"
-    "Authorization" = "token $($env:GH_TOKEN)"
-  }
-
-  $unmatchedTags = @()
-
-  foreach ($tag in $priorExistingTagList) {
-    $tagSha = (FireAPIRequest -Method "Get" -Url "$apiUrl/git/refs/tags/$tag" -Headers $headers)."object".sha
-
-    if ($tagSha -eq $releaseSha) {
-      Write-Host "This package has already been released. The existing tag commit SHA $releaseSha matches the artifact SHA being processed. Skipping release step for this tag."
-    }
-    else {
-      Write-Host "The artifact SHA $releaseSha does not match that of the currently existing tag."
-      Write-Host "Tag with issues is $tag with commit SHA $tagSha"
-
-      $unmatchedTags += $tag
-    }
-  }
-
-  if ($unmatchedTags.Length -gt 0) {
-    Write-Host "Tags already existing with different SHA versions. Exiting."
-    exit(1)
-  }
-}
+. (Join-Path $PSScriptRoot artifact-metadata-parsing.ps1)
 
 $apiUrl = "https://api.github.com/repos/$repoId"
 Write-Host "Using API URL $apiUrl"
 
 # VERIFY PACKAGES
-$pkgList = VerifyPackages -pkgRepository $packageRepository -artifactLocation $artifactLocation -workingDirectory $workingDirectory -apiUrl $apiUrl -releaseSha $releaseSha
+$pkgList = VerifyPackages -pkgRepository $packageRepository -artifactLocation $artifactLocation -workingDirectory $workingDirectory -apiUrl $apiUrl -releaseSha $releaseSha -continueOnError $continueOnError
 
 if ($pkgList) {
   Write-Host "Given the visible artifacts, github releases will be created for the following:"
diff --git a/eng/common/scripts/git-branch-push.ps1 b/eng/common/scripts/git-branch-push.ps1
index 813688b7ddd6..9b3d78345589 100644
--- a/eng/common/scripts/git-branch-push.ps1
+++ b/eng/common/scripts/git-branch-push.ps1
@@ -73,8 +73,8 @@ if ($LASTEXITCODE -ne 0)
 $numberOfRetries = 10
 $needsRetry = $false
 $tryNumber = 0
-do 
-{ 
+do
+{
     $needsRetry = $false
     Write-Host "git push azure-sdk-fork $PRBranchName $PushArgs"
     git push azure-sdk-fork $PRBranchName $PushArgs
@@ -91,7 +91,7 @@ do
             exit $LASTEXITCODE
         }
 
-        try 
+        try
         {
             $TempPatchFile = New-TemporaryFile
             Write-Host "git diff ${PRBranchName}~ ${PRBranchName} --output $TempPatchFile"
@@ -116,11 +116,19 @@ do
             if ($LASTEXITCODE -ne 0)
             {
                 Write-Error "Unable to apply diff file LASTEXITCODE=$($LASTEXITCODE), see command output above."
+                exit $LASTEXITCODE
+            }
+
+            Write-Host "git add -A"
+            git add -A
+            if ($LASTEXITCODE -ne 0)
+            {
+                Write-Error "Unable to git add LASTEXITCODE=$($LASTEXITCODE), see command output above."
                 continue
             }
 
-            Write-Host "git -c user.name=`"azure-sdk`" -c user.email=`"azuresdk@microsoft.com`" commit -am `"$($CommitMsg)`""
-            git -c user.name="azure-sdk" -c user.email="azuresdk@microsoft.com" commit -am "$($CommitMsg)"
+            Write-Host "git -c user.name=`"azure-sdk`" -c user.email=`"azuresdk@microsoft.com`" commit -m `"$($CommitMsg)`""
+            git -c user.name="azure-sdk" -c user.email="azuresdk@microsoft.com" commit -m "$($CommitMsg)"
             if ($LASTEXITCODE -ne 0)
             {
                 Write-Error "Unable to commit LASTEXITCODE=$($LASTEXITCODE), see command output above."
@@ -135,8 +143,7 @@ do
             }
         }
     }
-
-} while($needsRetry -and $tryNumber -le $numberOfRetries) 
+} while($needsRetry -and $tryNumber -le $numberOfRetries)
 
 if ($LASTEXITCODE -ne 0)
 {
diff --git a/eng/common/scripts/modules/ChangeLog-Operations.psm1 b/eng/common/scripts/modules/ChangeLog-Operations.psm1
new file mode 100644
index 000000000000..5279668412b7
--- /dev/null
+++ b/eng/common/scripts/modules/ChangeLog-Operations.psm1
@@ -0,0 +1,116 @@
+# Common Changelog Operations
+
+$RELEASE_TITLE_REGEX = "(?^\#+.*(?\b\d+\.\d+\.\d+([^0-9\s][^\s:]+)?)(\s(?\(Unreleased\)|\(\d{4}-\d{2}-\d{2}\)))?)"
+
+# Returns a Collection of changeLogEntry object containing changelog info for all version present in the gived CHANGELOG
+function Get-ChangeLogEntries {
+  param (
+    [Parameter(Mandatory = $true)]
+    [String]$ChangeLogLocation
+  )
+
+  $changeLogEntries = @{}
+  if (!(Test-Path $ChangeLogLocation)) {
+    Write-Host "ChangeLog '{0}' was not found" -f $ChangeLogLocation
+    exit 1
+  }
+
+  try {
+    $contents = Get-Content $ChangeLogLocation
+    # walk the document, finding where the version specifiers are and creating lists
+    $changeLogEntry = $null
+    foreach ($line in $contents) {
+      if ($line -match $RELEASE_TITLE_REGEX) {
+        $changeLogEntry = [pscustomobject]@{ 
+          ReleaseVersion = $matches["version"]
+          ReleaseStatus  = $matches["releaseStatus"]
+          ReleaseTitle   = $line
+          ReleaseContent = @() # Release content without the version title
+        }
+        $changeLogEntries[$changeLogEntry.ReleaseVersion] = $changeLogEntry
+      }
+      else {
+        if ($changeLogEntry) {
+          $changeLogEntry.ReleaseContent += $line
+        }
+      }
+    }
+  }
+  catch {
+    Write-Host "Error parsing $ChangeLogLocation."
+    Write-Host $_.Exception.Message
+  }
+  return $changeLogEntries
+}
+
+# Returns single changeLogEntry object containing the ChangeLog for a particular version
+function Get-ChangeLogEntry {
+  param (
+    [Parameter(Mandatory = $true)]
+    [String]$ChangeLogLocation,
+    [Parameter(Mandatory = $true)]
+    [String]$VersionString
+  )
+
+  $changeLogEntries = Get-ChangeLogEntries -ChangeLogLocation $ChangeLogLocation
+
+  if ($changeLogEntries.ContainsKey($VersionString)) {
+    return $changeLogEntries[$VersionString]
+  }
+  Write-Error "Release Notes for the Specified version ${VersionString} was not found"
+  exit 1
+}
+
+#Returns the changelog for a particular version as string
+function Get-ChangeLogEntryAsString {
+  param (
+    [Parameter(Mandatory = $true)]
+    [String]$ChangeLogLocation,
+    [Parameter(Mandatory = $true)]
+    [String]$VersionString
+  )
+
+  $changeLogEntries = Get-ChangeLogEntry -ChangeLogLocation $ChangeLogLocation -VersionString $VersionString
+  [string]$releaseTitle = $changeLogEntries.ReleaseTitle
+  [string]$releaseContent = $changeLogEntries.ReleaseContent -Join [Environment]::NewLine
+  return $releaseTitle, $releaseContent -Join [Environment]::NewLine
+}
+
+function Confirm-ChangeLogEntry {
+  param (
+    [Parameter(Mandatory = $true)]
+    [String]$ChangeLogLocation,
+    [Parameter(Mandatory = $true)]
+    [String]$VersionString,
+    [boolean]$ForRelease = $false
+  )
+
+  $changeLogEntries = Get-ChangeLogEntry -ChangeLogLocation $ChangeLogLocation -VersionString $VersionString
+
+  if ([System.String]::IsNullOrEmpty($changeLogEntries.ReleaseStatus)) {
+    Write-Host ("##[error]Changelog '{0}' has wrong release note title" -f $ChangeLogLocation)
+    Write-Host "##[info]Ensure the release date is included i.e. (yyyy-MM-dd) or (Unreleased) if not yet released"
+    exit 1
+  }
+
+  if ($ForRelease -eq $True) {
+    $CurrentDate = Get-Date -Format "yyyy-MM-dd"
+    if ($changeLogEntries.ReleaseStatus -ne "($CurrentDate)") {
+      Write-Host ("##[warning]Incorrect Date: Please use the current date in the Changelog '{0}' before releasing the package" -f $ChangeLogLocation)
+      exit 1
+    }
+
+    if ([System.String]::IsNullOrWhiteSpace($changeLogEntries.ReleaseContent)) {
+      Write-Host ("##[error]Empty Release Notes for '{0}' in '{1}'" -f $VersionString, $ChangeLogLocation)
+      Write-Host "##[info]Please ensure there is a release notes entry before releasing the package."
+      exit 1
+    }
+  }
+
+  Write-Host ($changeLogEntries | Format-Table | Out-String)
+}
+ 
+Export-ModuleMember -Function 'Get-ChangeLogEntries'
+Export-ModuleMember -Function 'Get-ChangeLogEntry'
+Export-ModuleMember -Function 'Get-ChangeLogEntryAsString'
+Export-ModuleMember -Function 'Confirm-ChangeLogEntry'
\ No newline at end of file
diff --git a/eng/common/scripts/modules/Package-Properties.psm1 b/eng/common/scripts/modules/Package-Properties.psm1
new file mode 100644
index 000000000000..294f6609dab8
--- /dev/null
+++ b/eng/common/scripts/modules/Package-Properties.psm1
@@ -0,0 +1,252 @@
+# Helper functions for retireving useful information from azure-sdk-for-* repo
+# Example Use : Import-Module .\eng\common\scripts\modules
+class PackageProps
+{
+    [string]$pkgName
+    [AzureEngSemanticVersion]$pkgVersion
+    [string]$pkgDirectoryPath
+    [string]$pkgServiceName
+    [string]$pkgReadMePath
+    [string]$pkgChangeLogPath
+
+    PackageProps(
+        [string]$pkgName,
+        [string]$pkgVersion,
+        [string]$pkgDirectoryPath,
+        [string]$pkgServiceName
+    )
+    {
+        $this.pkgName = $pkgName
+        $this.pkgVersion = [AzureEngSemanticVersion]::ParseVersionString($pkgVersion)
+        if ($this.pkgVersion -eq $null)
+        {
+            Write-Error "Invalid version in $pkgDirectoryPath"
+        }
+        $this.pkgDirectoryPath = $pkgDirectoryPath
+        $this.pkgServiceName = $pkgServiceName
+
+        if (Test-Path (Join-Path $pkgDirectoryPath "README.md"))
+        {
+            $this.pkgReadMePath = Join-Path $pkgDirectoryPath "README.md"
+        } 
+        else
+        {
+            $this.pkgReadMePath = $null
+        }
+
+        if (Test-Path (Join-Path $pkgDirectoryPath "CHANGELOG.md"))
+        {
+            $this.pkgChangeLogPath = Join-Path $pkgDirectoryPath "CHANGELOG.md"
+        } 
+        else
+        {
+            $this.pkgChangeLogPath = $null
+        }
+    }
+}
+
+Install-Module -Name powershell-yaml -RequiredVersion 0.4.1 -Force -Scope CurrentUser
+
+function Extract-PkgProps ($pkgPath, $serviceName, $pkgName, $lang)
+{
+    if ($lang -eq "net")
+    {
+        return Extract-DotNetPkgProps -pkgPath $pkgPath -serviceName $serviceName -pkgName $pkgName 
+    }
+    if ($lang -eq "java")
+    {
+        return Extract-JavaPkgProps -pkgPath $pkgPath -serviceName $serviceName -pkgName $pkgName 
+    }
+    if ($lang -eq "js")
+    {
+        return Extract-JsPkgProps -pkgPath $pkgPath -serviceName $serviceName -pkgName $pkgName 
+    }
+    if ($lang -eq "python")
+    {
+        return Extract-PythonPkgProps -pkgPath $pkgPath -serviceName $serviceName -pkgName $pkgName 
+    }
+}
+
+function Extract-DotNetPkgProps ($pkgPath, $serviceName, $pkgName)
+{
+    $projectPath = Join-Path $pkgPath "src" "$pkgName.csproj"
+    if (Test-Path $projectPath)
+    {
+        $projectData = New-Object -TypeName XML
+        $projectData.load($projectPath)
+        $pkgVersion = Select-XML -Xml $projectData -XPath '/Project/PropertyGroup/Version'
+        return [PackageProps]::new($pkgName, $pkgVersion, $pkgPath, $serviceName)
+    } 
+    else 
+    {
+        return $null
+    }
+}
+
+function Extract-JsPkgProps ($pkgPath, $serviceName, $pkgName)
+{
+    $projectPath = Join-Path $pkgPath "package.json"
+    if (Test-Path $projectPath)
+    {
+        $projectJson = Get-Content $projectPath | ConvertFrom-Json
+        $jsStylePkgName = $pkgName.replace("azure-", "@azure/")
+        if ($projectJson.name -eq "$jsStylePkgName")
+        {
+            return [PackageProps]::new($projectJson.name, $projectJson.version, $pkgPath, $serviceName)
+        }
+    }
+    return $null
+}
+
+function Extract-PythonPkgProps ($pkgPath, $serviceName, $pkgName)
+{
+    $pkgName = $pkgName.Replace('_', '-')
+
+    if (Test-Path (Join-Path $pkgPath "setup.py"))
+    {
+        $setupLocation = $pkgPath.Replace('\','/')
+        pushd $RepoRoot
+        $setupProps = (python -c "import scripts.devops_tasks.common_tasks; obj=scripts.devops_tasks.common_tasks.parse_setup('$setupLocation'); print('{0},{1}'.format(obj[0], obj[1]));") -split ","
+        popd
+        if (($setupProps -ne $null) -and ($setupProps[0] -eq $pkgName))
+        {
+            return [PackageProps]::new($setupProps[0], $setupProps[1], $pkgPath, $serviceName)
+        }
+    }
+    return $null
+}
+
+function Extract-JavaPkgProps ($pkgPath, $serviceName, $pkgName)
+{
+    $projectPath = Join-Path $pkgPath "pom.xml"
+
+    if (Test-Path $projectPath)
+    {
+        $projectData = New-Object -TypeName XML
+        $projectData.load($projectPath)
+        $projectPkgName = $projectData.project.artifactId
+        $pkgVersion = $projectData.project.version
+
+        if ($projectPkgName -eq $pkgName)
+        {
+            return [PackageProps]::new($pkgName, $pkgVersion.ToString(), $pkgPath, $serviceName)
+        }
+    }
+    return $null
+}
+
+# Takes package name and service Name
+# Returns important properties of the package as related to the language repo
+# Returns a PS Object with properties @ { pkgName, pkgVersion, pkgDirectoryPath, pkgReadMePath, pkgChangeLogPath }
+# Note: python is required for parsing python package properties.
+function Get-PkgProperties
+{
+    Param
+    (
+        [Parameter(Mandatory=$true)]
+        [string]$PackageName,
+        [Parameter(Mandatory=$true)]
+        [string]$ServiceName,
+        [Parameter(Mandatory=$true)]
+        [ValidateSet("net","java","js","python")]
+        [string]$Language,
+        [string]$RepoRoot="${PSScriptRoot}/../../../.."
+    )
+
+    $pkgDirectoryName = $null
+    $pkgDirectoryPath = $null
+    $serviceDirectoryPath = Join-Path $RepoRoot "sdk" $ServiceName
+    if (!(Test-Path $serviceDirectoryPath))
+    {
+        Write-Error "Service Directory $ServiceName does not exist"
+        exit 1
+    }
+
+    $directoriesPresent = Get-ChildItem $serviceDirectoryPath -Directory
+
+    foreach ($directory in $directoriesPresent)
+    {
+        $pkgDirectoryPath = Join-Path $serviceDirectoryPath $directory.Name
+        $pkgProps = Extract-PkgProps -pkgPath $pkgDirectoryPath -serviceName $ServiceName -pkgName $PackageName -lang $Language
+        if ($pkgProps -ne $null)
+        {
+            return $pkgProps
+        }
+    }
+    Write-Error "Failed to retrive Properties for $PackageName"
+}
+
+# Takes ServiceName, Language, and Repo Root Directory
+# Returns important properties for each package in the specified service, or entire repo if the serviceName is not specified
+# Returns an Table of service key to array values of PS Object with properties @ { pkgName, pkgVersion, pkgDirectoryPath, pkgReadMePath, pkgChangeLogPath }
+function Get-AllPkgProperties
+{
+    Param
+    (
+        [Parameter(Mandatory=$true)]
+        [ValidateSet("net","java","js","python")]
+        [string]$Language,
+        [string]$RepoRoot="${PSScriptRoot}/../../../..",
+        [string]$ServiceName=$null
+    )
+
+    $pkgPropsResult = @()
+
+    if ([string]::IsNullOrEmpty($ServiceName))
+    {
+        $searchDir = Join-Path $RepoRoot "sdk"
+        foreach ($dir in (Get-ChildItem $searchDir -Directory))
+        {
+            $serviceDir = Join-Path $searchDir $dir.Name
+
+            if (Test-Path (Join-Path $serviceDir "ci.yml"))
+            {
+                $activePkgList = Get-PkgListFromYml -ciYmlPath (Join-Path $serviceDir "ci.yml")
+                if ($activePkgList -ne $null)
+                {
+                    $pkgPropsResult = Operate-OnPackages -activePkgList $activePkgList -serviceName $dir.Name -language $Language -repoRoot $RepoRoot -pkgPropsResult $pkgPropsResult
+                }
+            }
+        }
+    } 
+    else
+    {
+        $serviceDir = Join-Path $RepoRoot "sdk" $ServiceName
+        if (Test-Path (Join-Path $serviceDir "ci.yml"))
+        {
+            $activePkgList = Get-PkgListFromYml -ciYmlPath (Join-Path $serviceDir "ci.yml")
+            if ($activePkgList -ne $null)
+            {
+                $pkgPropsResult = Operate-OnPackages -activePkgList $activePkgList -serviceName $ServiceName -language $Language -repoRoot $RepoRoot -pkgPropsResult $pkgPropsResult
+            }
+        }
+    }
+
+    return $pkgPropsResult
+}
+
+function Operate-OnPackages ($activePkgList, $serviceName, $language, $repoRoot, [Array]$pkgPropsResult)
+{
+    foreach ($pkg in $activePkgList)
+    {
+        $pkgProps = Get-PkgProperties -PackageName $pkg["name"] -ServiceName $serviceName -Language $language -RepoRoot $repoRoot
+        $pkgPropsResult += $pkgProps
+    }
+    return $pkgPropsResult
+}
+
+function Get-PkgListFromYml ($ciYmlPath)
+{
+    $ciYmlContent = Get-Content $ciYmlPath -Raw
+    $ciYmlObj = ConvertFrom-Yaml $ciYmlContent -Ordered
+    $artifactsInCI = $ciYmlObj["stages"][0]["parameters"]["Artifacts"]
+
+    if ($artifactsInCI -eq $null)
+    {
+        Write-Error "Failed to retrive package names in ci $ciYmlPath"
+    }
+    return $artifactsInCI
+}
+
+Export-ModuleMember -Function 'Get-PkgProperties'
+Export-ModuleMember -Function 'Get-AllPkgProperties'
\ No newline at end of file
diff --git a/eng/common/scripts/modules/common-manifest.psd1 b/eng/common/scripts/modules/common-manifest.psd1
new file mode 100644
index 000000000000..43dee1c26e23
--- /dev/null
+++ b/eng/common/scripts/modules/common-manifest.psd1
@@ -0,0 +1,123 @@
+#
+# Module manifest for module 'Common Modules'
+#
+# Generated by: azure-sdk
+#
+# Generated on: 5/19/2020
+#
+
+@{
+
+# Script module or binary module file associated with this manifest.
+# RootModule = ''
+
+# Version number of this module.
+ModuleVersion = '1.0'
+
+# Supported PSEditions
+# CompatiblePSEditions = @()
+
+# ID used to uniquely identify this module
+GUID = '552cdcff-1f53-4f68-87a6-54490af6e94a'
+
+# Author of this module
+Author = 'azure-sdk'
+
+# Company or vendor of this module
+CompanyName = 'Unknown'
+
+# Copyright statement for this module
+Copyright = '(c) azure-sdk. All rights reserved.'
+
+# Description of the functionality provided by this module
+# Description = ''
+
+# Minimum version of the PowerShell engine required by this module
+# PowerShellVersion = ''
+
+# Name of the PowerShell host required by this module
+# PowerShellHostName = ''
+
+# Minimum version of the PowerShell host required by this module
+# PowerShellHostVersion = ''
+
+# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
+# DotNetFrameworkVersion = ''
+
+# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
+# CLRVersion = ''
+
+# Processor architecture (None, X86, Amd64) required by this module
+# ProcessorArchitecture = ''
+
+# Modules that must be imported into the global environment prior to importing this module
+# RequiredModules = @()
+
+# Assemblies that must be loaded prior to importing this module
+RequiredAssemblies = @()
+
+# Script files (.ps1) that are run in the caller's environment prior to importing this module.
+ScriptsToProcess = @("${PSScriptRoot}\..\SemVer.ps1")
+
+# Type files (.ps1xml) to be loaded when importing this module
+# TypesToProcess = @()
+
+# Format files (.ps1xml) to be loaded when importing this module
+# FormatsToProcess = @()
+
+# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
+NestedModules = @("${PSScriptRoot}\Package-Properties.psm1", "${PSScriptRoot}\ChangeLog-Operations.psm1")
+
+# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
+# FunctionsToExport = @()
+
+# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
+CmdletsToExport = @()
+
+# Variables to export from this module
+VariablesToExport = '*'
+
+# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
+AliasesToExport = @()
+
+# DSC resources to export from this module
+# DscResourcesToExport = @()
+
+# List of all modules packaged with this module
+# ModuleList = @()
+
+# List of all files packaged with this module
+# FileList = @()
+
+# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
+PrivateData = @{
+
+    PSData = @{
+
+        # Tags applied to this module. These help with module discovery in online galleries.
+        # Tags = @()
+
+        # A URL to the license for this module.
+        # LicenseUri = ''
+
+        # A URL to the main website for this project.
+        # ProjectUri = ''
+
+        # A URL to an icon representing this module.
+        # IconUri = ''
+
+        # ReleaseNotes of this module
+        # ReleaseNotes = ''
+
+    } # End of PSData hashtable
+
+} # End of PrivateData hashtable
+
+# HelpInfo URI of this module
+# HelpInfoURI = ''
+
+# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
+# DefaultCommandPrefix = ''
+
+}
+
diff --git a/eng/common/scripts/update-docs-metadata.ps1 b/eng/common/scripts/update-docs-metadata.ps1
new file mode 100644
index 000000000000..a858078f5448
--- /dev/null
+++ b/eng/common/scripts/update-docs-metadata.ps1
@@ -0,0 +1,137 @@
+# Note, due to how `Expand-Archive` is leveraged in this script,
+# powershell core is a requirement for successful execution.
+param (
+  # arguments leveraged to parse and identify artifacts
+  $ArtifactLocation, # the root of the artifact folder. DevOps $(System.ArtifactsDirectory)
+  $WorkDirectory, # a clean folder that we can work in
+  $ReleaseSHA, # the SHA for the artifacts. DevOps: $(Release.Artifacts..SourceVersion) or $(Build.SourceVersion)
+  $RepoId, # full repo id. EG azure/azure-sdk-for-net  DevOps: $(Build.Repository.Id). Used as a part of VerifyPackages
+  $Repository, # EG: "Maven", "PyPI", "NPM"
+
+  # arguments necessary to power the docs release
+  $DocRepoLocation, # the location on disk where we have cloned the documentation repository
+  $Language, # EG: js, java, dotnet. Used in language for the embedded readme.
+  $DocRepoContentLocation = "docs-ref-services/" # within the doc repo, where does our readme go?
+)
+
+
+# import artifact parsing and semver handling
+. (Join-Path $PSScriptRoot artifact-metadata-parsing.ps1)
+. (Join-Path $PSScriptRoot SemVer.ps1)
+
+function GetMetaData($lang){
+  switch ($lang) {
+    "java" {
+      $metadataUri = "https://raw.githubusercontent.com/Azure/azure-sdk/master/_data/releases/latest/java-packages.csv"
+      break
+    }
+    ".net" {
+      $metadataUri = "https://raw.githubusercontent.com/Azure/azure-sdk/master/_data/releases/latest/dotnet-packages.csv"
+      break
+    }
+    "python" {
+      $metadataUri = "https://raw.githubusercontent.com/Azure/azure-sdk/master/_data/releases/latest/python-packages.csv"
+      break
+    }
+    "javascript" {
+      $metadataUri = "https://raw.githubusercontent.com/Azure/azure-sdk/master/_data/releases/latest/js-packages.csv"
+      break
+    }
+    default {
+      Write-Host "Unrecognized Language: $language"
+      exit(1)
+    }
+  }
+
+  $metadataResponse = Invoke-WebRequest-WithHandling -url $metadataUri -method "GET" | ConvertFrom-Csv
+
+  return $metadataResponse
+}
+
+function GetAdjustedReadmeContent($pkgInfo, $lang){
+    $date = Get-Date -Format "MM/dd/yyyy"
+    $service = ""
+
+    # the namespace is not expected to be present for js.
+    $pkgId = $pkgInfo.PackageId.Replace("@azure/", "")
+
+    try {
+      $metadata = GetMetaData -lang $lang
+
+      $service = $metadata | ? { $_.Package -eq $pkgId }
+
+      if ($service) {
+        $service = "$($service.ServiceName)".ToLower().Replace(" ", "")
+      }
+    }
+    catch {
+      Write-Host $_
+      Write-Host "Unable to retrieve service metadata for packageId $($pkgInfo.PackageId)"
+    }
+
+    $fileContent = $pkgInfo.ReadmeContent
+    $foundTitle = ""
+
+    # only replace the version if the formatted header can be found
+    $headerContentMatches = (Select-String -InputObject $pkgInfo.ReadmeContent -Pattern 'Azure .+? (client|plugin|shared) library for (JavaScript|Java|Python|\.NET|C)')
+    if ($headerContentMatches) {
+      $foundTitle = $headerContentMatches.Matches[0]
+      $fileContent = $pkgInfo.ReadmeContent -replace $foundTitle, "$foundTitle - Version $($pkgInfo.PackageVersion) `n"
+    }
+
+    $header = "---`ntitle: $foundTitle`nkeywords: Azure, $lang, SDK, API, $($pkgInfo.PackageId), $service`nauthor: maggiepint`nms.author: magpint`nms.date: $date`nms.topic: article`nms.prod: azure`nms.technology: azure`nms.devlang: $lang`nms.service: $service`n---`n"
+
+    if ($fileContent) {
+      return "$header`n$fileContent"
+    }
+    else {
+      return ""
+    }
+}
+
+$apiUrl = "https://api.github.com/repos/$repoId"
+$pkgs = VerifyPackages -pkgRepository $Repository `
+  -artifactLocation $ArtifactLocation `
+  -workingDirectory $WorkDirectory `
+  -apiUrl $apiUrl `
+  -releaseSha $ReleaseSHA `
+  -continueOnError $True
+
+if ($pkgs) {
+  Write-Host "Given the visible artifacts, readmes will be copied for the following packages"
+  Write-Host ($pkgs | % { $_.PackageId })
+
+  foreach ($packageInfo in $pkgs) {
+    # sync the doc repo
+    $semVer = [AzureEngSemanticVersion]::ParseVersionString($packageInfo.PackageVersion)
+    $rdSuffix = ""
+    if ($semVer.IsPreRelease) {
+      $rdSuffix = "-pre"
+    }
+
+    $readmeName = "$($packageInfo.PackageId.Replace('azure-','').Replace('Azure.', '').Replace('@azure/', '').ToLower())-readme$rdSuffix.md"
+    $readmeLocation = Join-Path $DocRepoLocation $DocRepoContentLocation $readmeName
+
+    if ($packageInfo.ReadmeContent) {
+      $adjustedContent = GetAdjustedReadmeContent -pkgInfo $packageInfo -lang $Language
+    }
+
+    if ($adjustedContent) {
+      try {
+        Push-Location $DocRepoLocation
+        Set-Content -Path $readmeLocation -Value $adjustedContent -Force
+
+        Write-Host "Updated readme for $readmeName."
+      } catch {
+        Write-Host $_
+      } finally {
+        Pop-Location
+      }
+    } else {
+      Write-Host "Unable to parse a header out of the readmecontent for PackageId $($packageInfo.PackageId)"
+    }
+  }
+}
+else {
+  Write-Host "No readmes discovered for doc release under folder $ArtifactLocation."
+}