diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index c4b2d15..34a2281 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -91,22 +91,22 @@ jobs: contents: write runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.build.outputs.source-commit }} - persist-credentials: false - uses: actions/download-artifact@v8 with: artifact-ids: ${{ needs.build.outputs.artifact-id }} merge-multiple: true path: .processing/platform-release + - name: Restore packaged release tools + run: Expand-Archive .processing/platform-release/OpenGuidePlatform-PlatformBuild.zip .processing/platform-release/tools - name: Publish versioned release env: GH_TOKEN: ${{ github.token }} PLATFORM_VERSION: ${{ needs.build.outputs.version }} - run: ./build.ps1 -Stage Release -OutputPath .processing/platform-release + PLATFORM_COMMIT: ${{ needs.build.outputs.source-commit }} + run: ./.processing/platform-release/tools/release.ps1 -Stage Release -AssetsPath .processing/platform-release -SourceCommit $env:PLATFORM_COMMIT - name: Verify published release env: GH_TOKEN: ${{ github.token }} PLATFORM_VERSION: ${{ needs.build.outputs.version }} - run: ./build.ps1 -Stage Validate -ReleaseTag "v$env:PLATFORM_VERSION" -OutputPath .processing/published-platform + PLATFORM_COMMIT: ${{ needs.build.outputs.source-commit }} + run: ./.processing/platform-release/tools/release.ps1 -Stage Validate -AssetsPath .processing/platform-release -SourceCommit $env:PLATFORM_COMMIT -ReleaseTag "v$env:PLATFORM_VERSION" diff --git a/system/OpenGuidePlatform.PowerShell.GuideSiteAdoption/Resolve-OpenGuidePlatform.ps1 b/system/OpenGuidePlatform.PowerShell.GuideSiteAdoption/Resolve-OpenGuidePlatform.ps1 index 2c486ef..310333e 100644 --- a/system/OpenGuidePlatform.PowerShell.GuideSiteAdoption/Resolve-OpenGuidePlatform.ps1 +++ b/system/OpenGuidePlatform.PowerShell.GuideSiteAdoption/Resolve-OpenGuidePlatform.ps1 @@ -326,7 +326,7 @@ if($Product -eq 'Platform' -and (Test-Path "$selected/release-manifest.json") -a $zip=[IO.Compression.ZipFile]::OpenRead($archivePath) $names=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) try{foreach($entry in $zip.Entries){ - if(-not $names.Add($entry.FullName) -or $entry.FullName -match '(^/|\\|:|(^|/)\.\.?(/|$))' -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or ($entry.FullName -cne 'platform-build.json' -and -not $entry.FullName.StartsWith('system/OpenGuidePlatform.PowerShell.PlatformBuild/'))){throw 'PlatformBuild contains an unsafe or overlapping entry.'} + if(-not $names.Add($entry.FullName) -or $entry.FullName -match '(^/|\\|:|(^|/)\.\.?(/|$))' -or (($entry.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000 -or ($entry.FullName -cne 'platform-build.json' -and $entry.FullName -cne 'release.ps1' -and -not $entry.FullName.StartsWith('system/OpenGuidePlatform.PowerShell.PlatformBuild/'))){throw 'PlatformBuild contains an unsafe or overlapping entry.'} }}finally{$zip.Dispose()} [IO.Compression.ZipFile]::ExtractToDirectory($archivePath,$selected) $identity=Get-Content "$selected/platform-build.json" -Raw|ConvertFrom-Json diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Packaging/Package-OpenGuidePlatform.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Packaging/Package-OpenGuidePlatform.ps1 index a33490d..463b536 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Packaging/Package-OpenGuidePlatform.ps1 +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Packaging/Package-OpenGuidePlatform.ps1 @@ -43,6 +43,7 @@ $guideHash=(Get-FileHash "$output/$guideArchive").Hash.ToLowerInvariant() $platformStage=Join-Path $output 'platform-build' [IO.Directory]::CreateDirectory("$platformStage/system")|Out-Null Copy-Item "$root/system/OpenGuidePlatform.PowerShell.PlatformBuild" "$platformStage/system/" -Recurse +Copy-Item "$root/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1" "$platformStage/release.ps1" $dependency=[ordered]@{version=$Version;sha256=$guideHash} $platformMetadata=[ordered]@{schemaVersion=1;product='OpenGuidePlatform';package='PlatformBuild';version=$Version;sourceCommit=$commit;dependencies=@{GuideSite=$dependency}} [IO.File]::WriteAllText("$platformStage/platform-build.json",($platformMetadata|ConvertTo-Json -Depth 10)) diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 index 21e7d64..fbc6d8a 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1 @@ -1,6 +1,6 @@ #Requires -Version 7.4 [CmdletBinding()] -param([Parameter(Mandatory)][string]$WorkspaceRoot,[Parameter(Mandatory)][string]$OutputPath,[string]$Repository='nkdAgility/OpenGuidePlatform') +param([Parameter(Mandatory)][string]$WorkspaceRoot,[Parameter(Mandatory)][string]$OutputPath,[string]$SourceCommit,[string]$Repository='nkdAgility/OpenGuidePlatform') $ErrorActionPreference='Stop' . "$PSScriptRoot/Publish-PlatformWorkflowAliases.ps1" $WorkspaceRoot=[IO.Path]::GetFullPath($WorkspaceRoot) @@ -15,8 +15,8 @@ foreach($name in @('GuideSite','PlatformBuild')){ $part=$manifest.packages.$name if($part.archive -cne "OpenGuidePlatform-$name.zip" -or $part.version -cne $manifest.version -or (Get-FileHash "$OutputPath/$($part.archive)").Hash -ine $part.sha256){throw "Release $name package identity or digest mismatch. Rebuild and validate the complete release."} } -$commit=(& git -C $WorkspaceRoot rev-parse HEAD).Trim() -if($LASTEXITCODE -ne 0 -or $commit -cne $manifest.sourceCommit){throw 'Release checkout does not match the package.'} +$commit=if($SourceCommit){$SourceCommit}else{(& git -C $WorkspaceRoot rev-parse HEAD).Trim()} +if($commit -cnotmatch '^[a-f0-9]{40}$' -or $commit -cne $manifest.sourceCommit){throw 'Release source commit does not match the package.'} $tag="v$($manifest.version)" $module=$manifest.nativeHugoModule if($module.path -cne 'github.com/nkdAgility/OpenGuidePlatform/system/OpenGuidePlatform.Hugo.Guides' -or $module.version -cne $tag -or $module.tag -cne "system/OpenGuidePlatform.Hugo.Guides/$tag" -or $module.sourceCommit -cne $commit){throw 'Native Hugo publication identity differs from the tested release.'} @@ -86,7 +86,7 @@ The corresponding native Hugo module tag is $($module.tag). The platform tests a "@ [IO.File]::WriteAllText("$OutputPath/release-notes.md",$notes) $releaseFlags=if($prerelease){@('--prerelease','--latest=false')}else{@()} -& gh release create $tag "$OutputPath/OpenGuidePlatform-GuideSite.zip" "$OutputPath/OpenGuidePlatform-PlatformBuild.zip" "$OutputPath/release-manifest.json" --repo $Repository --target $commit @releaseFlags --title "OpenGuidePlatform $($manifest.version)" --generate-notes --notes-file "$OutputPath/release-notes.md" +& gh release create $tag "$OutputPath/OpenGuidePlatform-GuideSite.zip" "$OutputPath/OpenGuidePlatform-PlatformBuild.zip" "$OutputPath/release-manifest.json" --repo $Repository --target $commit @releaseFlags --title $tag --generate-notes --notes-file "$OutputPath/release-notes.md" if($LASTEXITCODE -ne 0){throw 'Platform release publication failed.'} Publish-PlatformWorkflowAliases -WorkspaceRoot $WorkspaceRoot -Repository $Repository -Version $manifest.version -Commit $commit diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformWorkflowAliases.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformWorkflowAliases.ps1 index 068afaa..aa4f5cb 100644 --- a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformWorkflowAliases.ps1 +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformWorkflowAliases.ps1 @@ -3,25 +3,34 @@ function Publish-PlatformWorkflowAliases { $versionNumber=[System.Management.Automation.SemanticVersion]$Version $suffix=if($versionNumber.PreReleaseLabel){'-preview'}else{''} $aliases=@("v$($versionNumber.Major)$suffix","v$($versionNumber.Major).$($versionNumber.Minor)$suffix") - $remote="https://github.com/$Repository.git" - $raw=@(& git ls-remote --refs $remote 'refs/tags/v*') + $raw=& gh api "repos/$Repository/git/matching-refs/tags/v" --paginate --slurp if($LASTEXITCODE -ne 0){throw 'Cannot inspect workflow aliases. The immutable release is published; rerun Release to finish alias publication.'} $refs=@{} - foreach($line in $raw){$parts=$line -split '\s+';if($parts.Count -eq 2){$refs[$parts[1]]=$parts[0]}} + foreach($page in @($raw|ConvertFrom-Json)){foreach($item in $page){$refs[$item.ref]=$item}} foreach($alias in $aliases){ - $ref="refs/tags/$alias";$expected=if($refs.ContainsKey($ref)){$refs[$ref]}else{''} + $ref="refs/tags/$alias";$prior=if($refs.ContainsKey($ref)){$refs[$ref]}else{$null} + $expected=if($prior){$prior.object.sha}else{''} if($expected -ceq $Commit){continue} if($expected){ $priorVersions=@(foreach($name in $refs.Keys){ - if($refs[$name] -cne $expected -or $name -cnotmatch '^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$'){continue} + if($refs[$name].object.sha -cne $expected -or $name -cnotmatch '^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$'){continue} $prior=[System.Management.Automation.SemanticVersion]$name.Substring(11) if([bool]$prior.PreReleaseLabel -eq [bool]$versionNumber.PreReleaseLabel){$prior} }) if(-not $priorVersions.Count){throw "Workflow alias $alias does not identify an immutable release tag. Reconcile it before publishing aliases."} if(@($priorVersions|Where-Object {$_ -gt $versionNumber}).Count){Write-Host "Keeping newer workflow alias $alias.";continue} + $query='query($owner:String!,$name:String!,$ref:String!){repository(owner:$owner,name:$name){id ref(qualifiedName:$ref){target{oid}}}}' + $owner,$repoName=$Repository.Split('/') + $result=& gh api graphql -f "query=$query" -f "owner=$owner" -f "name=$repoName" -f "ref=$ref" | ConvertFrom-Json + if($LASTEXITCODE -ne 0 -or $result.PSObject.Properties['errors'] -or $result.data.repository.ref.target.oid -cne $expected){throw "Workflow alias $alias changed concurrently."} + $repositoryId=$result.data.repository.id + if(-not $repositoryId){throw 'Cannot identify the repository for alias publication.'} + $mutation='mutation{updateRefs(input:{repositoryId:"'+$repositoryId+'",refUpdates:[{name:"'+$ref+'",beforeOid:"'+$expected+'",afterOid:"'+$Commit+'",force:true}]}){clientMutationId}}' + $response=& gh api graphql -f "query=$mutation" | ConvertFrom-Json + if($LASTEXITCODE -ne 0 -or -not $response -or $response.PSObject.Properties['errors']){throw "Workflow alias $alias changed concurrently or could not be published."} + }else{ + $null=& gh api "repos/$Repository/git/refs" --method POST -f "ref=$ref" -f "sha=$Commit" } - # Lease rejects concurrent publication rather than overwriting a newer alias. - & git -C $WorkspaceRoot -c credential.helper= -c 'credential.helper=!gh auth git-credential' push "--force-with-lease=${ref}:$expected" $remote "${Commit}:$ref" if($LASTEXITCODE -ne 0){throw "Workflow alias $alias changed concurrently or could not be published. The release is intact; rerun Release."} Write-Host "Published workflow alias $alias -> v$Version." } diff --git a/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1 b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1 new file mode 100644 index 0000000..eb23de0 --- /dev/null +++ b/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1 @@ -0,0 +1,28 @@ +#Requires -Version 7.4 +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('Release','Validate')][string]$Stage, + [Parameter(Mandatory)][string]$AssetsPath, + [Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{40}$')][string]$SourceCommit, + [string]$ReleaseTag, + [string]$Repository='nkdAgility/OpenGuidePlatform' +) +$ErrorActionPreference='Stop' +$assets=[IO.Path]::GetFullPath($AssetsPath) +$manifest=Get-Content (Join-Path $assets 'release-manifest.json') -Raw|ConvertFrom-Json +if($manifest.sourceCommit -cne $SourceCommit){throw 'Release source commit does not match the package.'} +$tag="v$($manifest.version)" +if($ReleaseTag -and $ReleaseTag -cne $tag){throw 'Requested release tag does not match the package.'} +if($Stage -eq 'Release'){ + & "$PSScriptRoot/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformRelease.ps1" -WorkspaceRoot $assets -OutputPath $assets -SourceCommit $SourceCommit -Repository $Repository + return +} +$published=& gh release view $tag --repo $Repository --json targetCommitish,isDraft,isPrerelease | ConvertFrom-Json +if($LASTEXITCODE -ne 0 -or $published.targetCommitish -cne $SourceCommit -or $published.isDraft -or [bool]$published.isPrerelease -ne $manifest.version.Contains('-')){throw 'Published release identity differs from the package.'} +$verify=Join-Path $assets ('published-'+[guid]::NewGuid().ToString('N')) +& gh release download $tag --repo $Repository --pattern OpenGuidePlatform-GuideSite.zip --pattern OpenGuidePlatform-PlatformBuild.zip --pattern release-manifest.json --dir $verify +if($LASTEXITCODE -ne 0){throw 'Cannot download published release assets.'} +foreach($name in @('release-manifest.json','OpenGuidePlatform-GuideSite.zip','OpenGuidePlatform-PlatformBuild.zip')){ + if((Get-FileHash (Join-Path $verify $name)).Hash -cne (Get-FileHash (Join-Path $assets $name)).Hash){throw "Published $name differs from the tested package."} +} +Write-Host "Verified published release $tag from $SourceCommit." diff --git a/tests/Core/NativeModulePublication.Tests.ps1 b/tests/Core/NativeModulePublication.Tests.ps1 index 29ec9cc..bf1b9e8 100644 --- a/tests/Core/NativeModulePublication.Tests.ps1 +++ b/tests/Core/NativeModulePublication.Tests.ps1 @@ -21,6 +21,7 @@ BeforeAll { $global:LASTEXITCODE=0 $global:OgpNativeTagCalls.Add(($args -join ' ')) if($args[0] -eq 'api'){ + if($args[1] -like 'repos/*/git/matching-refs/tags/v'){return '[]'} if($global:OgpNativeTagFailure){$global:LASTEXITCODE=1} return } @@ -47,8 +48,10 @@ Describe 'Coordinated native module publication' { It 'publishes a nested module tag before making its coordinated release available' { & $publisher -WorkspaceRoot $root -Repository example/platform -OutputPath $assets $global:OgpNativeTagCalls[0] | Should -Match 'refs/tags/system/OpenGuidePlatform.Hugo.Guides/v0.1.0-Preview.1' - $global:OgpNativeTagCalls[-1] | Should -Match '^release create v0.1.0-Preview.1 ' - $global:OgpNativeTagCalls[-1] | Should -Match '--generate-notes' + $releaseCall=$global:OgpNativeTagCalls|Where-Object {$_ -like 'release create *'}|Select-Object -Last 1 + $releaseCall | Should -Match '^release create v0.1.0-Preview.1 ' + $releaseCall | Should -Match '--generate-notes' + $releaseCall | Should -Match '--title v0.1.0-Preview.1' $notes=Get-Content "$assets/release-notes.md" -Raw $notes|Should -Match 'Update -ring preview -PlatformRelease v0.1.0-Preview.1' $notes|Should -Match 'First installation|OpenGuidePlatform-GuideSite.zip|settings.yaml' @@ -59,13 +62,15 @@ Describe 'Coordinated native module publication' { ConvertTo-PackageManifest $manifest|ConvertTo-Json -Depth 10|Set-Content "$assets/release-manifest.json" & $publisher -WorkspaceRoot $root -OutputPath $assets $global:OgpNativeTagCalls[0]|Should -Match 'refs/tags/system/OpenGuidePlatform.Hugo.Guides/v0.1.0' - $global:OgpNativeTagCalls[-1]|Should -Match '^release create v0.1.0 ' - $global:OgpNativeTagCalls[-1]|Should -Not -Match '--prerelease|--latest=false' + $releaseCall=$global:OgpNativeTagCalls|Where-Object {$_ -like 'release create *'}|Select-Object -Last 1 + $releaseCall|Should -Match '^release create v0.1.0 ' + $releaseCall|Should -Not -Match '--prerelease|--latest=false' + $releaseCall|Should -Match '--title v0.1.0' Get-Content "$assets/release-notes.md" -Raw|Should -Match 'Update -ring production -PlatformRelease v0.1.0' } It 'keeps prerelease versions as preview releases' { & $publisher -WorkspaceRoot $root -OutputPath $assets - $global:OgpNativeTagCalls[-1]|Should -Match '--prerelease --latest=false' + ($global:OgpNativeTagCalls|Where-Object {$_ -like 'release create *'}|Select-Object -Last 1)|Should -Match '--prerelease --latest=false' } It 'rejects a manifest channel inconsistent with its version' { $manifest.channel='stable' @@ -85,12 +90,12 @@ Describe 'Coordinated native module publication' { try { & $publisher -WorkspaceRoot $workspace -Repository example/platform -OutputPath $relativeAssets } finally { Pop-Location } Test-Path "$assets/release-notes.md" | Should -BeTrue - $global:OgpNativeTagCalls[-1] | Should -Match ([regex]::Escape("$assets/OpenGuidePlatform-GuideSite.zip")) + ($global:OgpNativeTagCalls|Where-Object {$_ -like 'release create *'}|Select-Object -Last 1) | Should -Match ([regex]::Escape("$assets/OpenGuidePlatform-GuideSite.zip")) } It 'reuses an existing matching tag without moving or recreating it' { $global:OgpNativeTagExisting=@(('a'*40)+"`trefs/tags/system/OpenGuidePlatform.Hugo.Guides/v0.1.0-Preview.1") & $publisher -WorkspaceRoot $root -Repository example/platform -OutputPath $assets - @($global:OgpNativeTagCalls|Where-Object {$_ -match '^api '}).Count | Should -Be 0 + @($global:OgpNativeTagCalls|Where-Object {$_ -match '^api .*/git/refs --method POST.*system/OpenGuidePlatform.Hugo.Guides/' }).Count | Should -Be 0 } It 'refuses a conflicting immutable module tag' { $global:OgpNativeTagExisting=@(('b'*40)+"`trefs/tags/system/OpenGuidePlatform.Hugo.Guides/v0.1.0-Preview.1") diff --git a/tests/Core/PackagedRelease.Tests.ps1 b/tests/Core/PackagedRelease.Tests.ps1 new file mode 100644 index 0000000..95cd66b --- /dev/null +++ b/tests/Core/PackagedRelease.Tests.ps1 @@ -0,0 +1,40 @@ +BeforeAll { + $root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $launcher=Join-Path $root 'system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/release.ps1' + function global:gh { + $global:LASTEXITCODE=0 + if($args[0] -eq 'release' -and $args[1] -eq 'view'){ + return (@{targetCommitish=('a'*40);isDraft=$false;isPrerelease=$false}|ConvertTo-Json) + } + if($args[0] -eq 'release' -and $args[1] -eq 'download'){ + $index=[Array]::IndexOf($args,'--dir') + [IO.Directory]::CreateDirectory($args[$index+1])|Out-Null + foreach($name in @('release-manifest.json','OpenGuidePlatform-GuideSite.zip','OpenGuidePlatform-PlatformBuild.zip')){ + Copy-Item (Join-Path $global:OgpPackagedReleaseAssets $name) $args[$index+1] + } + if($global:OgpPackagedReleaseCorrupt){Set-Content (Join-Path $args[$index+1] 'OpenGuidePlatform-GuideSite.zip') 'changed'} + return + } + throw 'Unexpected GitHub command' + } +} +Describe 'Packaged release launcher' { + BeforeEach { + $global:OgpPackagedReleaseAssets=Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($global:OgpPackagedReleaseAssets)|Out-Null + $global:OgpPackagedReleaseCorrupt=$false + @{version='1.2.3';sourceCommit=('a'*40)}|ConvertTo-Json|Set-Content "$global:OgpPackagedReleaseAssets/release-manifest.json" + Set-Content "$global:OgpPackagedReleaseAssets/OpenGuidePlatform-GuideSite.zip" 'guide bytes' + Set-Content "$global:OgpPackagedReleaseAssets/OpenGuidePlatform-PlatformBuild.zip" 'platform bytes' + } + It 'rejects an artifact from another source commit before publication' { + { & $launcher -Stage Validate -AssetsPath $global:OgpPackagedReleaseAssets -SourceCommit ('b'*40) }|Should -Throw '*source commit does not match*' + } + It 'validates published bytes using only the artifact and GitHub release' { + & $launcher -Stage Validate -AssetsPath $global:OgpPackagedReleaseAssets -SourceCommit ('a'*40) -ReleaseTag v1.2.3 + } + It 'rejects changed published assets' { + $global:OgpPackagedReleaseCorrupt=$true + { & $launcher -Stage Validate -AssetsPath $global:OgpPackagedReleaseAssets -SourceCommit ('a'*40) }|Should -Throw '*differs from the tested package*' + } +} diff --git a/tests/Core/WorkflowReleaseAliases.Tests.ps1 b/tests/Core/WorkflowReleaseAliases.Tests.ps1 index dec772f..9053a44 100644 --- a/tests/Core/WorkflowReleaseAliases.Tests.ps1 +++ b/tests/Core/WorkflowReleaseAliases.Tests.ps1 @@ -1,31 +1,57 @@ BeforeAll { $root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent . "$root/system/OpenGuidePlatform.PowerShell.PlatformBuild/Release/Publish-PlatformWorkflowAliases.ps1" - function git { + function global:gh { $global:LASTEXITCODE=0 - if($args -contains 'ls-remote'){return $script:refs} - if($args -contains 'push'){$script:pushes.Add(($args -join ' '));if($script:fail){$global:LASTEXITCODE=1};return} - throw 'Unexpected git call' + $global:OgpAliasCalls.Add(($args -join ' ')) + if($args[0] -ne 'api'){throw 'Unexpected command'} + if($args[1] -like 'repos/*/git/matching-refs/tags/v'){ + if($global:OgpAliasRaw){return $global:OgpAliasRaw} + return ('['+($global:OgpAliasRefs|ConvertTo-Json -Depth 5 -AsArray)+']') + } + if($args[1] -eq 'graphql' -and $args -match 'query=query'){ + return (@{data=@{repository=@{id='repo-id';ref=@{target=@{oid=$global:OgpAliasObservedOid}}}}}|ConvertTo-Json -Depth 6) + } + if($args[1] -eq 'graphql'){ + if($global:OgpAliasFail){$global:LASTEXITCODE=1;return (@{errors=@(@{message='stale oid'})}|ConvertTo-Json)} + return (@{data=@{updateRefs=@{clientMutationId=$null}}}|ConvertTo-Json -Depth 6) + } + if($global:OgpAliasFail){$global:LASTEXITCODE=1} } } Describe 'Version-family workflow publication' { - BeforeEach {$script:refs=@();$script:pushes=[Collections.Generic.List[string]]::new();$script:fail=$false} - It 'publishes only ring-specific major and minor aliases' -ForEach @( + BeforeEach {$global:OgpAliasRefs=@();$global:OgpAliasRaw=$null;$global:OgpAliasCalls=[Collections.Generic.List[string]]::new();$global:OgpAliasFail=$false;$global:OgpAliasObservedOid='b'*40} + It 'creates only ring-specific major and minor aliases' -ForEach @( @{Version='1.2.3';Major='v1';Minor='v1.2'},@{Version='0.6.0-Preview.2';Major='v0-preview';Minor='v0.6-preview'} ) { Publish-PlatformWorkflowAliases -WorkspaceRoot $root -Repository example/platform -Version $Version -Commit ('a'*40) - $script:pushes.Count|Should -Be 2 - $script:pushes[0]|Should -Match ([regex]::Escape("refs/tags/$Major")) - $script:pushes[1]|Should -Match ([regex]::Escape("refs/tags/$Minor")) - $script:pushes[0]|Should -Match 'force-with-lease' + $creates=@($global:OgpAliasCalls|Where-Object {$_ -like 'api repos/*/git/refs --method POST*'}) + $creates.Count|Should -Be 2 + $creates[0]|Should -Match ([regex]::Escape("refs/tags/$Major")) + $creates[1]|Should -Match ([regex]::Escape("refs/tags/$Minor")) } It 'does not move aliases backwards when an older release is published' { - $script:refs=@(foreach($name in @('v1','v1.2','v1.2.9')){('b'*40)+"`trefs/tags/$name"}) + $global:OgpAliasRefs=@(foreach($name in @('v1','v1.2','v1.2.9')){@{ref="refs/tags/$name";object=@{sha=('b'*40)}}}) Publish-PlatformWorkflowAliases -WorkspaceRoot $root -Repository example/platform -Version 1.2.3 -Commit ('a'*40) - $script:pushes.Count|Should -Be 0 + @($global:OgpAliasCalls|Where-Object {$_ -match 'POST|graphql'}).Count|Should -Be 0 + } + It 'reads refs from every returned page before deciding whether to move aliases' { + $old='b'*40 + $global:OgpAliasRaw='[[{"ref":"refs/tags/v1","object":{"sha":"'+$old+'"}},{"ref":"refs/tags/v1.2","object":{"sha":"'+$old+'"}}],[{"ref":"refs/tags/v1.2.9","object":{"sha":"'+$old+'"}}]]' + Publish-PlatformWorkflowAliases -WorkspaceRoot $root -Repository example/platform -Version 1.2.3 -Commit ('a'*40) + @($global:OgpAliasCalls|Where-Object {$_ -match 'POST|graphql'}).Count|Should -Be 0 + } + It 'updates existing aliases with the expected oid' { + $global:OgpAliasRefs=@(foreach($name in @('v1','v1.2','v1.2.2')){@{ref="refs/tags/$name";object=@{sha=('b'*40)}}}) + Publish-PlatformWorkflowAliases -WorkspaceRoot $root -Repository example/platform -Version 1.2.3 -Commit ('a'*40) + $updates=@($global:OgpAliasCalls|Where-Object {$_ -match 'api graphql.*updateRefs'}) + $updates.Count|Should -Be 2 + $updates[0]|Should -Match ('beforeOid:"'+('b'*40)+'"') } It 'fails on a lost lease rather than overwriting a concurrent publication' { - $script:fail=$true + $global:OgpAliasRefs=@(foreach($name in @('v1','v1.2','v1.2.2')){@{ref="refs/tags/$name";object=@{sha=('b'*40)}}}) + $global:OgpAliasObservedOid='c'*40 {Publish-PlatformWorkflowAliases -WorkspaceRoot $root -Repository example/platform -Version 1.2.3 -Commit ('a'*40)}|Should -Throw '*changed concurrently*' + @($global:OgpAliasCalls|Where-Object {$_ -match 'api graphql.*updateRefs'}).Count|Should -Be 0 } }