diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 00000000..698b74cb --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,143 @@ +# Releasing + +Stable releases are cut by pushing an annotated `vX.Y.Z` tag. The `Release` workflow (`.github/workflows/github-release.yml`) does everything else: it builds the VSIX, creates the GitHub release with generated notes, checks that the tests passed, publishes to the Visual Studio Marketplace, records the version on `main` and finally marks the release as latest. + +## Stable releases + +1. Pick a PR merge commit on `main` whose `Run tests` run is green. + + ``` + git fetch origin + git log --merges --first-parent --oneline origin/main + gh run list --workflow test.yml --commit + ``` + +2. Tag that commit and push the tag. + + ``` + git tag -a vX.Y.Z -m "Release vX.Y.Z" + git push origin vX.Y.Z + ``` + +`X.Y.Z` must exceed the highest published stable release, and the tagged commit must descend from it. No version, changelog or release notes edits are made beforehand: the workflow writes the version into the build, the notes are generated from merged PRs, and the version is recorded on `main` afterwards. + +## Tagging rule + +- The tag is annotated and points at the PR merge commit on `main`. Never tag the `chore(release): set version to ...` bot commit. It has no `Run tests` run, because pushes made with `GITHUB_TOKEN` start no workflow, so the test gate would fail. +- When the bot commit is the tip of `main`, tag the merge commit below it. +- The test gate accepts any green `test.yml` run for the tagged commit, so a green `pull_request` run for a PR-head commit also passes it. That run tested the merge with the base of its time, not the commit itself. Tagging the merge commit avoids relying on that: its `push` run on `main` tested exactly the released tree. + +## What the workflow does + +Stable tags run all eight jobs, in this order: + +| # | Job name | Job id | What it does | +|---|----------|--------|--------------| +| 1 | Resolve Release Metadata | `metadata` | Classifies the tag, validates it (shape, version ordering, ancestry, dispatch rules) and resolves the commit, version and notes range | +| 2 | Build VSIX | `build` | Checks out the tag, injects the version, builds the solution and verifies the version inside the VSIX | +| 3 | Create Release | `create-release` | Creates or updates the GitHub release with generated notes. The release is created as not latest | +| 4 | Upload Release Assets | `upload` | Attaches the VSIX to the release; assets already attached are skipped | +| 5 | Verify Tests Passed | `test-gate` | Requires a successful `Run tests` run for the tagged commit | +| 6 | Publish to Visual Studio Marketplace | `publish` | Publishes the VSIX attached to the release | +| 7 | Record Released Version | `record-version` | Bot commit to `main` that sets the released version in the manifest and the version constant | +| 8 | Mark Release Latest | `promote` | Marks the release as latest, only if no newer release is already latest | + +The release is created not latest and promoted last, so "latest" never points at a version that failed to publish. + +Runs are serialised by the `github-release` concurrency group. Queued runs are kept rather than cancelled, so two tags pushed close together are processed one after the other. + +Test tags (`vX.Y.Z-test.`) run only `metadata`, `build`, `create-release` and `upload`. They never reach the gate, publish, record or promote stages. + +## Release notes + +Notes are generated by GitHub from `.github/release.yml`. PRs are grouped by label: + +| Section | Labels | +|---------|--------| +| Breaking Changes | `breaking-change`, `breaking` | +| Features | `feature`, `enhancement` | +| Bug Fixes | `bug`, `fix`, `defect` | +| Security | `security` | +| Documentation | `documentation`, `docs` | +| Dependencies | `dependencies` | +| Other Changes | everything else | + +Excluded from the notes: PRs labelled `ignore-for-release` or `skip-changelog`, and PRs authored by `dependabot` or `github-actions`. + +Labels are not enforced. An unlabelled PR lands in Other Changes. The repository currently has the labels `bug`, `documentation` and `enhancement`; the others (`breaking-change`, `breaking`, `feature`, `fix`, `defect`, `security`, `docs`, `dependencies`, `ignore-for-release`, `skip-changelog`) can be created when needed. + +## Recovery + +Any stage can fail after earlier stages have had their effect. Complete the release by dispatching the workflow for the existing tag: + +``` +gh workflow run github-release.yml --ref main -f tag=vX.Y.Z +``` + +- The dispatch must come from `main` (`--ref main`) and the tag must already exist. +- The stages re-run with these skip rules: + + | Stage | Re-run behaviour | + |-------|------------------| + | `metadata` | Recomputed; fails if the tag does not exist | + | `build` | Always rebuilt | + | `create-release` | The existing release is updated, never duplicated | + | `upload` | Only assets missing on the release are attached | + | `test-gate` | Re-evaluated against the current runs | + | `publish` | Skipped when the version is already on the Marketplace | + | `record-version` | Skipped when `main` is already at or above the version | + | `promote` | Unchanged when already latest; left alone when a newer release is latest | + +- Recovering an older tag never lowers the version on `main` and never moves latest away from a newer release. +- A gate failure because tests are still running is cleared by dispatching again after they finish. A gate failure because tests failed needs a fix on `main` and a new tag, or a re-run of the failed tests. +- Recovery regenerates the notes and overwrites a hand-edited release body. +- The workflow definition and the release helper (`.github/release-flow.ps1` and `.github/release-flow.psm1`) both come from `main`, while the sources, README and publish manifest come from the tag. A helper fix merged to `main` is therefore used by the next recovery dispatch, and tags that predate the helper can be recovered. + +## Dry run + +``` +gh workflow run github-release.yml --ref main -f tag=v0.9.1 -f dry_run=true +``` + +A dry run uses an existing stable tag. It runs `metadata` plus the Azure and publisher logins of the `publish` job only. It creates no release, builds nothing, publishes nothing, commits nothing and promotes nothing. It is used to prove the Azure credential and the publish rights. Test tags are rejected. + +## Test releases + +Test releases are prereleases for trying a build, with no Marketplace publish. + +``` +make tag-test-release BUMP=minor +pwsh -File tag-test-release.ps1 -Bump patch -WhatIf +pwsh -File tag-test-release.ps1 -Bump patch -NoPush +``` + +- `BUMP` is `patch`, `minor` or `major`, and defaults to `patch`. +- `-WhatIf` prints the tag that would be created. `-NoPush` creates the tag locally without pushing it. +- Prerequisites: `gh` installed and authenticated (`gh auth login`), and a clean worktree. +- NEXT comes from the highest published stable release, bumped by `BUMP`. The tag is `vNEXT-test.` on the current `HEAD`. +- The workflow checks the tag shape, the sha prefix and that the base version exceeds the previous stable release. +- The result is a prerelease with the VSIX and the test banner. There is no Marketplace publish, no write to `main` and no change to latest. + +### Installing a test release + +Download `Codescene.VSExtension.VS2022.vsix` from the prerelease and install it by double-clicking it (the VSIX installer). The version shown is the base `X.Y.Z`, so repeated test builds of the same base may not count as upgrades; uninstall the previous test build first when the installer reports it as already installed. + +## Changelog history + +Release notes before this flow lived in `CHANGELOG.md`. The migration script copies those sections into the bodies of the matching GitHub releases. + +``` +pwsh -File .github/migrate-changelog-to-releases.ps1 +pwsh -File .github/migrate-changelog-to-releases.ps1 --apply +``` + +- Without `--apply` it is a dry run that reports what it would change. +- `--file ` overrides the source. +- The source is the last committed `CHANGELOG.md` in Git history (the file is removed from the working tree). +- Only published stable releases are touched, and releases are never created. Sections with no matching release are reported. +- The script is safe to re-run: the migrated text sits in a delimited block that is replaced, not appended again. + +## Environment facts + +- The Azure federated credential used for publishing matches any subject under `repo:codescene-oss/codescene-vs:*` and is pinned to this repository and owner by ID, so tag, branch and dispatch runs can all log in without credential changes. The credential is developer-owned. +- The `record-version` job pushes to `main` with `GITHUB_TOKEN`. This works because `main` has no branch protection or rulesets. Adding either would break the version record. diff --git a/.github/migrate-changelog-to-releases.ps1 b/.github/migrate-changelog-to-releases.ps1 new file mode 100644 index 00000000..fc7bae26 --- /dev/null +++ b/.github/migrate-changelog-to-releases.ps1 @@ -0,0 +1,99 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'release-flow.psm1') -Force + +$script:DefaultRepository = 'codescene-oss/codescene-vs' +$script:ChangelogFileName = 'CHANGELOG.md' +$script:Usage = 'Usage: migrate-changelog-to-releases.ps1 [--apply] [--file ]' + +function Get-GitHubRepository { + $repository = [Environment]::GetEnvironmentVariable('GITHUB_REPOSITORY') + if ([string]::IsNullOrWhiteSpace($repository)) { return $script:DefaultRepository } + return $repository +} + +function Read-MigrationArguments($Arguments) { + $options = [pscustomobject]@{ Apply = $false; File = '' } + $index = 0 + while ($index -lt $Arguments.Count) { + $argument = [string]$Arguments[$index] + switch -CaseSensitive ($argument) { + '--apply' { $options.Apply = $true } + '--file' { + $index++ + if ($index -ge $Arguments.Count -or [string]::IsNullOrWhiteSpace([string]$Arguments[$index])) { + throw "--file requires a path. $($script:Usage)" + } + $options.File = [string]$Arguments[$index] + } + default { throw "Unknown argument $argument. $($script:Usage)" } + } + $index++ + } + return $options +} + +function Read-ChangelogSource($Options) { + if (-not $Options.File) { + return Read-FileFromHistory -RepositoryPath (Split-Path -Parent $PSScriptRoot) -Path $script:ChangelogFileName + } + if (-not (Test-Path -LiteralPath $Options.File -PathType Leaf)) { + throw "Changelog file not found: $($Options.File)" + } + $path = (Resolve-Path -LiteralPath $Options.File).ProviderPath + $text = (New-Object System.Text.UTF8Encoding($false)).GetString([IO.File]::ReadAllBytes($path)) + return $text.TrimStart([char]0xFEFF) +} + +function Write-MigrationList([string]$Title, $Items) { + $names = @($Items) + $listed = if ($names.Count -eq 0) { 'none' } else { $names -join ', ' } + Write-Host "${Title}: $listed" +} + +function Update-ReleaseBody([string]$Repository, $Update) { + $path = [IO.Path]::GetTempFileName() + try { + [IO.File]::WriteAllText($path, $Update.Body, (New-Object System.Text.UTF8Encoding($false))) + Invoke-NativeChecked -FilePath 'gh' -ArgumentList @('api', '-X', 'PATCH', "repos/$Repository/releases/$($Update.Id)", '-F', "body=@$path") | Out-Null + } + finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-ReleaseUpdate([string]$Repository, $Update, [bool]$Apply) { + if (-not $Apply) { + Write-Host "Would update $($Update.Tag)" + Write-Host $Update.Block + return + } + Write-Host "Updating $($Update.Tag)" + Write-Host $Update.Block + Update-ReleaseBody -Repository $Repository -Update $Update + Write-Host "Updated $($Update.Tag)" +} + +function Main($Arguments) { + $options = Read-MigrationArguments $Arguments + $repository = Get-GitHubRepository + $sections = @(ConvertFrom-Changelog -Text (Read-ChangelogSource $options)) + $releases = @(Get-GitHubRelease -Repository $repository) + $plan = Get-ChangelogMigrationPlan -Sections $sections -Releases $releases + + foreach ($update in $plan.Updates) { + Invoke-ReleaseUpdate -Repository $repository -Update $update -Apply $options.Apply + } + Write-MigrationList 'Up to date' $plan.UpToDate + Write-MigrationList 'Empty changelog sections' $plan.Empty + Write-MigrationList 'Changelog sections without a stable release' $plan.Unmatched + if ($options.Apply) { + Write-Host "Updated $($plan.Updates.Count) release(s)." + } + else { + Write-Host "Dry run: $($plan.Updates.Count) release(s) would be updated. Pass --apply to write." + } +} + +Main $args diff --git a/.github/release-flow.ps1 b/.github/release-flow.ps1 new file mode 100644 index 00000000..76732717 --- /dev/null +++ b/.github/release-flow.ps1 @@ -0,0 +1,289 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'release-flow.psm1') -Force + +$script:Commands = @('metadata', 'set-version', 'verify-vsix', 'create-release', 'upload', 'download-asset', 'test-gate', 'promote', 'write-overview', 'publish', 'record-version') +$script:DefaultRepository = 'codescene-oss/codescene-vs' + +function Get-UsageMessage([string]$Command) { + return "Unknown command '$Command'. Usage: release-flow.ps1 <$($script:Commands -join '|')>" +} + +function Get-RequiredEnvironment([string]$Name) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Environment variable $Name is required." + } + return $value +} + +function Get-OptionalEnvironment([string]$Name) { + $value = [Environment]::GetEnvironmentVariable($Name) + if ($null -eq $value) { return '' } + return $value +} + +function Assert-GitHubToken { + if ([string]::IsNullOrWhiteSpace($env:GH_TOKEN) -and [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + throw 'GH_TOKEN is required to call GitHub.' + } +} + +function Initialize-GitHubCommand { + Assert-GitHubToken + $env:GH_REPO = Get-GitHubRepository +} + +function Resolve-RepositoryPath { + $path = Get-OptionalEnvironment 'REPO_PATH' + if ([string]::IsNullOrWhiteSpace($path)) { + $path = (Get-Location).ProviderPath + } + return (Resolve-Path -LiteralPath $path).ProviderPath +} + +function Get-GitHubRepository { + $repository = Get-OptionalEnvironment 'GITHUB_REPOSITORY' + if ([string]::IsNullOrWhiteSpace($repository)) { return $script:DefaultRepository } + return $repository +} + +function Test-TagExists([string]$Tag) { + return (Invoke-Native -FilePath 'git' -ArgumentList @('rev-parse', '-q', '--verify', "refs/tags/$Tag")).ExitCode -eq 0 +} + +function Get-OtherStableReleases($Releases, [string]$Tag) { + return @(Select-PublishedStableRelease -Releases $Releases -ExcludeTag $Tag) +} + +function Invoke-Metadata { + Initialize-GitHubCommand + $tag = Get-RequiredEnvironment 'TAG' + $eventName = Get-RequiredEnvironment 'EVENT' + $runRef = Get-OptionalEnvironment 'RUN_REF' + $dryRun = (Get-OptionalEnvironment 'DRY_RUN') -ceq 'true' + + $info = Get-ReleaseTagInfo -Tag $tag + Write-Host "Tag $tag is a $($info.Kind) tag for version $($info.Version) (event: $eventName, dry run: $dryRun)" + + if ($eventName -ceq 'workflow_dispatch') { + Assert-DispatchAllowed -EventName $eventName -RunRef $runRef -TagExists (Test-TagExists $tag) -DryRun $dryRun -Kind $info.Kind + } + + $commit = Get-TagCommit -Tag $tag + if (-not $commit) { + throw "Tag $tag does not resolve to a commit in this checkout." + } + Write-Host "Tagged commit: $commit" + + $onMain = Test-GitAncestor -Ancestor $commit -Descendant 'origin/main' + Write-Host "Tagged commit is reachable from origin/main: $onMain" + + $releases = @(Get-GitHubRelease -Repository (Get-GitHubRepository)) + $releaseExists = @($releases | Where-Object { -not $_.draft -and $_.tag_name -ceq $tag }).Count -gt 0 + Write-Host "A published release for $tag exists: $releaseExists" + + $mode = Get-ComparisonMode -Kind $info.Kind -EventName $eventName -ReleaseExists $releaseExists + Write-Host "Comparison mode: $mode" + + $others = Get-OtherStableReleases $releases $tag + $highest = Select-HighestStableRelease -Releases $others + $isAncestor = { param($Candidate) Test-GitAncestor -Ancestor $Candidate -Descendant $commit }.GetNewClosure() + $previous = Select-PreviousStableRelease -Releases $others -IsAncestor $isAncestor + $highestIsAncestor = $null + if ($null -ne $highest) { + $highestIsAncestor = & $isAncestor $highest.tag_name + Write-Host "Highest stable release: $($highest.tag_name) (ancestor of the tagged commit: $highestIsAncestor)" + } + else { + Write-Host 'No other published stable release exists.' + } + + $notesStart = '' + if ($null -ne $previous) { + $notesStart = $previous.tag_name + Write-Host "Previous stable release: $notesStart" + } + else { + Write-Host 'No previous stable release is an ancestor of the tagged commit.' + } + + $facts = New-TagAdmissionFacts -OnMain $onMain -Highest $highest -HighestIsAncestor $highestIsAncestor -Previous $previous -TaggedCommit $commit + Assert-TagAdmissible -TagInfo $info -Mode $mode -Facts $facts + + Write-ActionOutput -Name 'tag' -Value $tag + Write-ActionOutput -Name 'version' -Value $info.Version + Write-ActionOutput -Name 'kind' -Value $info.Kind + Write-ActionOutput -Name 'notes_start' -Value $notesStart + Write-ActionOutput -Name 'sha' -Value $commit + Write-ActionOutput -Name 'dry_run' -Value $(if ($dryRun) { 'true' } else { 'false' }) +} + +function Invoke-CreateRelease { + Initialize-GitHubCommand + $tag = Get-RequiredEnvironment 'TAG' + $kind = Get-RequiredEnvironment 'KIND' + $notesStart = Get-OptionalEnvironment 'NOTES_START' + $repository = Get-GitHubRepository + + $info = Get-ReleaseTagInfo -Tag $tag + if ($info.Kind -cne $kind) { + throw "Kind '$kind' does not match tag $tag, which is a $($info.Kind) tag." + } + $sha = Get-OptionalEnvironment 'SHA' + $serverUrl = Get-OptionalEnvironment 'GITHUB_SERVER_URL' + if ($kind -ceq 'test') { + $sha = Get-RequiredEnvironment 'SHA' + $serverUrl = Get-RequiredEnvironment 'GITHUB_SERVER_URL' + } + + $generated = Get-GeneratedReleaseNotes -Repository $repository -Tag $tag -PreviousTag $notesStart + Write-Host "Generated release notes for $tag (changes since '$notesStart')" + $body = Get-ReleaseNotesBody -Kind $kind -Generated $generated -Sha $sha -ServerUrl $serverUrl -Repository $repository + $notesFile = Write-ReleaseNotesFile -Tag $tag -Body $body + + $exists = Test-ReleaseExists -Tag $tag + $isLatest = $false + if ($exists -and $kind -ceq 'stable') { + $isLatest = (Get-LatestReleaseTag -Repository $repository) -ceq $tag + } + Write-Host "Release exists: $exists, keeps the latest marker: $isLatest" + + $arguments = Get-ReleaseCommandArguments -Exists $exists -Kind $kind -Tag $tag -NotesFile $notesFile -IsLatest $isLatest + Invoke-NativeChecked -FilePath 'gh' -ArgumentList (@('release') + $arguments) | Out-Null + Write-Host "Release $tag $(if ($exists) { 'updated' } else { 'created' })" +} + +function Get-VsixFileInDirectory([string]$Directory) { + $files = @(Get-ChildItem -LiteralPath $Directory -File | Where-Object { $_.Name -like '*.vsix' }) + if ($files.Count -eq 0) { + throw "No .vsix file found in $Directory." + } + return @($files | ForEach-Object { $_.FullName }) +} + +function Invoke-Upload { + Initialize-GitHubCommand + $tag = Get-RequiredEnvironment 'TAG' + $directory = Get-RequiredEnvironment 'ASSET_DIR' + + $files = @(Get-VsixFileInDirectory $directory) + $attached = @(Get-ReleaseAssetName -Tag $tag) + $missing = @(Select-MissingAsset -ExistingNames $attached -Paths $files) + foreach ($file in $files) { + if ($missing -cnotcontains $file) { + Write-Host "Skipping $([IO.Path]::GetFileName($file)): already attached to $tag" + } + } + foreach ($file in $missing) { + Invoke-NativeChecked -FilePath 'gh' -ArgumentList @('release', 'upload', $tag, $file) | Out-Null + Write-Host "Uploaded $([IO.Path]::GetFileName($file)) to $tag" + } +} + +function Invoke-DownloadAsset { + Initialize-GitHubCommand + $tag = Get-RequiredEnvironment 'TAG' + $directory = Get-RequiredEnvironment 'ASSET_DIR' + + Invoke-NativeChecked -FilePath 'gh' -ArgumentList (Get-AssetDownloadArguments -Tag $tag -Directory $directory) | Out-Null + $vsix = Find-SingleVsix -Root $directory + Write-Host "Downloaded $vsix from $tag" +} + +function Invoke-TestGate { + Initialize-GitHubCommand + $sha = Get-RequiredEnvironment 'SHA' + + $runs = @(Get-TestRun -Repository (Get-GitHubRepository) -Sha $sha) + $verdict = Get-TestGateVerdict -Runs $runs -Sha $sha + if (-not $verdict.Pass) { + throw $verdict.Message + } + Write-Host $verdict.Message +} + +function Invoke-Promote { + Initialize-GitHubCommand + $tag = Get-RequiredEnvironment 'TAG' + + if ((Get-ReleaseTagInfo -Tag $tag).Kind -cne 'stable') { + throw "Only a stable release can be promoted, but $tag is not one." + } + $latest = Get-LatestReleaseTag -Repository (Get-GitHubRepository) + $action = Get-PromotionAction -Tag $tag -LatestTag $latest + switch -CaseSensitive ($action) { + 'promote' { + Invoke-NativeChecked -FilePath 'gh' -ArgumentList @('release', 'edit', $tag, '--latest') | Out-Null + Write-Host "Release $tag is now the latest release" + } + 'unchanged' { Write-Host "Release $tag is already the latest release" } + 'superseded' { Write-Host "Release $tag is superseded by $latest, which stays the latest release" } + default { throw "Unknown promotion action '$action'." } + } +} + +function Invoke-SetVersion([string]$RepositoryPath) { + $version = Get-RequiredEnvironment 'VERSION' + Set-ReleaseVersion -RepositoryPath $RepositoryPath -Version $version + Write-Host "Version set to $version" +} + +function Invoke-VerifyVsix([string]$RepositoryPath) { + $version = Get-RequiredEnvironment 'VERSION' + $root = Join-Path $RepositoryPath 'Codescene.VSExtension.VS2022' + $vsix = Find-SingleVsix -Root $root + Assert-VsixVersion -Path $vsix -Version $version + Write-Host "VSIX $vsix embeds version $version" + Write-ActionOutput -Name 'path' -Value $vsix +} + +function Invoke-WriteOverview([string]$RepositoryPath) { + $path = Write-MarketplaceOverview -RepositoryPath $RepositoryPath + Write-Host "Wrote $path" +} + +function Invoke-Publish { + $version = Get-RequiredEnvironment 'VERSION' + $publisher = Get-RequiredEnvironment 'VSIX_PUBLISHER' + $directory = Get-RequiredEnvironment 'VSIX_DIR' + $manifest = Get-RequiredEnvironment 'PUBLISH_MANIFEST' + + Invoke-MarketplacePublish -Version $version -PublisherPath $publisher -VsixDirectory $directory -PublishManifest $manifest | Out-Null +} + +function Invoke-RecordVersion([string]$RepositoryPath) { + $version = Get-RequiredEnvironment 'VERSION' + Invoke-VersionRecord -RepositoryPath $RepositoryPath -Version $version | Out-Null +} + +function Invoke-ReleaseCommand([string]$Command, [string]$RepositoryPath) { + switch -CaseSensitive ($Command) { + 'metadata' { Invoke-Metadata } + 'set-version' { Invoke-SetVersion $RepositoryPath } + 'verify-vsix' { Invoke-VerifyVsix $RepositoryPath } + 'create-release' { Invoke-CreateRelease } + 'upload' { Invoke-Upload } + 'download-asset' { Invoke-DownloadAsset } + 'test-gate' { Invoke-TestGate } + 'promote' { Invoke-Promote } + 'write-overview' { Invoke-WriteOverview $RepositoryPath } + 'publish' { Invoke-Publish } + 'record-version' { Invoke-RecordVersion $RepositoryPath } + default { throw (Get-UsageMessage $Command) } + } +} + +function Main([string]$Command) { + if ($script:Commands -cnotcontains $Command) { + throw (Get-UsageMessage $Command) + } + $repositoryPath = Resolve-RepositoryPath + Set-Location -LiteralPath $repositoryPath + Invoke-ReleaseCommand $Command $repositoryPath +} + +$command = '' +if ($args.Count -gt 0) { $command = [string]$args[0] } +Main $command diff --git a/.github/release-flow.psm1 b/.github/release-flow.psm1 new file mode 100644 index 00000000..c1949090 --- /dev/null +++ b/.github/release-flow.psm1 @@ -0,0 +1,19 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:PartNames = @( + 'version-tags', 'release-selection', 'dispatch-rules', 'tag-admission', + 'version-files', 'vsix', 'release-notes', 'generated-notes', + 'release-assets', 'release-commands', 'test-gate', 'promotion', + 'json-helpers', 'marketplace-gallery', 'marketplace-overview', 'marketplace-publish', + 'native-process', 'action-output', 'github-releases', 'git-helpers', + 'version-record', 'changelog' +) + +foreach ($partName in $script:PartNames) { + . (Join-Path $PSScriptRoot "release-flow\$partName.ps1") +} + +Set-Utf8ConsoleEncoding + +Export-ModuleMember -Function Get-ReleaseTagInfo, Compare-ReleaseVersion, Get-NextReleaseVersion, Get-TestReleaseTagName, Select-PublishedStableRelease, Select-HighestStableRelease, Select-PreviousStableRelease, Get-ComparisonMode, Assert-DispatchAllowed, Assert-TagAdmissible, New-TagAdmissionFacts, Get-VersionFilePaths, Get-ReleaseVersion, Set-ReleaseVersion, Find-SingleVsix, Get-VsixManifestVersion, Assert-VsixVersion, Format-TestReleaseNotes, Get-ReleaseNotesBody, Get-ReleaseCommandArguments, Select-MissingAsset, Get-TestGateVerdict, Get-PromotionAction, Test-VersionRecordNeeded, Test-PublisherVersionExists, Test-GalleryHasVersion, ConvertTo-MarketplaceOverview, Invoke-Native, Invoke-NativeChecked, Write-ActionOutput, Get-GitHubRelease, Get-TagCommit, Test-GitAncestor, Get-AssetDownloadArguments, Get-GenerateNotesArguments, Get-GeneratedReleaseNotes, Write-ReleaseNotesFile, Test-ReleaseExists, Get-ReleaseAssetName, Get-LatestReleaseTag, Get-TestRun, Write-MarketplaceOverview, Get-GalleryRequest, Test-MarketplaceVersionListed, Invoke-MarketplacePublish, Assert-OnlyVersionFilesChanged, Invoke-VersionRecord, ConvertFrom-Changelog, Get-ChangelogSectionTag, Set-ChangelogBlock, Get-ChangelogMigrationPlan, Read-FileFromHistory diff --git a/.github/release-flow/action-output.ps1 b/.github/release-flow/action-output.ps1 new file mode 100644 index 00000000..82178610 --- /dev/null +++ b/.github/release-flow/action-output.ps1 @@ -0,0 +1,17 @@ +function Write-ActionOutput { + param( + [Parameter(Mandatory)] + [string]$Name, + [AllowNull()][AllowEmptyString()] + [string]$Value + ) + + if (-not $env:GITHUB_OUTPUT) { + throw 'GITHUB_OUTPUT is required to write action outputs.' + } + if ($Name -match '[=\r\n]' -or "$Value" -match '[\r\n]') { + throw "Action output '$Name' must be a single line without '=' in its name." + } + $encoding = New-Object System.Text.UTF8Encoding($false) + [IO.File]::AppendAllText($env:GITHUB_OUTPUT, "$Name=$Value`n", $encoding) +} diff --git a/.github/release-flow/changelog.ps1 b/.github/release-flow/changelog.ps1 new file mode 100644 index 00000000..27374cfd --- /dev/null +++ b/.github/release-flow/changelog.ps1 @@ -0,0 +1,119 @@ +$script:ChangelogBlockStart = '' +$script:ChangelogBlockEnd = '' + +function ConvertTo-LineFeed([AllowNull()][AllowEmptyString()][string]$Text) { + return ("$Text").Replace("`r`n", "`n") +} + +function Get-ChangelogHeadingKey([string]$Line) { + $heading = $Line.Substring(3).Trim() + $match = [regex]::Match($heading, '^\[([^\]]*)\](?: - .*)?$') + if ($match.Success) { return $match.Groups[1].Value } + return $heading +} + +function ConvertFrom-Changelog { + param( + [AllowNull()][AllowEmptyString()] + [string]$Text + ) + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + $key = $null + $lines = New-Object System.Collections.Generic.List[string] + foreach ($line in @((ConvertTo-LineFeed $Text) -split "`n")) { + if ($line.StartsWith('## ', [StringComparison]::Ordinal)) { + if ($null -ne $key) { + [pscustomobject]@{ Key = $key; Body = ($lines -join "`n").Trim() } + } + $key = Get-ChangelogHeadingKey $line + if (-not $seen.Add($key)) { + throw "Duplicate changelog section '$key'." + } + $lines = New-Object System.Collections.Generic.List[string] + continue + } + $lines.Add($line) + } + if ($null -ne $key) { + [pscustomobject]@{ Key = $key; Body = ($lines -join "`n").Trim() } + } +} + +function Get-ChangelogSectionTag { + param( + [AllowNull()][AllowEmptyString()] + [string]$Key + ) + + if ("$Key" -cmatch $script:VersionPattern) { return "v$Key" } + return $null +} + +function Get-ChangelogBlock([string]$Section) { + return "$($script:ChangelogBlockStart)`n## Changelog`n`n$(ConvertTo-LineFeed $Section)`n$($script:ChangelogBlockEnd)`n" +} + +function Set-ChangelogBlock { + param( + [AllowNull()][AllowEmptyString()] + [string]$Body, + [AllowNull()][AllowEmptyString()] + [string]$Section + ) + + $pattern = [regex]::Escape($script:ChangelogBlockStart) + '.*?' + [regex]::Escape($script:ChangelogBlockEnd) + '\n?' + $remaining = [regex]::Replace((ConvertTo-LineFeed $Body), $pattern, '', [System.Text.RegularExpressions.RegexOptions]::Singleline) + $block = Get-ChangelogBlock $Section + if ([string]::IsNullOrWhiteSpace($remaining)) { return $block } + return $remaining.TrimEnd() + "`n`n" + $block +} + +function Get-ChangelogMigrationPlan { + param( + [AllowNull()][AllowEmptyCollection()] + $Sections, + [AllowNull()][AllowEmptyCollection()] + $Releases + ) + + $byTag = New-Object 'System.Collections.Generic.Dictionary[string,object]' ([StringComparer]::Ordinal) + foreach ($release in @(Select-PublishedStableRelease -Releases $Releases)) { + $byTag[$release.tag_name] = $release + } + + $updates = @() + $upToDate = @() + $empty = @() + $unmatched = @() + foreach ($section in @($Sections | Where-Object { $null -ne $_ })) { + $tag = Get-ChangelogSectionTag -Key $section.Key + if ($null -eq $tag -or -not $byTag.ContainsKey($tag)) { + $unmatched += $section.Key + continue + } + if ([string]::IsNullOrWhiteSpace($section.Body)) { + $empty += $section.Key + continue + } + $release = $byTag[$tag] + $current = ConvertTo-LineFeed (Get-OptionalProperty $release 'body') + $body = Set-ChangelogBlock -Body $current -Section $section.Body + if ($body -ceq $current) { + $upToDate += $tag + continue + } + $updates += [pscustomobject]@{ + Id = $release.id + Tag = $tag + Body = $body + Block = Get-ChangelogBlock $section.Body + } + } + return [pscustomobject]@{ + Updates = @($updates) + UpToDate = @($upToDate) + Empty = @($empty) + Unmatched = @($unmatched) + } +} diff --git a/.github/release-flow/dispatch-rules.ps1 b/.github/release-flow/dispatch-rules.ps1 new file mode 100644 index 00000000..19568573 --- /dev/null +++ b/.github/release-flow/dispatch-rules.ps1 @@ -0,0 +1,47 @@ +function Get-ComparisonMode { + param( + [Parameter(Mandatory)] + [string]$Kind, + [Parameter(Mandatory)] + [string]$EventName, + [Parameter(Mandatory)] + [bool]$ReleaseExists + ) + + if ($Kind -cne 'stable' -and $Kind -cne 'test') { + throw "Unknown tag kind '$Kind'. Expected stable or test." + } + if ($EventName -cne 'push' -and $EventName -cne 'workflow_dispatch') { + throw "Unsupported event '$EventName'. Expected push or workflow_dispatch." + } + if ($Kind -ceq 'test') { return 'recovery' } + if ($EventName -ceq 'push') { return 'fresh' } + if ($ReleaseExists) { return 'recovery' } + return 'fresh' +} + +function Assert-DispatchAllowed { + param( + [Parameter(Mandatory)] + [string]$EventName, + [AllowNull()][AllowEmptyString()] + [string]$RunRef, + [Parameter(Mandatory)] + [bool]$TagExists, + [Parameter(Mandatory)] + [bool]$DryRun, + [Parameter(Mandatory)] + [string]$Kind + ) + + if ($EventName -cne 'workflow_dispatch') { return } + if ($RunRef -cne 'refs/heads/main') { + throw "The release workflow must be dispatched from main, but was dispatched from '$RunRef'." + } + if (-not $TagExists) { + throw 'The tag does not exist. Create and push the tag before dispatching the workflow.' + } + if ($DryRun -and $Kind -cne 'stable') { + throw 'Dry run needs a stable tag. Test tags always publish a prerelease.' + } +} diff --git a/.github/release-flow/generated-notes.ps1 b/.github/release-flow/generated-notes.ps1 new file mode 100644 index 00000000..25619df0 --- /dev/null +++ b/.github/release-flow/generated-notes.ps1 @@ -0,0 +1,35 @@ +function Get-GenerateNotesArguments { + param( + [Parameter(Mandatory)] + [string]$Repository, + [Parameter(Mandatory)] + [string]$Tag, + [AllowNull()][AllowEmptyString()] + [string]$PreviousTag + ) + + $arguments = @('api', '-X', 'POST', "repos/$Repository/releases/generate-notes", '-f', "tag_name=$Tag") + if (-not [string]::IsNullOrEmpty($PreviousTag)) { + $arguments += @('-f', "previous_tag_name=$PreviousTag") + } + return $arguments +} + +function Get-GeneratedReleaseNotes { + param( + [Parameter(Mandatory)] + [string]$Repository, + [Parameter(Mandatory)] + [string]$Tag, + [AllowNull()][AllowEmptyString()] + [string]$PreviousTag + ) + + $arguments = Get-GenerateNotesArguments -Repository $Repository -Tag $Tag -PreviousTag $PreviousTag + $result = Invoke-NativeChecked -FilePath 'gh' -ArgumentList $arguments + $body = Get-OptionalProperty ($result.Output | ConvertFrom-Json) 'body' + if ($null -eq $body) { + throw "GitHub returned no release notes body for $Tag." + } + return [string]$body +} diff --git a/.github/release-flow/git-helpers.ps1 b/.github/release-flow/git-helpers.ps1 new file mode 100644 index 00000000..3482a3eb --- /dev/null +++ b/.github/release-flow/git-helpers.ps1 @@ -0,0 +1,39 @@ +function Get-TagCommit { + param( + [Parameter(Mandatory)] + [string]$Tag + ) + + $result = Invoke-Native -FilePath 'git' -ArgumentList @('rev-parse', '--verify', '-q', "$Tag^{commit}") + if ($result.ExitCode -ne 0) { return $null } + return $result.Output.Trim() +} + +function Test-GitAncestor { + param( + [Parameter(Mandatory)] + [string]$Ancestor, + [Parameter(Mandatory)] + [string]$Descendant + ) + + $result = Invoke-Native -FilePath 'git' -ArgumentList @('merge-base', '--is-ancestor', $Ancestor, $Descendant) + if ($result.ExitCode -eq 0) { return $true } + if ($result.ExitCode -eq 1) { return $false } + throw "git merge-base --is-ancestor $Ancestor $Descendant failed with exit code $($result.ExitCode): $($result.Output)" +} + +function Read-FileFromHistory { + param( + [string]$RepositoryPath = (Get-Location).ProviderPath, + [Parameter(Mandatory)] + [string]$Path + ) + + $log = Invoke-NativeChecked -FilePath 'git' -ArgumentList @('-C', $RepositoryPath, 'log', '-1', '--diff-filter=D', '--format=%H', '--', $Path) + $sha = $log.Output.Trim() + if (-not $sha) { + throw "$Path deletion not found in git history; pass --file " + } + return (Invoke-NativeChecked -FilePath 'git' -ArgumentList @('-C', $RepositoryPath, 'show', "${sha}^:$Path")).Output +} diff --git a/.github/release-flow/github-releases.ps1 b/.github/release-flow/github-releases.ps1 new file mode 100644 index 00000000..5ac8c013 --- /dev/null +++ b/.github/release-flow/github-releases.ps1 @@ -0,0 +1,18 @@ +function Expand-JsonPage($Item) { + if ($Item -is [System.Array]) { + foreach ($child in $Item) { Expand-JsonPage $child } + return + } + if ($null -ne $Item) { $Item } +} + +function Get-GitHubRelease { + param( + [Parameter(Mandatory)] + [string]$Repository + ) + + $result = Invoke-NativeChecked -FilePath 'gh' -ArgumentList @('api', '--paginate', '--slurp', "repos/$Repository/releases?per_page=100") + $pages = $result.Output | ConvertFrom-Json + return @(Expand-JsonPage $pages) +} diff --git a/.github/release-flow/json-helpers.ps1 b/.github/release-flow/json-helpers.ps1 new file mode 100644 index 00000000..bda01823 --- /dev/null +++ b/.github/release-flow/json-helpers.ps1 @@ -0,0 +1,6 @@ +function Get-OptionalProperty($Object, [string]$Name) { + if ($null -eq $Object) { return $null } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} diff --git a/.github/release-flow/marketplace-gallery.ps1 b/.github/release-flow/marketplace-gallery.ps1 new file mode 100644 index 00000000..5cda1c21 --- /dev/null +++ b/.github/release-flow/marketplace-gallery.ps1 @@ -0,0 +1,60 @@ +function Get-GalleryVersionEntries($Response) { + $result = @(Get-OptionalProperty $Response 'results') | Select-Object -First 1 + $extension = @(Get-OptionalProperty $result 'extensions') | Select-Object -First 1 + return @(Get-OptionalProperty $extension 'versions') +} + +function Test-GalleryHasVersion { + param( + [AllowNull()] + $Response, + [Parameter(Mandatory)] + [string]$Version + ) + + $listed = @(Get-GalleryVersionEntries $Response | ForEach-Object { Get-OptionalProperty $_ 'version' }) + return [bool]($listed -ccontains $Version) +} + +function Get-GalleryRequest { + param( + [Parameter(Mandatory)] + [string]$Version + ) + + $query = [ordered]@{ + filters = @( + [ordered]@{ + criteria = @( + [ordered]@{ filterType = 7; value = 'CodeScene.codescene-vs' } + ) + } + ) + flags = 1 + } + return [pscustomobject]@{ + Uri = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' + Headers = [ordered]@{ + 'Accept' = 'application/json;api-version=3.0-preview.1' + 'Content-Type' = 'application/json' + } + Body = ($query | ConvertTo-Json -Depth 6 -Compress) + } +} + +function Test-MarketplaceVersionListed { + param( + [Parameter(Mandatory)] + [string]$Version, + [scriptblock]$Sender = { param($Request) Invoke-RestMethod -Method Post -Uri $Request.Uri -Headers $Request.Headers -Body $Request.Body -UseBasicParsing } + ) + + $request = Get-GalleryRequest -Version $Version + try { + $response = & $Sender $request + } + catch { + throw "Could not query the Marketplace for version ${Version}: $($_.Exception.Message)" + } + return Test-GalleryHasVersion -Response $response -Version $Version +} diff --git a/.github/release-flow/marketplace-overview.ps1 b/.github/release-flow/marketplace-overview.ps1 new file mode 100644 index 00000000..a15ce10f --- /dev/null +++ b/.github/release-flow/marketplace-overview.ps1 @@ -0,0 +1,23 @@ +function ConvertTo-MarketplaceOverview { + param( + [AllowNull()][AllowEmptyString()] + [string]$Readme + ) + + $baseUrl = 'https://raw.githubusercontent.com/codescene-oss/codescene-vs/main' + $content = $Readme -replace 'src="screenshots/', "src=`"$baseUrl/screenshots/" + return $content -replace '\]\(screenshots/', "]($baseUrl/screenshots/" +} + +function Write-MarketplaceOverview { + param( + [Parameter(Mandatory)] + [string]$RepositoryPath + ) + + $encoding = New-Object System.Text.UTF8Encoding($false) + $readme = $encoding.GetString([IO.File]::ReadAllBytes((Join-Path $RepositoryPath 'README.md'))) + $path = Join-Path $RepositoryPath 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\overview.md' + [IO.File]::WriteAllBytes($path, $encoding.GetBytes((ConvertTo-MarketplaceOverview -Readme $readme))) + return $path +} diff --git a/.github/release-flow/marketplace-publish.ps1 b/.github/release-flow/marketplace-publish.ps1 new file mode 100644 index 00000000..b2062ed4 --- /dev/null +++ b/.github/release-flow/marketplace-publish.ps1 @@ -0,0 +1,61 @@ +function Test-PublisherVersionExists { + param( + [AllowNull()][AllowEmptyString()] + [string[]]$Output + ) + + $text = @($Output | Where-Object { $null -ne $_ }) -join "`n" + return [bool]($text -match '(?i)already exists|version\b.*\bexists') +} + +function Find-SingleVsix { + param( + [Parameter(Mandatory)] + [string]$Root + ) + + $found = @(Get-ChildItem -LiteralPath $Root -Recurse -File | Where-Object { $_.Name -like '*.vsix' }) + if ($found.Count -eq 0) { + throw "No .vsix file found under $Root." + } + if ($found.Count -gt 1) { + throw "Expected exactly one .vsix under $Root but found $($found.Count): $(($found | ForEach-Object { $_.FullName }) -join ', ')." + } + return $found[0].FullName +} + +function Invoke-MarketplacePublish { + param( + [Parameter(Mandatory)] + [string]$Version, + [Parameter(Mandatory)] + [string]$PublisherPath, + [Parameter(Mandatory)] + [string]$VsixDirectory, + [Parameter(Mandatory)] + [string]$PublishManifest, + [scriptblock]$Publisher = { param($FilePath, $ArgumentList) Invoke-Native -FilePath $FilePath -ArgumentList $ArgumentList }, + [scriptblock]$IsListed = { param($Version) Test-MarketplaceVersionListed -Version $Version } + ) + + if (-not (Test-Path -LiteralPath $PublishManifest -PathType Leaf)) { + throw "publishManifest.json not found at $PublishManifest." + } + $vsix = Find-SingleVsix -Root $VsixDirectory + if (& $IsListed $Version) { + Write-Host "Version $Version is already listed on the Marketplace; skipping publish" + return 'listed' + } + Write-Host "Publishing $vsix with manifest $PublishManifest" + $result = & $Publisher $PublisherPath @('publish', '-payload', $vsix, '-publishManifest', $PublishManifest) + if ($result.Output) { Write-Host $result.Output } + if ($result.ExitCode -eq 0) { + Write-Host "Published version $Version to the Marketplace" + return 'published' + } + if (Test-PublisherVersionExists -Output $result.Output) { + Write-Host "Version $Version already exists on the Marketplace; skipping publish" + return 'exists' + } + throw "Publishing $vsix failed with exit code $($result.ExitCode): $($result.Output)" +} diff --git a/.github/release-flow/native-process.ps1 b/.github/release-flow/native-process.ps1 new file mode 100644 index 00000000..fde1423c --- /dev/null +++ b/.github/release-flow/native-process.ps1 @@ -0,0 +1,40 @@ +function Set-Utf8ConsoleEncoding { + try { + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) + } + catch [System.IO.IOException] { + } +} + +function Invoke-Native { + param( + [Parameter(Mandatory)] + [string]$FilePath, + [AllowNull()][AllowEmptyCollection()] + [string[]]$ArgumentList + ) + + $arguments = @($ArgumentList | Where-Object { $null -ne $_ }) + $ErrorActionPreference = 'Continue' + $lines = & $FilePath @arguments 2>&1 | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + return [pscustomobject]@{ + ExitCode = $exitCode + Output = (@($lines) -join "`n").TrimEnd() + } +} + +function Invoke-NativeChecked { + param( + [Parameter(Mandatory)] + [string]$FilePath, + [AllowNull()][AllowEmptyCollection()] + [string[]]$ArgumentList + ) + + $result = Invoke-Native -FilePath $FilePath -ArgumentList $ArgumentList + if ($result.ExitCode -ne 0) { + throw "$FilePath $(@($ArgumentList) -join ' ') failed with exit code $($result.ExitCode): $($result.Output)" + } + return $result +} diff --git a/.github/release-flow/promotion.ps1 b/.github/release-flow/promotion.ps1 new file mode 100644 index 00000000..15f470b1 --- /dev/null +++ b/.github/release-flow/promotion.ps1 @@ -0,0 +1,40 @@ +function Get-PromotionAction { + param( + [Parameter(Mandatory)] + [string]$Tag, + [AllowNull()][AllowEmptyString()] + [string]$LatestTag + ) + + $info = Get-ReleaseTagInfo -Tag $Tag + if ([string]::IsNullOrEmpty($LatestTag)) { return 'promote' } + if ($LatestTag -ceq $Tag) { return 'unchanged' } + + $latest = Get-ReleaseTagInfo -Tag $LatestTag + if ($latest.Kind -cne 'stable') { + throw "The latest release $LatestTag is not a stable release tag." + } + $comparison = Compare-ReleaseVersion -Left $info.Version -Right $latest.Version + if ($comparison -gt 0) { return 'promote' } + if ($comparison -lt 0) { return 'superseded' } + return 'unchanged' +} + +function Get-LatestReleaseTag { + param( + [Parameter(Mandatory)] + [string]$Repository, + [scriptblock]$Invoker = { param($FilePath, $ArgumentList) Invoke-Native -FilePath $FilePath -ArgumentList $ArgumentList } + ) + + $result = & $Invoker 'gh' @('api', "repos/$Repository/releases/latest") + if ($result.ExitCode -ne 0) { + if ($result.Output -match 'HTTP 404') { return $null } + throw "gh api repos/$Repository/releases/latest failed with exit code $($result.ExitCode): $($result.Output)" + } + $tag = Get-OptionalProperty ($result.Output | ConvertFrom-Json) 'tag_name' + if ([string]::IsNullOrEmpty($tag)) { + throw "The latest release of $Repository has no tag name." + } + return [string]$tag +} diff --git a/.github/release-flow/release-assets.ps1 b/.github/release-flow/release-assets.ps1 new file mode 100644 index 00000000..e6790d7d --- /dev/null +++ b/.github/release-flow/release-assets.ps1 @@ -0,0 +1,37 @@ +function Select-MissingAsset { + param( + [AllowNull()][AllowEmptyCollection()] + [string[]]$ExistingNames, + [AllowNull()][AllowEmptyCollection()] + [string[]]$Paths + ) + + $attached = @($ExistingNames | Where-Object { $null -ne $_ }) + foreach ($path in @($Paths | Where-Object { $null -ne $_ })) { + if ($attached -cnotcontains [IO.Path]::GetFileName($path)) { $path } + } +} + +$script:ReleaseAssetName = 'Codescene.VSExtension.VS2022.vsix' + +function Get-AssetDownloadArguments { + param( + [Parameter(Mandatory)] + [string]$Tag, + [Parameter(Mandatory)] + [string]$Directory + ) + + return @('release', 'download', $Tag, '--pattern', $script:ReleaseAssetName, '--dir', $Directory, '--clobber') +} + +function Get-ReleaseAssetName { + param( + [Parameter(Mandatory)] + [string]$Tag + ) + + $result = Invoke-NativeChecked -FilePath 'gh' -ArgumentList @('release', 'view', $Tag, '--json', 'assets') + $assets = @(Get-OptionalProperty ($result.Output | ConvertFrom-Json) 'assets') + return @($assets | Where-Object { $null -ne $_ } | ForEach-Object { $_.name }) +} diff --git a/.github/release-flow/release-commands.ps1 b/.github/release-flow/release-commands.ps1 new file mode 100644 index 00000000..3eaa105a --- /dev/null +++ b/.github/release-flow/release-commands.ps1 @@ -0,0 +1,34 @@ +function Get-ReleaseCommandArguments { + param( + [Parameter(Mandatory)] + [bool]$Exists, + [Parameter(Mandatory)] + [string]$Kind, + [Parameter(Mandatory)] + [string]$Tag, + [Parameter(Mandatory)] + [string]$NotesFile, + [Parameter(Mandatory)] + [bool]$IsLatest + ) + + if ($Kind -cne 'stable' -and $Kind -cne 'test') { + throw "Unknown tag kind '$Kind'. Expected stable or test." + } + if ($Exists) { + $latest = if ($IsLatest) { 'true' } else { 'false' } + return @('edit', $Tag, '--title', $Tag, '--notes-file', $NotesFile, "--latest=$latest") + } + $arguments = @('create', $Tag, '--title', $Tag, '--notes-file', $NotesFile, '--verify-tag', '--latest=false') + if ($Kind -ceq 'test') { $arguments += '--prerelease' } + return $arguments +} + +function Test-ReleaseExists { + param( + [Parameter(Mandatory)] + [string]$Tag + ) + + return (Invoke-Native -FilePath 'gh' -ArgumentList @('release', 'view', $Tag, '--json', 'tagName')).ExitCode -eq 0 +} diff --git a/.github/release-flow/release-notes.ps1 b/.github/release-flow/release-notes.ps1 new file mode 100644 index 00000000..1e10582a --- /dev/null +++ b/.github/release-flow/release-notes.ps1 @@ -0,0 +1,52 @@ +function Format-TestReleaseNotes { + param( + [AllowNull()][AllowEmptyString()] + [string]$Generated, + [Parameter(Mandatory)] + [string]$Sha, + [Parameter(Mandatory)] + [string]$ServerUrl, + [Parameter(Mandatory)] + [string]$Repository + ) + + $short = $Sha.Substring(0, [Math]::Min(7, $Sha.Length)) + $banner = "Test build, not published to the Visual Studio Marketplace. Commit: [$short]($ServerUrl/$Repository/commit/$Sha)" + return "$banner`n`n$Generated" +} + +function Get-ReleaseNotesBody { + param( + [Parameter(Mandatory)] + [string]$Kind, + [AllowNull()][AllowEmptyString()] + [string]$Generated, + [string]$Sha, + [string]$ServerUrl, + [string]$Repository + ) + + if ($Kind -ceq 'stable') { return $Generated } + if ($Kind -ceq 'test') { + return Format-TestReleaseNotes -Generated $Generated -Sha $Sha -ServerUrl $ServerUrl -Repository $Repository + } + throw "Unknown tag kind '$Kind'. Expected stable or test." +} + +function Write-ReleaseNotesFile { + param( + [Parameter(Mandatory)] + [string]$Tag, + [AllowNull()][AllowEmptyString()] + [string]$Body + ) + + $directory = $env:RUNNER_TEMP + if ([string]::IsNullOrWhiteSpace($directory)) { + $directory = [IO.Path]::GetTempPath() + } + $path = Join-Path $directory "release-notes-$Tag.md" + $encoding = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($path, "$Body", $encoding) + return $path +} diff --git a/.github/release-flow/release-selection.ps1 b/.github/release-flow/release-selection.ps1 new file mode 100644 index 00000000..35ee88a2 --- /dev/null +++ b/.github/release-flow/release-selection.ps1 @@ -0,0 +1,64 @@ +function Get-ReleaseTagName($Release) { + if ($Release -is [string]) { return $Release } + return $Release.tag_name +} + +function Select-PublishedStableRelease { + param( + [AllowNull()][AllowEmptyCollection()] + $Releases, + [AllowNull()][AllowEmptyString()] + [string]$ExcludeTag + ) + + foreach ($release in @($Releases | Where-Object { $null -ne $_ })) { + if ($release.draft -or $release.prerelease) { continue } + if ($ExcludeTag -and $release.tag_name -ceq $ExcludeTag) { continue } + try { + $info = Get-ReleaseTagInfo -Tag $release.tag_name + } + catch { + continue + } + if ($info.Kind -ceq 'stable') { $release } + } +} + +function Sort-ReleaseByVersionDescending { + param( + [AllowNull()][AllowEmptyCollection()] + $Releases + ) + + $keyed = foreach ($release in @($Releases)) { + $parts = Get-VersionComponents (Get-ReleaseTagInfo -Tag $release.tag_name).Version + [pscustomobject]@{ Major = $parts[0]; Minor = $parts[1]; Patch = $parts[2]; Release = $release } + } + @($keyed) | Sort-Object -Property Major, Minor, Patch -Descending | ForEach-Object { $_.Release } +} + +function Select-HighestStableRelease { + param( + [AllowNull()][AllowEmptyCollection()] + $Releases + ) + + $published = @(Select-PublishedStableRelease -Releases $Releases) + if ($published.Count -eq 0) { return $null } + return @(Sort-ReleaseByVersionDescending -Releases $published)[0] +} + +function Select-PreviousStableRelease { + param( + [AllowNull()][AllowEmptyCollection()] + $Releases, + [Parameter(Mandatory)] + [scriptblock]$IsAncestor + ) + + $published = @(Select-PublishedStableRelease -Releases $Releases) + foreach ($release in @(Sort-ReleaseByVersionDescending -Releases $published)) { + if (& $IsAncestor $release.tag_name) { return $release } + } + return $null +} diff --git a/.github/release-flow/tag-admission.ps1 b/.github/release-flow/tag-admission.ps1 new file mode 100644 index 00000000..68f0215d --- /dev/null +++ b/.github/release-flow/tag-admission.ps1 @@ -0,0 +1,74 @@ +function New-TagAdmissionFacts { + param( + [Parameter(Mandatory)] + [bool]$OnMain, + [AllowNull()] + $Highest, + [AllowNull()] + $HighestIsAncestor, + [AllowNull()] + $Previous, + [AllowNull()][AllowEmptyString()] + [string]$TaggedCommit + ) + + return [pscustomobject]@{ + OnMain = $OnMain + Highest = $Highest + HighestIsAncestor = $HighestIsAncestor + Previous = $Previous + TaggedCommit = $TaggedCommit + } +} + +function Assert-TagOnMain($TagInfo, [bool]$OnMain) { + if ($TagInfo.Kind -ceq 'stable' -and -not $OnMain) { + throw "Tag $($TagInfo.Tag) is not on main. Stable releases must be tagged on a commit reachable from main." + } +} + +function Assert-VersionExceedsRelease($TagInfo, $Release, [string]$Shipped) { + $releaseTag = Get-ReleaseTagName $Release + $releaseVersion = (Get-ReleaseTagInfo -Tag $releaseTag).Version + if ((Compare-ReleaseVersion -Left $TagInfo.Version -Right $releaseVersion) -le 0) { + throw "Version $($TagInfo.Version) of tag $($TagInfo.Tag) does not exceed $Shipped ($releaseTag)." + } +} + +function Assert-FreshTagAdmissible($TagInfo, $Facts) { + if ($null -eq $Facts.Highest) { return } + Assert-VersionExceedsRelease $TagInfo $Facts.Highest 'the release already shipped' + if ($null -ne $Facts.HighestIsAncestor -and -not $Facts.HighestIsAncestor) { + $highestTag = Get-ReleaseTagName $Facts.Highest + throw "Tag $($TagInfo.Tag) predates the newest release ${highestTag}: that release is not an ancestor of the tagged commit." + } +} + +function Assert-RecoveryTagAdmissible($TagInfo, $Facts) { + if ($null -eq $Facts.Previous) { return } + Assert-VersionExceedsRelease $TagInfo $Facts.Previous 'the release already shipped before it' +} + +function Assert-TestShaMatchesCommit($TagInfo, [string]$TaggedCommit) { + if ($TagInfo.Kind -cne 'test') { return } + if (-not $TaggedCommit -or -not $TaggedCommit.StartsWith($TagInfo.Sha, [StringComparison]::Ordinal)) { + throw "Sha mismatch: tag $($TagInfo.Tag) names sha $($TagInfo.Sha) but the tagged commit is '$TaggedCommit'." + } +} + +function Assert-TagAdmissible { + param( + [Parameter(Mandatory)] + $TagInfo, + [Parameter(Mandatory)] + [ValidateSet('fresh', 'recovery')] + [string]$Mode, + [Parameter(Mandatory)] + $Facts + ) + + Assert-TagOnMain $TagInfo $Facts.OnMain + if ($Mode -ceq 'fresh') { Assert-FreshTagAdmissible $TagInfo $Facts } + if ($Mode -ceq 'recovery') { Assert-RecoveryTagAdmissible $TagInfo $Facts } + Assert-TestShaMatchesCommit $TagInfo $Facts.TaggedCommit +} diff --git a/.github/release-flow/test-gate.ps1 b/.github/release-flow/test-gate.ps1 new file mode 100644 index 00000000..540bf90f --- /dev/null +++ b/.github/release-flow/test-gate.ps1 @@ -0,0 +1,63 @@ +function Get-TestGateVerdict { + param( + [AllowNull()][AllowEmptyCollection()] + $Runs, + [Parameter(Mandatory)] + [string]$Sha + ) + + $all = @($Runs | Where-Object { $null -ne $_ }) + $guidance = "The tag must be on a tested PR merge commit, not the bot commit. Wait for or fix the 'Run tests' run for commit $Sha." + + $passed = @($all | Where-Object { $_.status -ceq 'completed' -and $_.conclusion -ceq 'success' }) + if ($passed.Count -gt 0) { + return [pscustomobject]@{ + Pass = $true + Reason = 'pass' + Message = "Tests passed for commit $Sha in 'Run tests': $($passed[0].html_url)" + } + } + + $running = @($all | Where-Object { $_.status -cne 'completed' }) + if ($running.Count -gt 0) { + return [pscustomobject]@{ + Pass = $false + Reason = 'in-progress' + Message = "The 'Run tests' run for commit $Sha is not finished: tests still running; dispatch again when done." + } + } + + if ($all.Count -gt 0) { + $conclusions = ($all | ForEach-Object { "$($_.conclusion)" }) -join ', ' + return [pscustomobject]@{ + Pass = $false + Reason = 'failed' + Message = "No successful 'Run tests' run for commit $Sha. Conclusions: $conclusions. $guidance" + } + } + + return [pscustomobject]@{ + Pass = $false + Reason = 'absent' + Message = "No 'Run tests' run found for commit $Sha. $guidance" + } +} + +function Get-TestRun { + param( + [Parameter(Mandatory)] + [string]$Repository, + [Parameter(Mandatory)] + [string]$Sha, + [scriptblock]$Invoker = { param($FilePath, $ArgumentList) Invoke-Native -FilePath $FilePath -ArgumentList $ArgumentList } + ) + + $path = "repos/$Repository/actions/workflows/test.yml/runs?head_sha=$Sha&per_page=100" + $result = & $Invoker 'gh' @('api', $path) + if ($result.ExitCode -ne 0) { + if ($result.Output -match 'HTTP 404') { return @() } + throw "gh api $path failed with exit code $($result.ExitCode): $($result.Output)" + } + $runs = @(Get-OptionalProperty ($result.Output | ConvertFrom-Json) 'workflow_runs') + return @($runs | Where-Object { $null -ne $_ }) +} diff --git a/.github/release-flow/version-files.ps1 b/.github/release-flow/version-files.ps1 new file mode 100644 index 00000000..5546c20b --- /dev/null +++ b/.github/release-flow/version-files.ps1 @@ -0,0 +1,64 @@ +function Get-VersionFilePaths { + param( + [Parameter(Mandatory)] + [string]$RepositoryPath + ) + + $directory = Join-Path $RepositoryPath 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022' + return [pscustomobject]@{ + Manifest = Join-Path $directory 'source.extension.vsixmanifest' + Source = Join-Path $directory 'source.extension.cs' + } +} + +function Get-IdentityVersion([xml]$Document, [string]$Origin) { + $node = $Document.SelectSingleNode("//*[local-name()='Identity']/@Version") + if ($null -eq $node -or [string]::IsNullOrWhiteSpace($node.Value)) { + throw "No Identity version found in $Origin." + } + return $node.Value +} + +function Get-ReleaseVersion { + param( + [Parameter(Mandatory)] + [string]$RepositoryPath + ) + + $manifest = (Get-VersionFilePaths -RepositoryPath $RepositoryPath).Manifest + $document = New-Object System.Xml.XmlDocument + $document.Load($manifest) + return Get-IdentityVersion $document $manifest +} + +function Edit-VersionText { + param( + [string]$Path, + [string]$Pattern, + [string]$Version + ) + + $encoding = New-Object System.Text.UTF8Encoding($false) + $text = $encoding.GetString([IO.File]::ReadAllBytes($Path)) + $regex = New-Object System.Text.RegularExpressions.Regex($Pattern, [System.Text.RegularExpressions.RegexOptions]::CultureInvariant) + $count = $regex.Matches($text).Count + if ($count -ne 1) { + throw "Version file ${Path}: expected exactly one version, found $count." + } + $updated = $regex.Replace($text, [System.Text.RegularExpressions.MatchEvaluator] { param($match) $match.Groups[1].Value + $Version + $match.Groups[2].Value }) + [IO.File]::WriteAllBytes($Path, $encoding.GetBytes($updated)) +} + +function Set-ReleaseVersion { + param( + [Parameter(Mandatory)] + [string]$RepositoryPath, + [Parameter(Mandatory)] + [string]$Version + ) + + Get-VersionComponents $Version | Out-Null + $paths = Get-VersionFilePaths -RepositoryPath $RepositoryPath + Edit-VersionText -Path $paths.Manifest -Pattern '(]*?\sVersion=")[^"]*(")' -Version $Version + Edit-VersionText -Path $paths.Source -Pattern '(public const string Version = ")[^"]*(";)' -Version $Version +} diff --git a/.github/release-flow/version-record.ps1 b/.github/release-flow/version-record.ps1 new file mode 100644 index 00000000..d29f682c --- /dev/null +++ b/.github/release-flow/version-record.ps1 @@ -0,0 +1,77 @@ +function Test-VersionRecordNeeded { + param( + [Parameter(Mandatory)] + [string]$MainVersion, + [Parameter(Mandatory)] + [string]$Version + ) + + return (Compare-ReleaseVersion -Left $Version -Right $MainVersion) -gt 0 +} + +function Assert-OnlyVersionFilesChanged { + param( + [AllowNull()][AllowEmptyCollection()][AllowEmptyString()] + [string[]]$StatusLines + ) + + $expected = @( + 'Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs', + 'Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest' + ) + $lines = @($StatusLines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $changed = @($lines | ForEach-Object { $_.TrimEnd("`r").Substring(3).Replace('\', '/') } | Sort-Object -Unique) + if (($changed -join '|') -cne ($expected -join '|')) { + $reported = if ($changed.Count -eq 0) { 'no changes' } else { $changed -join ', ' } + throw "Expected exactly the two version files to change but git status reports: $reported." + } +} + +function Test-PushRejected { + param( + [AllowNull()][AllowEmptyString()] + [string]$Output + ) + + return [bool]("$Output" -match 'rejected|non-fast-forward|fetch first') +} + +function Invoke-VersionRecord { + param( + [Parameter(Mandatory)] + [string]$RepositoryPath, + [Parameter(Mandatory)] + [string]$Version, + [ValidateRange(1, 100)] + [int]$Attempts = 5 + ) + + Get-VersionComponents $Version | Out-Null + $identity = @('-c', 'user.name=github-actions[bot]', '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com') + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('-C', $RepositoryPath, 'fetch', 'origin', 'main') | Out-Null + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('-C', $RepositoryPath, 'checkout', '-B', 'main', 'origin/main') | Out-Null + + $mainVersion = Get-ReleaseVersion -RepositoryPath $RepositoryPath + if (-not (Test-VersionRecordNeeded -MainVersion $mainVersion -Version $Version)) { + Write-Host "main already at $mainVersion" + return [pscustomobject]@{ Changed = $false } + } + + Set-ReleaseVersion -RepositoryPath $RepositoryPath -Version $Version + $status = Invoke-NativeChecked -FilePath 'git' -ArgumentList @('-C', $RepositoryPath, 'status', '--porcelain', '--untracked-files=all') + Assert-OnlyVersionFilesChanged -StatusLines @($status.Output -split "`n") + + Invoke-NativeChecked -FilePath 'git' -ArgumentList (@('-C', $RepositoryPath) + $identity + @('commit', '-am', "chore(release): set version to $Version")) | Out-Null + $push = Invoke-Native -FilePath 'git' -ArgumentList @('-C', $RepositoryPath, 'push', 'origin', 'HEAD:refs/heads/main') + if ($push.ExitCode -eq 0) { + Write-Host "Recorded version $Version on main (attempt $attempt)" + return [pscustomobject]@{ Changed = $true; Attempt = $attempt } + } + if (-not (Test-PushRejected -Output $push.Output)) { + throw "git push origin HEAD:refs/heads/main failed with exit code $($push.ExitCode): $($push.Output)" + } + Write-Host "Push of version $Version was rejected on attempt $attempt of ${Attempts}: $($push.Output)" + } + throw "Could not record version $Version on main after $Attempts attempts" +} diff --git a/.github/release-flow/version-tags.ps1 b/.github/release-flow/version-tags.ps1 new file mode 100644 index 00000000..e0025130 --- /dev/null +++ b/.github/release-flow/version-tags.ps1 @@ -0,0 +1,102 @@ +$script:MaxVersionComponent = 65534 +$script:TagPattern = '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-test\.([0-9a-f]{7,40}))?\z' +$script:VersionPattern = '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z' + +function Test-VersionComponent([string]$Component) { + return $Component.Length -le 5 -and [int]$Component -le $script:MaxVersionComponent +} + +function Get-VersionComponents([string]$Version) { + if ($Version -cnotmatch $script:VersionPattern) { + throw "Malformed version '$Version'. Expected X.Y.Z with numeric components." + } + $components = @($Matches[1], $Matches[2], $Matches[3]) + foreach ($component in $components) { + if (-not (Test-VersionComponent $component)) { + throw "Malformed version '$Version'. Components must be at most $($script:MaxVersionComponent)." + } + } + return @($components | ForEach-Object { [int]$_ }) +} + +function Get-ReleaseTagInfo { + param( + [Parameter(Mandatory)] + [string]$Tag + ) + + $malformed = "Malformed tag '$Tag'. Expected vX.Y.Z (stable) or vX.Y.Z-test.<7-40 lowercase hex sha> (test), with each component at most $($script:MaxVersionComponent)." + if ($Tag -cnotmatch $script:TagPattern) { + throw $malformed + } + $parts = @($Matches[1], $Matches[2], $Matches[3]) + $sha = $null + $kind = 'stable' + if ($Matches[5]) { + $kind = 'test' + $sha = $Matches[5] + } + foreach ($part in $parts) { + if (-not (Test-VersionComponent $part)) { + throw $malformed + } + } + + return [pscustomobject]@{ + Tag = $Tag + Kind = $kind + Version = $parts -join '.' + Sha = $sha + } +} + +function Compare-ReleaseVersion { + param( + [Parameter(Mandatory)] + [string]$Left, + [Parameter(Mandatory)] + [string]$Right + ) + + $leftParts = Get-VersionComponents $Left + $rightParts = Get-VersionComponents $Right + for ($i = 0; $i -lt 3; $i++) { + if ($leftParts[$i] -lt $rightParts[$i]) { return -1 } + if ($leftParts[$i] -gt $rightParts[$i]) { return 1 } + } + return 0 +} + +function Get-NextReleaseVersion { + param( + [Parameter(Mandatory)] + [string]$Version, + [Parameter(Mandatory)] + [string]$Bump + ) + + $parts = Get-VersionComponents $Version + switch -CaseSensitive ($Bump) { + 'patch' { $next = @($parts[0], $parts[1], ($parts[2] + 1)) } + 'minor' { $next = @($parts[0], ($parts[1] + 1), 0) } + 'major' { $next = @(($parts[0] + 1), 0, 0) } + default { throw "Invalid bump '$Bump'. Expected patch, minor or major." } + } + + $result = $next -join '.' + Get-VersionComponents $result | Out-Null + return $result +} + +function Get-TestReleaseTagName { + param( + [Parameter(Mandatory)] + [string]$Version, + [Parameter(Mandatory)] + [string]$ShortSha + ) + + $tag = "v$Version-test.$ShortSha" + Get-ReleaseTagInfo -Tag $tag | Out-Null + return $tag +} diff --git a/.github/release-flow/vsix.ps1 b/.github/release-flow/vsix.ps1 new file mode 100644 index 00000000..bc3de866 --- /dev/null +++ b/.github/release-flow/vsix.ps1 @@ -0,0 +1,42 @@ +function Get-VsixManifestVersion { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + $entry = $archive.GetEntry('extension.vsixmanifest') + if ($null -eq $entry) { + throw "$Path does not contain extension.vsixmanifest." + } + $stream = $entry.Open() + try { + $document = New-Object System.Xml.XmlDocument + $document.Load($stream) + } + finally { + $stream.Dispose() + } + return Get-IdentityVersion $document "$Path extension.vsixmanifest" + } + finally { + $archive.Dispose() + } +} + +function Assert-VsixVersion { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [string]$Version + ) + + $actual = Get-VsixManifestVersion -Path $Path + if ($actual -cne $Version) { + throw "VSIX version mismatch: $Path embeds version $actual but expected $Version." + } +} diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..ba541be6 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,35 @@ +changelog: + exclude: + labels: + - ignore-for-release + - skip-changelog + authors: + - dependabot + - github-actions + categories: + - title: Breaking Changes + labels: + - breaking-change + - breaking + - title: Features + labels: + - feature + - enhancement + - title: Bug Fixes + labels: + - bug + - fix + - defect + - title: Security + labels: + - security + - title: Documentation + labels: + - documentation + - docs + - title: Dependencies + labels: + - dependencies + - title: Other Changes + labels: + - "*" diff --git a/.github/test-release-flow.ps1 b/.github/test-release-flow.ps1 new file mode 100644 index 00000000..e587db01 --- /dev/null +++ b/.github/test-release-flow.ps1 @@ -0,0 +1,102 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'release-flow.psm1') -Force + +$script:GitHubDirectory = $PSScriptRoot +$script:RepositoryRoot = Split-Path -Parent $PSScriptRoot +$script:Passed = 0 +$script:TempPaths = @() +$script:LocalTags = @() + +$script:TestFiles = @( + 'harness', 'tag-tests', 'selection-tests', 'admission-tests', + 'version-file-tests', 'release-decision-tests', 'process-tests', 'entry-script-tests', + 'metadata-tests', 'version-command-tests', 'github-command-tests', 'release-command-tests', + 'tag-script-tests', 'marketplace-tests', 'version-record-tests', 'publish-command-tests', + 'changelog-tests', 'migration-script-tests' +) + +foreach ($testFile in $script:TestFiles) { + . (Join-Path $PSScriptRoot "test-release-flow\$testFile.ps1") +} + +try { + Test-ReleaseTagInfo + Test-CompareReleaseVersion + Test-NextReleaseVersion + Test-TestReleaseTagName + Test-ReleaseSelection + Test-ComparisonMode + Test-DispatchAllowed + Test-TagAdmissible + Test-VersionFilePaths + Test-ReleaseVersionFiles + Test-ReleaseVersionPreservesEncoding + Test-ReleaseVersionFailures + Test-VsixVerification + Test-FindSingleVsix + Test-BuiltVsixIntegration + Test-Utf8ConsoleEncoding + Test-InvokeNative + Test-WriteActionOutput + Test-GitHelpers + Test-EntryScriptUsage + Test-MetadataIntegration + Test-SetVersionIntegration + Test-VerifyVsixIntegration + Test-HelperIndependentOfTaggedTree + Test-AssetDownloadArguments + Test-GenerateNotesArguments + Test-LatestReleaseTag + Test-TestRunQuery + Test-ReleaseNotesFile + Test-CreateReleaseCommand + Test-UploadCommand + Test-DownloadAssetCommand + Test-TestGateCommand + Test-PromoteCommand + Test-GitHubReadIntegration + Test-TagTestReleaseScriptPrerequisites + Test-TagTestReleaseScriptReleases + Test-TagTestReleaseScriptTagging + Test-TagTestReleaseScriptIntegration + Test-TestReleaseNotes + Test-ReleaseNotesConfig + Test-ReleaseCommandArguments + Test-MissingAssets + Test-TestGateVerdict + Test-PromotionAction + Test-VersionRecordNeededDecision + Test-PublisherVersionExistsDecision + Test-GalleryHasVersionDecision + Test-MarketplaceOverview + Test-GalleryRequest + Test-MarketplaceVersionListedSender + Test-OnlyVersionFilesChanged + Test-MarketplacePublishDecisions + Test-VersionRecordIntegration + Test-VersionRecordSyncsWithOrigin + Test-VersionRecordStrayFile + Test-VersionRecordRetry + Test-RecordVersionCommand + Test-WriteOverviewCommand + Test-WriteOverviewOnArchive + Test-PublishCommand + Test-MarketplaceReadIntegration + Test-ChangelogParsing + Test-ChangelogSectionTag + Test-ChangelogBlock + Test-ChangelogMigrationPlan + Test-ReadFileFromHistory + Test-MigrationScriptArguments + Test-MigrationScriptWithFakeGh + Test-MigrationIntegration + Write-Host "$($script:Passed) assertions passed" +} +finally { + Remove-LocalTags + foreach ($path in $script:TempPaths) { + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/.github/test-release-flow/admission-tests.ps1 b/.github/test-release-flow/admission-tests.ps1 new file mode 100644 index 00000000..919d8784 --- /dev/null +++ b/.github/test-release-flow/admission-tests.ps1 @@ -0,0 +1,111 @@ +function Assert-DispatchPasses([string]$EventName, [string]$RunRef, [bool]$TagExists, [bool]$DryRun, [string]$Kind, [string]$Name) { + Assert-DispatchAllowed -EventName $EventName -RunRef $RunRef -TagExists $TagExists -DryRun $DryRun -Kind $Kind + $script:Passed++ +} + +function Test-DispatchAllowed { + $main = 'refs/heads/main' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef 'refs/heads/other' -TagExists $true -DryRun $false -Kind 'stable' } -MessagePattern 'must be dispatched from main' -Name 'dispatch from another branch throws' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef 'refs/tags/v0.9.1' -TagExists $true -DryRun $false -Kind 'stable' } -MessagePattern 'must be dispatched from main' -Name 'dispatch from a tag ref throws' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef '' -TagExists $true -DryRun $false -Kind 'stable' } -MessagePattern 'must be dispatched from main' -Name 'dispatch with an empty ref throws' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef 'refs/heads/main2' -TagExists $true -DryRun $false -Kind 'stable' } -MessagePattern 'must be dispatched from main' -Name 'dispatch from a lookalike branch throws' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef $main -TagExists $false -DryRun $false -Kind 'stable' } -MessagePattern 'does not exist' -Name 'dispatch with a missing tag throws' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef $main -TagExists $true -DryRun $true -Kind 'test' } -MessagePattern 'Dry run needs a stable tag' -Name 'dry run on a test tag throws' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef 'refs/heads/other' -TagExists $false -DryRun $true -Kind 'test' } -MessagePattern 'must be dispatched from main' -Name 'the ref is checked before the tag and dry run' + Assert-Throws -Action { Assert-DispatchAllowed -EventName 'workflow_dispatch' -RunRef $main -TagExists $false -DryRun $true -Kind 'test' } -MessagePattern 'does not exist' -Name 'the tag is checked before the dry run' + Assert-DispatchPasses 'workflow_dispatch' $main $true $true 'stable' 'dry run on a stable tag passes' + Assert-DispatchPasses 'workflow_dispatch' $main $true $false 'stable' 'real stable dispatch passes' + Assert-DispatchPasses 'workflow_dispatch' $main $true $false 'test' 'real test dispatch passes' + Assert-DispatchPasses 'push' 'refs/tags/v0.9.2' $false $true 'test' 'push event skips every dispatch check' +} + +function Get-AdmissionFacts([hashtable]$Facts) { + $values = @{ OnMain = $true; Highest = $null; HighestIsAncestor = $null; Previous = $null; TaggedCommit = $null } + foreach ($key in $Facts.Keys) { $values[$key] = $Facts[$key] } + return New-TagAdmissionFacts @values +} + +function Assert-AdmissiblePasses($TagInfo, [string]$Mode, [hashtable]$Facts, [string]$Name) { + Assert-TagAdmissible -TagInfo $TagInfo -Mode $Mode -Facts (Get-AdmissionFacts $Facts) + $script:Passed++ +} + +function Assert-AdmissibleThrows($TagInfo, [string]$Mode, [hashtable]$Facts, [string]$Pattern, [string]$Name) { + $admission = Get-AdmissionFacts $Facts + Assert-Throws -Action { Assert-TagAdmissible -TagInfo $TagInfo -Mode $Mode -Facts $admission }.GetNewClosure() -MessagePattern $Pattern -Name $Name +} + +function Test-AdmissionFacts { + $facts = New-TagAdmissionFacts -OnMain $true -Highest (New-Release 'v0.9.1') -HighestIsAncestor $true -Previous $null -TaggedCommit 'abc' + Assert-Equal -Expected 'OnMain,Highest,HighestIsAncestor,Previous,TaggedCommit' -Actual ($facts.PSObject.Properties.Name -join ',') -Name 'admission facts have the documented shape' + Assert-True -Condition ($facts.OnMain -eq $true) -Name 'admission facts keep OnMain' + Assert-Equal -Expected 'v0.9.1' -Actual $facts.Highest.tag_name -Name 'admission facts keep the highest release' + Assert-True -Condition ($null -eq $facts.Previous) -Name 'admission facts keep a null previous release' + Assert-Equal -Expected 'abc' -Actual $facts.TaggedCommit -Name 'admission facts keep the tagged commit' + $bare = New-TagAdmissionFacts -OnMain $false -Highest $null -HighestIsAncestor $null -Previous $null -TaggedCommit $null + Assert-True -Condition ($bare.OnMain -eq $false -and $null -eq $bare.Highest -and $null -eq $bare.HighestIsAncestor -and $null -eq $bare.Previous) -Name 'admission facts accept every release argument as null' +} + +function Test-FreshAdmission { + $stable = Get-ReleaseTagInfo -Tag 'v0.9.2' + $v091 = Get-ReleaseTagInfo -Tag 'v0.9.1' + $v090 = Get-ReleaseTagInfo -Tag 'v0.9.0' + $v093 = Get-ReleaseTagInfo -Tag 'v0.9.3' + $exceed = 'does not exceed the release already shipped' + + Assert-AdmissiblePasses $stable 'fresh' @{ Highest = (New-Release 'v0.9.1'); HighestIsAncestor = $true; Previous = (New-Release 'v0.9.1'); TaggedCommit = '015c6a0ffffffffffffffffffffffffffffffff0' } 'fresh above an ancestor highest passes' + Assert-AdmissiblePasses $stable 'fresh' @{} 'fresh without any release passes' + Assert-AdmissiblePasses $stable 'fresh' @{ Highest = (New-Release 'v0.9.1') } 'fresh with an unknown ancestry passes' + Assert-AdmissiblePasses $stable 'fresh' @{ Highest = 'v0.9.1'; HighestIsAncestor = $true } 'fresh accepts a highest given as a tag name' + Assert-AdmissiblePasses (Get-ReleaseTagInfo -Tag 'v0.10.0') 'fresh' @{ Highest = (New-Release 'v0.9.9'); HighestIsAncestor = $true } 'fresh compares versions numerically' + + Assert-AdmissibleThrows $v091 'fresh' @{ Highest = (New-Release 'v0.9.1'); HighestIsAncestor = $true } $exceed 'fresh equal to highest throws' + Assert-AdmissibleThrows $v090 'fresh' @{ Highest = (New-Release 'v0.9.1'); HighestIsAncestor = $true } $exceed 'fresh below highest throws' + Assert-AdmissibleThrows $v093 'fresh' @{ Highest = (New-Release 'v0.9.2'); HighestIsAncestor = $false } 'predates the newest release' 'fresh whose highest release is not an ancestor throws' + Assert-AdmissibleThrows $v091 'fresh' @{ Highest = (New-Release 'v0.9.1'); HighestIsAncestor = $false } 'does not exceed' 'the version check precedes the ancestry check' + Assert-AdmissibleThrows $v093 'fresh' @{ OnMain = $false; Highest = (New-Release 'v0.9.2'); HighestIsAncestor = $false } 'not on main' 'the main check precedes the ancestry check' +} + +function Test-RecoveryAdmission { + $v090 = Get-ReleaseTagInfo -Tag 'v0.9.0' + $exceed = 'does not exceed the release already shipped' + + Assert-AdmissiblePasses $v090 'recovery' @{ Highest = (New-Release 'v0.9.1'); HighestIsAncestor = $false; Previous = (New-Release 'v0.8.7') } 'recovery passes even though a higher release exists' + Assert-AdmissiblePasses $v090 'recovery' @{} 'recovery without any release passes' + Assert-AdmissiblePasses $v090 'recovery' @{ Previous = 'v0.8.7' } 'recovery accepts a previous given as a tag name' + Assert-AdmissibleThrows $v090 'recovery' @{ Previous = (New-Release 'v0.9.0') } $exceed 'recovery equal to previous throws' + Assert-AdmissibleThrows $v090 'recovery' @{ Previous = (New-Release 'v0.9.1') } $exceed 'recovery below previous throws' + Assert-AdmissibleThrows $v090 'recovery' @{ Previous = (New-Release 'v0.9.1') } 'shipped before it \(v0\.9\.1\)' 'the recovery message names the previous release' + Assert-AdmissibleThrows $v090 'recovery' @{ Highest = (New-Release 'v0.9.1'); HighestIsAncestor = $true; Previous = (New-Release 'v0.9.0') } '\(v0\.9\.0\)' 'recovery compares against previous, not highest' +} + +function Test-TestTagAdmission { + $sha = '015c6a0' + $fullSha = '015c6a0ffffffffffffffffffffffffffffffff0' + $test = Get-ReleaseTagInfo -Tag "v0.9.1-test.$sha" + + Assert-AdmissibleThrows $test 'recovery' @{ OnMain = $false; Highest = (New-Release 'v0.9.1'); HighestIsAncestor = $true; Previous = (New-Release 'v0.9.1'); TaggedCommit = $fullSha } 'does not exceed' 'test base equal to previous throws' + Assert-AdmissiblePasses $test 'recovery' @{ OnMain = $false; TaggedCommit = $fullSha } 'a test tag with no previous passes off main' + Assert-AdmissiblePasses $test 'recovery' @{ OnMain = $false; Previous = (New-Release 'v0.9.0'); TaggedCommit = $fullSha } 'a test tag above previous passes' + Assert-AdmissiblePasses $test 'recovery' @{ OnMain = $false; Previous = 'v0.9.0'; TaggedCommit = $sha } 'a test sha equal to the tagged commit passes' + Assert-AdmissibleThrows $test 'recovery' @{ TaggedCommit = 'ffffffffffffffffffffffffffffffffffffffff' } 'Sha mismatch' 'test sha mismatch throws' + Assert-AdmissibleThrows $test 'recovery' @{ TaggedCommit = 'ab015c6a0ffffffffffffffffffffffffffffff0' } 'Sha mismatch' 'test sha must be a prefix, not a substring' + Assert-AdmissibleThrows $test 'recovery' @{ TaggedCommit = $null } 'Sha mismatch' 'test tag without a tagged commit fails closed' + Assert-AdmissibleThrows $test 'recovery' @{ TaggedCommit = '' } 'Sha mismatch' 'test tag with an empty tagged commit fails closed' + Assert-AdmissibleThrows $test 'recovery' @{ Previous = (New-Release 'v0.9.1'); TaggedCommit = 'ffffffffffffffffffffffffffffffffffffffff' } 'does not exceed' 'the version check precedes the sha check' +} + +function Test-TagAdmissible { + $stable = Get-ReleaseTagInfo -Tag 'v0.9.2' + $v090 = Get-ReleaseTagInfo -Tag 'v0.9.0' + $test = Get-ReleaseTagInfo -Tag 'v0.9.1-test.015c6a0' + + Test-AdmissionFacts + Test-FreshAdmission + Test-RecoveryAdmission + Test-TestTagAdmission + Assert-AdmissibleThrows $stable 'fresh' @{ OnMain = $false } 'not on main' 'stable tag not on main throws' + Assert-AdmissibleThrows $v090 'recovery' @{ OnMain = $false } 'not on main' 'recovery of a stable tag not on main throws' + Assert-AdmissiblePasses $stable 'fresh' @{ OnMain = $true } 'a stable tag on main passes' + Assert-AdmissibleThrows $test 'bogus' @{ TaggedCommit = '015c6a0ffffffffffffffffffffffffffffffff0' } 'bogus' 'unknown mode is rejected' +} diff --git a/.github/test-release-flow/changelog-tests.ps1 b/.github/test-release-flow/changelog-tests.ps1 new file mode 100644 index 00000000..94752f65 --- /dev/null +++ b/.github/test-release-flow/changelog-tests.ps1 @@ -0,0 +1,192 @@ +$script:ChangelogFixture = @' +# Changelog + +Text before the first section is ignored. + +## [Unreleased] + +## [0.9.1] - 2026-10-28 +### Fixed +- keep WebView2 visible (#341) + +## [0.9.0] +### Added +- run cs-ide as a persistent server +### Changed +- bump the CLI + +## [0.8.7] + +## [0.8.1] - 2026-09-07 +### Fixed +- a draft only fix + +## Notes +Plain notes +'@ + +$script:ChangelogFixture = $script:ChangelogFixture.Replace("`r`n", "`n") + +function Get-ChangelogSectionBody($Sections, [string]$Key) { + return @($Sections | Where-Object { $_.Key -ceq $Key })[0].Body +} + +function Test-ChangelogParsing { + $sections = @(ConvertFrom-Changelog -Text $script:ChangelogFixture) + Assert-Equal -Expected 'Unreleased,0.9.1,0.9.0,0.8.7,0.8.1,Notes' -Actual (($sections | ForEach-Object { $_.Key }) -join ',') -Name 'changelog section keys come from the headings in order' + Assert-Equal -Expected '' -Actual (Get-ChangelogSectionBody $sections 'Unreleased') -Name 'an empty section has an empty body' + Assert-Equal -Expected "### Fixed`n- keep WebView2 visible (#341)" -Actual (Get-ChangelogSectionBody $sections '0.9.1') -Name 'a dated section keeps its subsections and loses surrounding blank lines' + Assert-Equal -Expected "### Added`n- run cs-ide as a persistent server`n### Changed`n- bump the CLI" -Actual (Get-ChangelogSectionBody $sections '0.9.0') -Name 'an undated section keeps all its subsections' + Assert-Equal -Expected '' -Actual (Get-ChangelogSectionBody $sections '0.8.7') -Name 'an empty versioned section has an empty body' + Assert-Equal -Expected 'Plain notes' -Actual (Get-ChangelogSectionBody $sections 'Notes') -Name 'a plain heading uses the raw heading as its key' + Assert-True -Condition (-not (($sections | ForEach-Object { $_.Body }) -join "`n").Contains('Text before')) -Name 'text before the first section is ignored' + + $crlf = @(ConvertFrom-Changelog -Text ($script:ChangelogFixture -replace "`n", "`r`n")) + Assert-Equal -Expected (($sections | ForEach-Object { $_.Key + '=' + $_.Body }) -join '|') -Actual (($crlf | ForEach-Object { $_.Key + '=' + $_.Body }) -join '|') -Name 'a CRLF changelog parses like an LF one' + + Assert-Equal -Expected 0 -Actual @(ConvertFrom-Changelog -Text '').Count -Name 'an empty changelog has no sections' + Assert-Equal -Expected 0 -Actual @(ConvertFrom-Changelog -Text "# Title`nno sections here").Count -Name 'a changelog without sections has none' + Assert-Equal -Expected 0 -Actual @(ConvertFrom-Changelog -Text "### Added`n- not a section").Count -Name 'a third-level heading is not a section' + Assert-Equal -Expected 'Notes' -Actual (@(ConvertFrom-Changelog -Text '## Notes')[0].Key) -Name 'a heading on the last line without a newline is a section' + Assert-Equal -Expected '[1.0.0]x' -Actual (@(ConvertFrom-Changelog -Text "## [1.0.0]x`nbody")[0].Key) -Name 'a bracket heading with trailing text is a raw key' + + Assert-Throws -Action { ConvertFrom-Changelog -Text "## [0.9.1]`n- a`n## [0.9.1] - 2026-10-28`n- b" } -MessagePattern "Duplicate changelog section '0\.9\.1'" -Name 'a duplicate version key throws' + Assert-Throws -Action { ConvertFrom-Changelog -Text "## Notes`n## Notes" } -MessagePattern "Duplicate changelog section 'Notes'" -Name 'a duplicate raw key throws' +} + +function Test-ChangelogSectionTag { + Assert-Equal -Expected 'v0.9.1' -Actual (Get-ChangelogSectionTag -Key '0.9.1') -Name '0.9.1 maps to v0.9.1' + Assert-Equal -Expected 'v10.20.30' -Actual (Get-ChangelogSectionTag -Key '10.20.30') -Name '10.20.30 maps to v10.20.30' + foreach ($key in @('Unreleased', 'Notes', '0.9', '1.2.3.4', '01.2.3', 'v0.9.1', '0.9.1-test', ' 0.9.1', '')) { + Assert-True -Condition ($null -eq (Get-ChangelogSectionTag -Key $key)) -Name "section '$key' matches no tag" + } +} + +function Get-ExpectedChangelogBlock([string]$Section) { + return "`n## Changelog`n`n$Section`n`n" +} + +function Test-ChangelogBlock { + $section = "### Fixed`n- a fix" + $block = Get-ExpectedChangelogBlock $section + Assert-Equal -Expected $block -Actual (Set-ChangelogBlock -Body '' -Section $section) -Name 'an empty body becomes the block' + Assert-Equal -Expected $block -Actual (Set-ChangelogBlock -Body $null -Section $section) -Name 'a null body becomes the block' + Assert-Equal -Expected $block -Actual (Set-ChangelogBlock -Body " `n" -Section $section) -Name 'a blank body becomes the block' + + $automated = Set-ChangelogBlock -Body 'Automated build for v0.9.1' -Section $section + Assert-Equal -Expected "Automated build for v0.9.1`n`n$block" -Actual $automated -Name 'the block follows a blank line after existing text' + Assert-Equal -Expected "Automated build for v0.9.1`n`n$block" -Actual (Set-ChangelogBlock -Body "Automated build for v0.9.1`n`n`n" -Section $section) -Name 'trailing blank lines before the block collapse to one blank line' + + Assert-Equal -Expected $automated -Actual (Set-ChangelogBlock -Body $automated -Section $section) -Name 'applying the same section twice is idempotent' + $replaced = Set-ChangelogBlock -Body $automated -Section "### Changed`n- other" + Assert-Equal -Expected "Automated build for v0.9.1`n`n$(Get-ExpectedChangelogBlock "### Changed`n- other")" -Actual $replaced -Name 'an existing block is replaced by the new one' + Assert-Equal -Expected 1 -Actual ([regex]::Matches($replaced, 'changelog-migration:start')).Count -Name 'a replaced block is not duplicated' + Assert-Equal -Expected $block -Actual (Set-ChangelogBlock -Body $block -Section $section) -Name 'a body that is only the block stays the block' + + $crlf = Set-ChangelogBlock -Body "Automated build`r`n`r`nmore text`r`n" -Section "### Fixed`r`n- a fix" + Assert-Equal -Expected "Automated build`n`nmore text`n`n$block" -Actual $crlf -Name 'a CRLF body and section are normalised to LF' + Assert-Equal -Expected $crlf -Actual (Set-ChangelogBlock -Body ($crlf -replace "`n", "`r`n") -Section $section) -Name 'a CRLF copy of a migrated body is recognised and rewritten identically' + + $surrounded = Set-ChangelogBlock -Body "Intro`n`n$block`nAfter" -Section $section + Assert-Equal -Expected "Intro`n`n`nAfter`n`n$block" -Actual $surrounded -Name 'text around an existing block is kept and the block moves to the end' + Assert-Equal -Expected $surrounded -Actual (Set-ChangelogBlock -Body $surrounded -Section $section) -Name 'a moved block is stable on the next application' +} + +function Get-FixtureRelease([string]$Tag, [string]$Body, [bool]$Draft, [bool]$Prerelease, [int]$Id) { + return [pscustomobject]@{ tag_name = $Tag; draft = $Draft; prerelease = $Prerelease; id = $Id; body = $Body } +} + +function Get-MigrationFixtureReleases([string]$Published090Body) { + return @( + (Get-FixtureRelease 'v0.9.1' 'Automated build for v0.9.1' $false $false 101), + (Get-FixtureRelease 'v0.8.1' 'Automated build for v0.8.1' $true $false 81), + (Get-FixtureRelease 'v0.9.0-test.abc1234' 'Test build' $false $true 90), + (Get-FixtureRelease 'v0.9.0' $Published090Body $false $false 100), + (Get-FixtureRelease 'v0.8.7' 'Automated build for v0.8.7' $false $false 87) + ) +} + +function Test-ChangelogMigrationPlan { + $sections = @(ConvertFrom-Changelog -Text $script:ChangelogFixture) + $section090 = Get-ChangelogSectionBody $sections '0.9.0' + $section091 = Get-ChangelogSectionBody $sections '0.9.1' + $identical = Set-ChangelogBlock -Body 'Automated build for v0.9.0' -Section $section090 + $plan = Get-ChangelogMigrationPlan -Sections $sections -Releases (Get-MigrationFixtureReleases $identical) + + Assert-Equal -Expected 'v0.9.1' -Actual (@($plan.Updates | ForEach-Object { $_.Tag }) -join ',') -Name 'only the release without the block is updated' + Assert-Equal -Expected 101 -Actual $plan.Updates[0].Id -Name 'the update carries the release id' + Assert-Equal -Expected (Set-ChangelogBlock -Body 'Automated build for v0.9.1' -Section $section091) -Actual $plan.Updates[0].Body -Name 'the update carries the new body' + Assert-Equal -Expected (Get-ExpectedChangelogBlock $section091) -Actual $plan.Updates[0].Block -Name 'the update carries the block for display' + Assert-Equal -Expected 'v0.9.0' -Actual ($plan.UpToDate -join ',') -Name 'a release already carrying the identical block is up to date' + Assert-Equal -Expected '0.8.7' -Actual ($plan.Empty -join ',') -Name 'an empty section of a published release is reported as empty' + Assert-Equal -Expected 'Unreleased,0.8.1,Notes' -Actual ($plan.Unmatched -join ',') -Name 'sections without a published stable release are unmatched, including the draft' + + $crlfPlan = Get-ChangelogMigrationPlan -Sections $sections -Releases (Get-MigrationFixtureReleases ($identical -replace "`n", "`r`n")) + Assert-Equal -Expected 'v0.9.0' -Actual ($crlfPlan.UpToDate -join ',') -Name 'a CRLF current body compares equal after normalisation' + Assert-Equal -Expected 'v0.9.1' -Actual (@($crlfPlan.Updates | ForEach-Object { $_.Tag }) -join ',') -Name 'a CRLF current body adds no updates' + + $stale = Get-ChangelogMigrationPlan -Sections $sections -Releases (Get-MigrationFixtureReleases (Set-ChangelogBlock -Body 'Automated build for v0.9.0' -Section 'old text')) + Assert-Equal -Expected 'v0.9.1,v0.9.0' -Actual (@($stale.Updates | ForEach-Object { $_.Tag }) -join ',') -Name 'a release with a different block is updated' + Assert-Equal -Expected 0 -Actual $stale.UpToDate.Count -Name 'nothing is up to date when the block differs' + + $blank = Get-ChangelogMigrationPlan -Sections $sections -Releases (Get-MigrationFixtureReleases '') + Assert-Equal -Expected (Get-ExpectedChangelogBlock $section090) -Actual @($blank.Updates | Where-Object { $_.Tag -ceq 'v0.9.0' })[0].Body -Name 'a release with an empty body gets only the block' + + $unpublished = Get-ChangelogMigrationPlan -Sections $sections -Releases @((Get-FixtureRelease 'v0.9.1' '' $false $false 1)) + Assert-Equal -Expected 'Unreleased,0.9.0,0.8.7,0.8.1,Notes' -Actual ($unpublished.Unmatched -join ',') -Name 'an empty section without a release is unmatched, not empty' + Assert-Equal -Expected 0 -Actual $unpublished.Empty.Count -Name 'unmatched is checked before empty' + + $none = Get-ChangelogMigrationPlan -Sections @() -Releases @() + Assert-Equal -Expected '0,0,0,0' -Actual (@($none.Updates.Count, $none.UpToDate.Count, $none.Empty.Count, $none.Unmatched.Count) -join ',') -Name 'no sections and no releases give an empty plan' + $nullInputs = Get-ChangelogMigrationPlan -Sections $null -Releases $null + Assert-Equal -Expected 0 -Actual $nullInputs.Updates.Count -Name 'null inputs give an empty plan' +} + +function Invoke-HistoryGit([string]$Directory, [string[]]$Arguments) { + return (Invoke-NativeChecked -FilePath 'git' -ArgumentList (@('-C', $Directory, '-c', 'user.name=release-flow-test', '-c', 'user.email=release-flow-test@example.com') + $Arguments)).Output +} + +function New-HistoryRepository { + $root = New-TempDirectory + Invoke-HistoryGit $root @('init', '--quiet', '--initial-branch=main') | Out-Null + return $root +} + +function Test-ReadFileFromHistory { + $hint = 'history-fixture\.md deletion not found in git history; pass --file ' + $repository = New-HistoryRepository + $first = "# Fixture`n`n## [0.1.0]`n- caf`u{00E9} first version" + Write-AllText (Join-Path $repository 'history-fixture.md') "$first`n" + Invoke-HistoryGit $repository @('add', 'history-fixture.md') | Out-Null + Invoke-HistoryGit $repository @('commit', '--quiet', '-m', 'add fixture') | Out-Null + Write-AllText (Join-Path $repository 'history-fixture.md') "# Fixture`n`nedited`n" + Invoke-HistoryGit $repository @('commit', '--quiet', '-am', 'edit fixture') | Out-Null + Assert-Throws -Action { Read-FileFromHistory -RepositoryPath $repository -Path 'history-fixture.md' } -MessagePattern $hint -Name 'a file that still exists has no deletion to read' + + Invoke-HistoryGit $repository @('rm', '--quiet', 'history-fixture.md') | Out-Null + Invoke-HistoryGit $repository @('commit', '--quiet', '-m', 'delete fixture') | Out-Null + Assert-Equal -Expected "# Fixture`n`nedited" -Actual (Read-FileFromHistory -RepositoryPath $repository -Path 'history-fixture.md') -Name 'the content before the deletion commit is returned' + Assert-Throws -Action { Read-FileFromHistory -RepositoryPath $repository -Path 'never-existed.md' } -MessagePattern 'never-existed\.md deletion not found in git history; pass --file ' -Name 'a file that never existed throws the hint' + + $other = New-HistoryRepository + Write-AllText (Join-Path $other 'other.md') 'x' + Invoke-HistoryGit $other @('add', 'other.md') | Out-Null + Invoke-HistoryGit $other @('commit', '--quiet', '-m', 'only an addition') | Out-Null + Assert-Throws -Action { Read-FileFromHistory -RepositoryPath $other -Path 'history-fixture.md' } -MessagePattern $hint -Name 'a repository without a deletion throws the hint' + + $unicode = New-HistoryRepository + Write-AllText (Join-Path $unicode 'history-fixture.md') "$first`n" + Invoke-HistoryGit $unicode @('add', 'history-fixture.md') | Out-Null + Invoke-HistoryGit $unicode @('commit', '--quiet', '-m', 'add fixture') | Out-Null + Invoke-HistoryGit $unicode @('rm', '--quiet', 'history-fixture.md') | Out-Null + Invoke-HistoryGit $unicode @('commit', '--quiet', '-m', 'delete fixture') | Out-Null + Assert-Equal -Expected $first -Actual (Read-FileFromHistory -RepositoryPath $unicode -Path 'history-fixture.md') -Name 'the content from history is the first commit and keeps non-ASCII text' + + Push-Location -LiteralPath $unicode + try { + Assert-Equal -Expected $first -Actual (Read-FileFromHistory -Path 'history-fixture.md') -Name 'the repository defaults to the current directory' + } + finally { + Pop-Location + } +} diff --git a/.github/test-release-flow/entry-script-tests.ps1 b/.github/test-release-flow/entry-script-tests.ps1 new file mode 100644 index 00000000..7c967374 --- /dev/null +++ b/.github/test-release-flow/entry-script-tests.ps1 @@ -0,0 +1,79 @@ +function Get-EntryScriptPath { + return Join-Path $script:GitHubDirectory 'release-flow.ps1' +} + +function Invoke-EntryScript { + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Command, + [hashtable]$Environment = @{}, + [string]$WorkingDirectory = $script:RepositoryRoot, + [string]$ScriptPath = (Get-EntryScriptPath), + [string[]]$ScriptArguments = @() + ) + + $names = @('TAG', 'EVENT', 'RUN_REF', 'DRY_RUN', 'VERSION', 'REPO_PATH', 'GITHUB_OUTPUT', 'GITHUB_REPOSITORY', 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_REPO', 'KIND', 'NOTES_START', 'SHA', 'ASSET_DIR', 'GITHUB_SERVER_URL', 'RUNNER_TEMP', 'FAKE_GH_LOG', 'FAKE_RELEASE_EXISTS', 'FAKE_ASSETS', 'FAKE_LATEST', 'FAKE_RUNS', 'VSIX_PUBLISHER', 'VSIX_DIR', 'PUBLISH_MANIFEST', 'FAKE_PUBLISH_LOG', 'FAKE_PUBLISH_EXIT', 'FAKE_PUBLISH_OUTPUT') + $restored = @($names) + @($Environment.Keys | Where-Object { $names -cnotcontains $_ }) + $saved = @{} + foreach ($name in $restored) { $saved[$name] = [Environment]::GetEnvironmentVariable($name) } + Push-Location -LiteralPath $WorkingDirectory + try { + foreach ($name in $names) { [Environment]::SetEnvironmentVariable($name, $null) } + foreach ($name in $Environment.Keys) { [Environment]::SetEnvironmentVariable($name, [string]$Environment[$name]) } + $shell = (Get-Process -Id $PID).Path + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath) + if ($Command) { $arguments += $Command } + $arguments += @($ScriptArguments) + return Invoke-Native -FilePath $shell -ArgumentList $arguments + } + finally { + Pop-Location + foreach ($name in $restored) { [Environment]::SetEnvironmentVariable($name, $saved[$name]) } + } +} + +function Read-ActionOutput([string]$Path) { + $values = @{} + if (-not (Test-Path -LiteralPath $Path)) { return $values } + foreach ($line in @((Read-AllText $Path) -split "`n" | Where-Object { $_ })) { + $index = $line.IndexOf('=') + $values[$line.Substring(0, $index)] = $line.Substring($index + 1) + } + return $values +} + +function Assert-EntryScriptFails($Result, [string]$Pattern, [string]$Name) { + Assert-True -Condition ($Result.ExitCode -ne 0) -Name "$Name exits non-zero" + $text = ($Result.Output -replace "$([char]27)\[[0-9;]*m", '') -replace '\s+', ' ' + Assert-True -Condition ($text -match $Pattern) -Name "$Name reports '$Pattern'" +} + +function Test-EntryScriptUsage { + $unknown = Invoke-EntryScript -Command 'frobnicate' + Assert-EntryScriptFails $unknown "Unknown command 'frobnicate'.*metadata\|set-version\|verify-vsix" 'an unknown command' + + $empty = Invoke-EntryScript -Command '' + Assert-EntryScriptFails $empty 'Unknown command.*Usage' 'no command' + + $wrongCase = Invoke-EntryScript -Command 'Metadata' + Assert-EntryScriptFails $wrongCase 'Unknown command' 'a differently cased command' + + $noToken = Invoke-EntryScript -Command 'metadata' -Environment @{ TAG = 'v0.9.1'; EVENT = 'push' } + Assert-EntryScriptFails $noToken 'GH_TOKEN is required' 'metadata without a token' + + $noTag = Invoke-EntryScript -Command 'metadata' -Environment @{ GH_TOKEN = 'unused'; EVENT = 'push' } + Assert-EntryScriptFails $noTag 'TAG is required' 'metadata without a tag' + + $malformed = Invoke-EntryScript -Command 'metadata' -Environment @{ GH_TOKEN = 'unused'; EVENT = 'push'; TAG = 'v1.2' } + Assert-EntryScriptFails $malformed 'Malformed tag' 'metadata with a malformed tag' + + $noVersion = Invoke-EntryScript -Command 'set-version' + Assert-EntryScriptFails $noVersion 'VERSION is required' 'set-version without a version' + + $noVerifyVersion = Invoke-EntryScript -Command 'verify-vsix' + Assert-EntryScriptFails $noVerifyVersion 'VERSION is required' 'verify-vsix without a version' + + foreach ($command in @('create-release', 'upload', 'download-asset', 'test-gate', 'promote')) { + $result = Invoke-EntryScript -Command $command + Assert-EntryScriptFails $result 'GH_TOKEN is required' "$command without a token" + } +} diff --git a/.github/test-release-flow/github-command-tests.ps1 b/.github/test-release-flow/github-command-tests.ps1 new file mode 100644 index 00000000..4c05e283 --- /dev/null +++ b/.github/test-release-flow/github-command-tests.ps1 @@ -0,0 +1,136 @@ +function Test-AssetDownloadArguments { + Assert-Equal -Expected 'release download v0.9.2 --pattern Codescene.VSExtension.VS2022.vsix --dir vsix --clobber' -Actual ((Get-AssetDownloadArguments -Tag 'v0.9.2' -Directory 'vsix') -join ' ') -Name 'the asset download arguments' + Assert-Equal -Expected 8 -Actual @(Get-AssetDownloadArguments -Tag 'v0.9.2' -Directory 'C:\a dir\vsix').Count -Name 'a directory with spaces stays one argument' +} + +function Test-GenerateNotesArguments { + Assert-Equal -Expected 'api -X POST repos/o/r/releases/generate-notes -f tag_name=v0.9.2 -f previous_tag_name=v0.9.1' -Actual ((Get-GenerateNotesArguments -Repository 'o/r' -Tag 'v0.9.2' -PreviousTag 'v0.9.1') -join ' ') -Name 'generate-notes arguments with a previous tag' + Assert-Equal -Expected 'api -X POST repos/o/r/releases/generate-notes -f tag_name=v0.9.2' -Actual ((Get-GenerateNotesArguments -Repository 'o/r' -Tag 'v0.9.2' -PreviousTag '') -join ' ') -Name 'generate-notes arguments omit an empty previous tag' + Assert-Equal -Expected 'api -X POST repos/o/r/releases/generate-notes -f tag_name=v0.9.2' -Actual ((Get-GenerateNotesArguments -Repository 'o/r' -Tag 'v0.9.2' -PreviousTag $null) -join ' ') -Name 'generate-notes arguments omit a null previous tag' +} + +function New-FakeInvoker([int]$ExitCode, [string]$Output) { + $calls = New-Object System.Collections.ArrayList + $script:InvokerCalls = $calls + return { + param($FilePath, $ArgumentList) + $calls.Add("$FilePath $(@($ArgumentList) -join ' ')") | Out-Null + [pscustomobject]@{ ExitCode = $ExitCode; Output = $Output } + }.GetNewClosure() +} + +function Test-LatestReleaseTag { + $found = Get-LatestReleaseTag -Repository 'o/r' -Invoker (New-FakeInvoker 0 '{"tag_name":"v0.9.1","draft":false}') + Assert-Equal -Expected 'v0.9.1' -Actual $found -Name 'the latest release tag is returned' + Assert-Equal -Expected 'gh api repos/o/r/releases/latest' -Actual ($script:InvokerCalls -join '|') -Name 'the latest release is read from the latest endpoint' + + $absent = Get-LatestReleaseTag -Repository 'o/r' -Invoker (New-FakeInvoker 1 "{`"message`":`"Not Found`"}`ngh: Not Found (HTTP 404)") + Assert-True -Condition ($null -eq $absent) -Name 'a 404 means no latest release' + + Assert-Throws -Action { Get-LatestReleaseTag -Repository 'o/r' -Invoker (New-FakeInvoker 1 'gh: Server Error (HTTP 500)') } -MessagePattern 'exit code 1.*HTTP 500' -Name 'another failure throws' + Assert-Throws -Action { Get-LatestReleaseTag -Repository 'o/r' -Invoker (New-FakeInvoker 4 'gh: To use GitHub CLI, run: gh auth login') } -MessagePattern 'exit code 4.*gh auth login' -Name 'an authentication failure throws' + Assert-Throws -Action { Get-LatestReleaseTag -Repository 'o/r' -Invoker (New-FakeInvoker 0 '{"name":"x"}') } -MessagePattern 'no tag name' -Name 'a response without a tag name throws' + + $mentions = Get-LatestReleaseTag -Repository 'o/r' -Invoker (New-FakeInvoker 0 '{"tag_name":"v0.9.1","body":"Fixes HTTP 404 handling"}') + Assert-Equal -Expected 'v0.9.1' -Actual $mentions -Name 'a successful response that mentions HTTP 404 still returns the tag' +} + +function Test-TestRunQuery { + $sha = '0123456789abcdef0123456789abcdef01234567' + $absent = @(Get-TestRun -Repository 'o/r' -Sha $sha -Invoker (New-FakeInvoker 1 "{`"message`":`"Not Found`"}`ngh: Not Found (HTTP 404)")) + Assert-Equal -Expected 0 -Actual $absent.Count -Name 'a 404 gives an empty run list' + Assert-Equal -Expected 'absent' -Actual (Get-TestGateVerdict -Runs $absent -Sha $sha).Reason -Name 'a 404 gives an absent verdict' + Assert-Equal -Expected "gh api repos/o/r/actions/workflows/test.yml/runs?head_sha=$sha&per_page=100" -Actual ($script:InvokerCalls -join '|') -Name 'the runs are queried by head sha' + + $payload = '{"total_count":2,"workflow_runs":[{"status":"completed","conclusion":"failure","event":"pull_request","html_url":"https://github.com/o/r/actions/runs/7"},{"status":"completed","conclusion":"success","event":"push","html_url":"https://github.com/o/r/actions/runs/42"}]}' + $runs = @(Get-TestRun -Repository 'o/r' -Sha $sha -Invoker (New-FakeInvoker 0 $payload)) + Assert-Equal -Expected 2 -Actual $runs.Count -Name 'the workflow runs are returned' + $verdict = Get-TestGateVerdict -Runs $runs -Sha $sha + Assert-Equal -Expected 'pass' -Actual $verdict.Reason -Name 'a success run gives a pass verdict' + Assert-True -Condition ($verdict.Message -clike '*actions/runs/42*') -Name 'the pass verdict links the successful run' + + $single = @(Get-TestRun -Repository 'o/r' -Sha $sha -Invoker (New-FakeInvoker 0 '{"workflow_runs":[{"status":"queued","conclusion":null,"html_url":"u"}]}')) + Assert-Equal -Expected 1 -Actual $single.Count -Name 'a single run is returned as one entry' + Assert-Equal -Expected 'in-progress' -Actual (Get-TestGateVerdict -Runs $single -Sha $sha).Reason -Name 'a queued run gives an in-progress verdict' + + $empty = @(Get-TestRun -Repository 'o/r' -Sha $sha -Invoker (New-FakeInvoker 0 '{"total_count":0,"workflow_runs":[]}')) + Assert-Equal -Expected 0 -Actual $empty.Count -Name 'an empty run list stays empty' + + Assert-Throws -Action { Get-TestRun -Repository 'o/r' -Sha $sha -Invoker (New-FakeInvoker 1 'gh: Bad credentials (HTTP 401)') } -MessagePattern 'exit code 1.*HTTP 401' -Name 'another failure throws instead of reading as absent' +} + +function Test-ReleaseNotesFile { + $saved = $env:RUNNER_TEMP + $directory = New-TempDirectory + $body = "## Changes`n- caf$([char]0xE9) $([char]0x2192) ok" + try { + $env:RUNNER_TEMP = $directory + $path = Write-ReleaseNotesFile -Tag 'v0.9.2' -Body $body + Assert-Equal -Expected (Join-Path $directory 'release-notes-v0.9.2.md') -Actual $path -Name 'the notes file is written to RUNNER_TEMP' + $bytes = [IO.File]::ReadAllBytes($path) + Assert-True -Condition (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) -Name 'the notes file has no BOM' + Assert-Equal -Expected $body -Actual (New-Object System.Text.UTF8Encoding($false)).GetString($bytes) -Name 'the notes file keeps the body as UTF-8' + + $empty = Write-ReleaseNotesFile -Tag 'v0.9.3' -Body '' + Assert-Equal -Expected 0 -Actual ([IO.File]::ReadAllBytes($empty)).Length -Name 'an empty body writes an empty file' + + $env:RUNNER_TEMP = $null + $fallback = Write-ReleaseNotesFile -Tag 'v0.9.4' -Body 'x' + try { + Assert-Equal -Expected ([IO.Path]::GetTempPath()) -Actual ((Split-Path -Parent $fallback) + [IO.Path]::DirectorySeparatorChar) -Name 'the notes file falls back to the temp directory' + } + finally { + Remove-Item -LiteralPath $fallback -Force -ErrorAction SilentlyContinue + } + } + finally { + $env:RUNNER_TEMP = $saved + } +} + +function Test-GitHubReadIntegration { + $token = Get-GitHubTokenForTests + if (-not $token) { + Write-Host 'skipping GitHub read integration: no GitHub token available' + return + } + $repository = 'codescene-oss/codescene-vs' + $latest = Get-LatestReleaseTag -Repository $repository + if ($latest -cne 'v0.9.1') { + Write-Host "skipping GitHub read integration: the latest release is '$latest', not v0.9.1" + return + } + Assert-Equal -Expected 'v0.9.1' -Actual $latest -Name 'the real latest release is v0.9.1' + Assert-True -Condition (Test-ReleaseExists -Tag 'v0.9.1') -Name 'the real release v0.9.1 exists' + Assert-True -Condition (-not (Test-ReleaseExists -Tag 'v9.9.9')) -Name 'a missing release does not exist' + Assert-Equal -Expected 'Codescene.VSExtension.VS2022.vsix' -Actual (@(Get-ReleaseAssetName -Tag 'v0.9.1') -join ',') -Name 'the real release carries the vsix asset' + + $notes = Get-GeneratedReleaseNotes -Repository $repository -Tag 'v0.9.1' -PreviousTag 'v0.9.0' + Assert-True -Condition ($notes.Length -gt 0) -Name 'generate-notes returns a body' + Assert-True -Condition ($notes -clike '*v0.9.0...v0.9.1*') -Name 'generate-notes compares against the previous tag' + $composed = Get-ReleaseNotesBody -Kind 'test' -Generated $notes -Sha '01f198db079248e461264ef79f94e82d8bd6faa8' -ServerUrl 'https://github.com' -Repository $repository + Assert-True -Condition ($composed.StartsWith('Test build, not published', [StringComparison]::Ordinal)) -Name 'the generated notes get the test banner' + Assert-Equal -Expected $notes -Actual (Get-ReleaseNotesBody -Kind 'stable' -Generated $notes -Sha '' -ServerUrl '' -Repository $repository) -Name 'the generated notes stay unchanged for a stable release' + + $sha = '01f198db079248e461264ef79f94e82d8bd6faa8' + $runs = @(Get-TestRun -Repository $repository -Sha $sha) + $gate = Invoke-EntryScript -Command 'test-gate' -Environment @{ GH_TOKEN = $token; SHA = $sha } + if ($runs.Count -eq 0) { + Assert-EntryScriptFails $gate "No 'Run tests' run found for commit $sha" 'test-gate before test.yml has run for the sha' + } + else { + $verdict = Get-TestGateVerdict -Runs $runs -Sha $sha + Assert-Equal -Expected $verdict.Pass -Actual ($gate.ExitCode -eq 0) -Name 'test-gate agrees with the verdict of the real runs' + } + + $directory = New-TempDirectory + $download = Invoke-EntryScript -Command 'download-asset' -Environment @{ GH_TOKEN = $token; TAG = 'v0.9.1'; ASSET_DIR = $directory } + Assert-Equal -Expected 0 -Actual $download.ExitCode -Name "download-asset of v0.9.1 exits zero ($($download.Output))" + Assert-Equal -Expected 'Codescene.VSExtension.VS2022.vsix' -Actual (@(Get-ChildItem -LiteralPath $directory -Force | ForEach-Object { $_.Name }) -join ',') -Name 'download-asset leaves exactly the vsix in the directory' + Assert-Equal -Expected '0.9.1' -Actual (Get-VsixManifestVersion -Path (Join-Path $directory 'Codescene.VSExtension.VS2022.vsix')) -Name 'the downloaded vsix embeds version 0.9.1' + $again = Invoke-EntryScript -Command 'download-asset' -Environment @{ GH_TOKEN = $token; TAG = 'v0.9.1'; ASSET_DIR = $directory } + Assert-Equal -Expected 0 -Actual $again.ExitCode -Name "download-asset overwrites an existing file ($($again.Output))" + + $missing = Invoke-EntryScript -Command 'download-asset' -Environment @{ GH_TOKEN = $token; TAG = 'v9.9.9'; ASSET_DIR = (New-TempDirectory) } + Assert-True -Condition ($missing.ExitCode -ne 0) -Name 'download-asset of a missing release fails' +} diff --git a/.github/test-release-flow/harness.ps1 b/.github/test-release-flow/harness.ps1 new file mode 100644 index 00000000..e81d7a74 --- /dev/null +++ b/.github/test-release-flow/harness.ps1 @@ -0,0 +1,63 @@ +function Format-Value($Value) { + if ($null -eq $Value) { return '$null' } + return "'$Value'" +} + +function Fail-Assertion([string]$Name, [string]$Expected, [string]$Actual) { + [Console]::Error.WriteLine("FAILED: $Name") + [Console]::Error.WriteLine(" expected: $Expected") + [Console]::Error.WriteLine(" actual: $Actual") + exit 1 +} + +function Assert-Equal { + param( + [Parameter(Mandatory)][AllowNull()]$Expected, + [Parameter(Mandatory)][AllowNull()]$Actual, + [Parameter(Mandatory)][string]$Name + ) + + if (-not ($Expected -ceq $Actual)) { + Fail-Assertion $Name (Format-Value $Expected) (Format-Value $Actual) + } + $script:Passed++ +} + +function Assert-True { + param( + [Parameter(Mandatory)][AllowNull()]$Condition, + [Parameter(Mandatory)][string]$Name + ) + + if ($Condition -ne $true) { + Fail-Assertion $Name 'True' (Format-Value $Condition) + } + $script:Passed++ +} + +function Assert-Throws { + param( + [Parameter(Mandatory)][scriptblock]$Action, + [Parameter(Mandatory)][string]$MessagePattern, + [Parameter(Mandatory)][string]$Name + ) + + try { + & $Action | Out-Null + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + Fail-Assertion $Name "error matching '$MessagePattern'" "'$($_.Exception.Message)'" + } + $script:Passed++ + return + } + Fail-Assertion $Name "error matching '$MessagePattern'" 'no error' +} + +function New-TempDirectory { + $path = Join-Path ([IO.Path]::GetTempPath()) ('release-flow-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $path | Out-Null + $script:TempPaths += $path + return $path +} diff --git a/.github/test-release-flow/marketplace-tests.ps1 b/.github/test-release-flow/marketplace-tests.ps1 new file mode 100644 index 00000000..c62399ff --- /dev/null +++ b/.github/test-release-flow/marketplace-tests.ps1 @@ -0,0 +1,119 @@ +function Test-GalleryRequest { + $request = Get-GalleryRequest -Version '0.9.1' + Assert-Equal -Expected 'Uri,Headers,Body' -Actual ($request.PSObject.Properties.Name -join ',') -Name 'the gallery request has the documented shape' + Assert-Equal -Expected 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' -Actual $request.Uri -Name 'the gallery request uri' + Assert-Equal -Expected 'application/json;api-version=3.0-preview.1' -Actual $request.Headers['Accept'] -Name 'the gallery request accept header' + Assert-Equal -Expected 'application/json' -Actual $request.Headers['Content-Type'] -Name 'the gallery request content type header' + Assert-Equal -Expected 2 -Actual @($request.Headers.Keys).Count -Name 'the gallery request carries exactly two headers' + $body = $request.Body | ConvertFrom-Json + Assert-Equal -Expected 1 -Actual $body.flags -Name 'the gallery request flags value' + Assert-Equal -Expected 1 -Actual @($body.filters).Count -Name 'the gallery request has one filter' + Assert-Equal -Expected 1 -Actual @($body.filters[0].criteria).Count -Name 'the gallery request has one criterion' + Assert-Equal -Expected 7 -Actual $body.filters[0].criteria[0].filterType -Name 'the gallery request filter type' + Assert-Equal -Expected 'CodeScene.codescene-vs' -Actual $body.filters[0].criteria[0].value -Name 'the gallery request criteria value is publisher-qualified' + Assert-Equal -Expected $request.Body -Actual (Get-GalleryRequest -Version '99.0.0').Body -Name 'the gallery request body does not depend on the version' +} + +function Test-MarketplaceVersionListedSender { + $listed = ConvertFrom-Json '{"results":[{"extensions":[{"versions":[{"version":"0.9.1"}]}]}]}' + $sender = { param($Request) $script:Sent = $Request; return $listed } + Assert-Equal -Expected $true -Actual (Test-MarketplaceVersionListed -Version '0.9.1' -Sender $sender) -Name 'a version present in the response is listed' + Assert-Equal -Expected (Get-GalleryRequest -Version '0.9.1').Body -Actual $script:Sent.Body -Name 'the sender receives the gallery request body' + Assert-Equal -Expected (Get-GalleryRequest -Version '0.9.1').Uri -Actual $script:Sent.Uri -Name 'the sender receives the gallery request uri' + Assert-Equal -Expected $false -Actual (Test-MarketplaceVersionListed -Version '0.9.2' -Sender $sender) -Name 'a version absent from the response is not listed' + $failing = { param($Request) throw 'connection refused' } + Assert-Throws -Action { Test-MarketplaceVersionListed -Version '0.9.1' -Sender $failing } -MessagePattern 'Could not query the Marketplace for version 0\.9\.1.*connection refused' -Name 'a failed gallery query throws' +} + +function Test-OnlyVersionFilesChanged { + $manifest = 'Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest' + $source = 'Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs' + Assert-OnlyVersionFilesChanged -StatusLines @(" M $manifest", " M $source") + $script:Passed++ + Assert-OnlyVersionFilesChanged -StatusLines @(" M $source", " M $manifest") + $script:Passed++ + Assert-OnlyVersionFilesChanged -StatusLines @("M $manifest", "MM $source") + $script:Passed++ + Assert-OnlyVersionFilesChanged -StatusLines @(" M $manifest`r", " M $source`r", '') + $script:Passed++ + Assert-OnlyVersionFilesChanged -StatusLines @(" M $($manifest.Replace('/', '\'))", " M $($source.Replace('/', '\'))") + $script:Passed++ + Assert-Throws -Action { Assert-OnlyVersionFilesChanged -StatusLines @(" M $manifest", " M $source", ' M README.md') } -MessagePattern 'README\.md' -Name 'a third changed path is rejected' + Assert-Throws -Action { Assert-OnlyVersionFilesChanged -StatusLines @(" M $manifest", " M $source", '?? stray.txt') } -MessagePattern 'stray\.txt' -Name 'an untracked path is rejected' + Assert-Throws -Action { Assert-OnlyVersionFilesChanged -StatusLines @(" M $manifest") } -MessagePattern 'exactly the two version files' -Name 'only the manifest changed is rejected' + Assert-Throws -Action { Assert-OnlyVersionFilesChanged -StatusLines @(" M $source") } -MessagePattern 'exactly the two version files' -Name 'only the source changed is rejected' + Assert-Throws -Action { Assert-OnlyVersionFilesChanged -StatusLines @() } -MessagePattern 'no changes' -Name 'an empty status is rejected' + Assert-Throws -Action { Assert-OnlyVersionFilesChanged -StatusLines @('', ' ') } -MessagePattern 'no changes' -Name 'a blank status is rejected' + Assert-Throws -Action { Assert-OnlyVersionFilesChanged -StatusLines @(" M $manifest", " M $manifest", " M $source", " M other/$source") } -MessagePattern 'other/' -Name 'a path that merely ends like a version file is rejected' +} + +function New-FakePublisher([int]$ExitCode, [string]$Output) { + $state = [pscustomobject]@{ Calls = @() } + $block = { param($FilePath, $ArgumentList) $state.Calls += , (@($FilePath) + @($ArgumentList)); return [pscustomobject]@{ ExitCode = $ExitCode; Output = $Output } }.GetNewClosure() + return [pscustomobject]@{ Block = $block; State = $state } +} + +function New-PublishTree { + $root = New-TempDirectory + $manifest = Join-Path $root 'publishManifest.json' + Write-AllText $manifest '{}' + $vsix = New-VsixFile $root 'Codescene.VSExtension.VS2022.vsix' '0.9.2' + return [pscustomobject]@{ Root = $root; Manifest = $manifest; Vsix = $vsix } +} + +function Test-MarketplacePublishDecisions { + $tree = New-PublishTree + $listed = { param($Version) $true } + $unlisted = { param($Version) $false } + + $skipped = New-FakePublisher 0 'published' + $outcome = Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'VsixPublisher.exe' -VsixDirectory $tree.Root -PublishManifest $tree.Manifest -Publisher $skipped.Block -IsListed $listed + Assert-Equal -Expected 'listed' -Actual $outcome -Name 'a listed version is skipped' + Assert-Equal -Expected 0 -Actual $skipped.State.Calls.Count -Name 'a listed version never calls the publisher' + + $published = New-FakePublisher 0 'Published ok' + $outcome = Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'VsixPublisher.exe' -VsixDirectory $tree.Root -PublishManifest $tree.Manifest -Publisher $published.Block -IsListed $unlisted + Assert-Equal -Expected 'published' -Actual $outcome -Name 'exit 0 means published' + Assert-Equal -Expected 1 -Actual $published.State.Calls.Count -Name 'the publisher is called once' + Assert-Equal -Expected "VsixPublisher.exe|publish|-payload|$($tree.Vsix)|-publishManifest|$($tree.Manifest)" -Actual ($published.State.Calls[0] -join '|') -Name 'the publisher is called with the payload and manifest' + + $exists = New-FakePublisher 1 'Error: Extension version already exists' + $outcome = Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'VsixPublisher.exe' -VsixDirectory $tree.Root -PublishManifest $tree.Manifest -Publisher $exists.Block -IsListed $unlisted + Assert-Equal -Expected 'exists' -Actual $outcome -Name 'exit 1 with already exists is skipped' + + $failed = New-FakePublisher 1 'Error: unauthorized' + Assert-Throws -Action { Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'VsixPublisher.exe' -VsixDirectory $tree.Root -PublishManifest $tree.Manifest -Publisher $failed.Block -IsListed $unlisted }.GetNewClosure() -MessagePattern 'exit code 1.*unauthorized' -Name 'exit 1 with other text throws the output' + + $unlistedProbe = New-FakePublisher 0 '' + Assert-Throws -Action { Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'VsixPublisher.exe' -VsixDirectory $tree.Root -PublishManifest (Join-Path $tree.Root 'missing.json') -Publisher $unlistedProbe.Block -IsListed $unlisted }.GetNewClosure() -MessagePattern 'publishManifest\.json not found' -Name 'a missing manifest throws' + Assert-Equal -Expected 0 -Actual $unlistedProbe.State.Calls.Count -Name 'a missing manifest never calls the publisher' + + $listCheck = { param($Version) $script:ProbedVersion = $Version; $false } + $quiet = New-FakePublisher 0 '' + Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'p' -VsixDirectory $tree.Root -PublishManifest $tree.Manifest -Publisher $quiet.Block -IsListed $listCheck | Out-Null + Assert-Equal -Expected '0.9.2' -Actual $script:ProbedVersion -Name 'the gallery check receives the version' + + $empty = New-TempDirectory + Assert-Throws -Action { Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'p' -VsixDirectory $empty -PublishManifest $tree.Manifest -Publisher $unlistedProbe.Block -IsListed $unlisted }.GetNewClosure() -MessagePattern 'No \.vsix file found' -Name 'a missing vsix throws' + New-VsixFile $tree.Root 'second.vsix' '0.9.2' | Out-Null + Assert-Throws -Action { Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'p' -VsixDirectory $tree.Root -PublishManifest $tree.Manifest -Publisher $unlistedProbe.Block -IsListed $unlisted }.GetNewClosure() -MessagePattern 'exactly one \.vsix' -Name 'two vsix files throw' + Assert-Equal -Expected 0 -Actual $unlistedProbe.State.Calls.Count -Name 'an invalid vsix directory never calls the publisher' + + $failingCheck = { param($Version) throw 'Could not query the Marketplace' } + $guarded = New-FakePublisher 0 '' + $single = New-PublishTree + Assert-Throws -Action { Invoke-MarketplacePublish -Version '0.9.2' -PublisherPath 'p' -VsixDirectory $single.Root -PublishManifest $single.Manifest -Publisher $guarded.Block -IsListed $failingCheck }.GetNewClosure() -MessagePattern 'Could not query the Marketplace' -Name 'a failed gallery check fails the publish' + Assert-Equal -Expected 0 -Actual $guarded.State.Calls.Count -Name 'a failed gallery check never calls the publisher' +} + +function Test-MarketplaceReadIntegration { + try { + $listed = Test-MarketplaceVersionListed -Version '0.9.1' + } + catch { + Write-Host "skipping Marketplace read integration: $($_.Exception.Message)" + return + } + Assert-Equal -Expected $true -Actual $listed -Name 'the Marketplace lists version 0.9.1' + Assert-Equal -Expected $false -Actual (Test-MarketplaceVersionListed -Version '99.0.0') -Name 'the Marketplace does not list version 99.0.0' +} diff --git a/.github/test-release-flow/metadata-tests.ps1 b/.github/test-release-flow/metadata-tests.ps1 new file mode 100644 index 00000000..819effc1 --- /dev/null +++ b/.github/test-release-flow/metadata-tests.ps1 @@ -0,0 +1,159 @@ +function Get-GitHubTokenForTests { + foreach ($candidate in @($env:GH_TOKEN, $env:GITHUB_TOKEN)) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { return $candidate } + } + try { + $result = Invoke-Native -FilePath 'gh' -ArgumentList @('auth', 'token') + } + catch { + return $null + } + if ($result.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($result.Output)) { return $null } + return $result.Output.Trim() +} + +function Get-RefProblem([string]$Ref, [string]$Label, [string]$ExpectedPrefix = '') { + $commit = Get-TagCommit -Tag $Ref + if (-not $commit) { return "$Label is not available locally" } + if ($ExpectedPrefix -and -not $commit.StartsWith($ExpectedPrefix, [StringComparison]::Ordinal)) { return "$Label does not point at $ExpectedPrefix" } + return $null +} + +function Test-MetadataPrerequisites { + $problems = @( + (Get-RefProblem 'v0.9.1' 'tag v0.9.1' '01f198db'), + (Get-RefProblem 'v0.9.0-test.015c6a0' 'tag v0.9.0-test.015c6a0'), + (Get-RefProblem '01f198db' 'commit 01f198db'), + (Get-RefProblem '7f9f1296' 'commit 7f9f1296'), + (Get-RefProblem 'origin/main' 'origin/main') + ) + return $problems | Where-Object { $_ } | Select-Object -First 1 +} + +function Invoke-MetadataCommand([hashtable]$Environment, [string]$Token) { + $output = Join-Path (New-TempDirectory) 'output.txt' + $variables = @{ GH_TOKEN = $Token; GITHUB_OUTPUT = $output } + foreach ($name in $Environment.Keys) { $variables[$name] = $Environment[$name] } + $result = Invoke-EntryScript -Command 'metadata' -Environment $variables + return [pscustomobject]@{ Result = $result; Outputs = (Read-ActionOutput $output) } +} + +function Assert-MetadataFails($Run, [string]$Pattern, [string]$Name) { + Assert-EntryScriptFails $Run.Result $Pattern $Name + Assert-Equal -Expected 0 -Actual $Run.Outputs.Count -Name "$Name writes no outputs" +} + +function Assert-MetadataPasses($Run, [hashtable]$Expected, [string]$Name) { + Assert-Equal -Expected 0 -Actual $Run.Result.ExitCode -Name "$Name exits zero ($($Run.Result.Output))" + foreach ($key in $Expected.Keys) { + Assert-Equal -Expected $Expected[$key] -Actual $Run.Outputs[$key] -Name "$Name output $key" + } + Assert-Equal -Expected 6 -Actual $Run.Outputs.Count -Name "$Name writes all six outputs" +} + +function Add-LocalTag([string]$Tag, [string]$Commit) { + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('tag', $Tag, $Commit) | Out-Null + $script:LocalTags += $Tag +} + +function Remove-LocalTags { + foreach ($tag in $script:LocalTags) { + Invoke-Native -FilePath 'git' -ArgumentList @('tag', '-d', $tag) | Out-Null + } + $script:LocalTags = @() +} + +function Test-MetadataDispatch([string]$Token) { + $main = @{ TAG = 'v0.9.1'; EVENT = 'workflow_dispatch'; RUN_REF = 'refs/heads/main' } + $recovery = Invoke-MetadataCommand $main $Token + Assert-MetadataPasses $recovery @{ tag = 'v0.9.1'; version = '0.9.1'; kind = 'stable'; notes_start = 'v0.9.0'; sha = '01f198db079248e461264ef79f94e82d8bd6faa8'; dry_run = 'false' } 'recovery dispatch of v0.9.1' + Assert-True -Condition ($recovery.Result.Output -clike '*Comparison mode: recovery*') -Name 'a dispatch of a released tag logs the recovery mode' + + $dry = Invoke-MetadataCommand ($main + @{ DRY_RUN = 'true' }) $Token + Assert-MetadataPasses $dry @{ version = '0.9.1'; dry_run = 'true' } 'dry run dispatch of v0.9.1' + + $notTrue = Invoke-MetadataCommand ($main + @{ DRY_RUN = 'True' }) $Token + Assert-MetadataPasses $notTrue @{ dry_run = 'false' } 'a DRY_RUN other than true' + + $other = Invoke-MetadataCommand @{ TAG = 'v0.9.1'; EVENT = 'workflow_dispatch'; RUN_REF = 'refs/heads/other' } $Token + Assert-MetadataFails $other 'must be dispatched from main' 'a dispatch from another branch' + + $missing = Invoke-MetadataCommand @{ TAG = 'v9.9.9'; EVENT = 'workflow_dispatch'; RUN_REF = 'refs/heads/main' } $Token + Assert-MetadataFails $missing 'does not exist' 'a dispatch of a missing tag' + + $dryTest = Invoke-MetadataCommand @{ TAG = 'v0.9.0-test.015c6a0'; EVENT = 'workflow_dispatch'; RUN_REF = 'refs/heads/main'; DRY_RUN = 'true' } $Token + Assert-MetadataFails $dryTest 'Dry run needs a stable tag' 'a dry run of a test tag' + + $unsupported = Invoke-MetadataCommand @{ TAG = 'v0.9.1'; EVENT = 'pull_request'; RUN_REF = 'refs/heads/main' } $Token + Assert-MetadataFails $unsupported 'Unsupported event' 'an unsupported event' +} + +function Test-MetadataFreshTags([string]$Token) { + $highest = Select-HighestStableRelease -Releases @(Get-GitHubRelease -Repository 'codescene-oss/codescene-vs') + if ($null -eq $highest -or $highest.tag_name -cne 'v0.9.1') { + Write-Host 'skipping fresh tag checks: v0.9.1 is no longer the newest stable release' + return + } + $headSha = (Invoke-NativeChecked -FilePath 'git' -ArgumentList @('rev-parse', 'HEAD')).Output + $headShort = $headSha.Substring(0, 7) + $descendsFromRelease = Test-GitAncestor -Ancestor 'v0.9.1' -Descendant 'HEAD' + $tags = @('v0.9.2', 'v0.9.3', "v0.9.2-test.$headShort", 'v0.9.2-test.abcdef1') + foreach ($tag in $tags) { + if (Get-TagCommit -Tag $tag) { + Write-Host "skipping fresh tag checks: tag $tag already exists locally" + return + } + } + + try { + Add-LocalTag 'v0.9.2' '01f198db' + Add-LocalTag 'v0.9.3' '7f9f1296' + $fresh = Invoke-MetadataCommand @{ TAG = 'v0.9.2'; EVENT = 'push'; RUN_REF = 'refs/tags/v0.9.2' } $Token + Assert-MetadataPasses $fresh @{ tag = 'v0.9.2'; version = '0.9.2'; kind = 'stable'; notes_start = 'v0.9.1'; sha = '01f198db079248e461264ef79f94e82d8bd6faa8'; dry_run = 'false' } 'a fresh stable tag' + Assert-True -Condition ($fresh.Result.Output -clike '*Comparison mode: fresh*') -Name 'a pushed stable tag logs the fresh mode' + + $predates = Invoke-MetadataCommand @{ TAG = 'v0.9.3'; EVENT = 'push'; RUN_REF = 'refs/tags/v0.9.3' } $Token + Assert-MetadataFails $predates 'predates the newest release' 'a stable tag older than the newest release' + + if ($descendsFromRelease) { + Add-LocalTag "v0.9.2-test.$headShort" 'HEAD' + Add-LocalTag 'v0.9.2-test.abcdef1' 'HEAD' + $test = Invoke-MetadataCommand @{ TAG = "v0.9.2-test.$headShort"; EVENT = 'push'; RUN_REF = "refs/tags/v0.9.2-test.$headShort" } $Token + Assert-MetadataPasses $test @{ version = '0.9.2'; kind = 'test'; notes_start = 'v0.9.1'; sha = $headSha; dry_run = 'false' } 'a test tag on HEAD' + Assert-True -Condition ($test.Result.Output -clike '*Comparison mode: recovery*') -Name 'a test tag logs the recovery mode' + + $mismatch = Invoke-MetadataCommand @{ TAG = 'v0.9.2-test.abcdef1'; EVENT = 'push'; RUN_REF = 'refs/tags/v0.9.2-test.abcdef1' } $Token + Assert-MetadataFails $mismatch 'Sha mismatch' 'a test tag naming another sha' + } + else { + Write-Host 'skipping test tag checks: HEAD does not descend from v0.9.1' + } + } + finally { + Remove-LocalTags + } + foreach ($tag in $tags) { + Assert-True -Condition ($null -eq (Get-TagCommit -Tag $tag)) -Name "local tag $tag is removed afterwards" + } +} + +function Test-MetadataIntegration { + $token = Get-GitHubTokenForTests + if (-not $token) { + Write-Host 'skipping metadata integration: no GitHub token available' + return + } + $unavailable = Test-MetadataPrerequisites + if ($unavailable) { + Write-Host "skipping metadata integration: $unavailable" + return + } + + $releases = @(Get-GitHubRelease -Repository 'codescene-oss/codescene-vs') + Assert-True -Condition ($releases.Count -gt 0) -Name 'releases are loaded from GitHub' + Assert-True -Condition (@($releases | Where-Object { $_.tag_name -ceq 'v0.9.1' }).Count -eq 1) -Name 'the loaded releases are flattened release objects' + Assert-True -Condition (-not ($releases[0] -is [System.Array])) -Name 'the loaded releases are not nested pages' + + Test-MetadataDispatch $token + Test-MetadataFreshTags $token +} diff --git a/.github/test-release-flow/migration-script-tests.ps1 b/.github/test-release-flow/migration-script-tests.ps1 new file mode 100644 index 00000000..798ec3a1 --- /dev/null +++ b/.github/test-release-flow/migration-script-tests.ps1 @@ -0,0 +1,122 @@ +function Get-MigrationScriptPath { + return Join-Path $script:GitHubDirectory 'migrate-changelog-to-releases.ps1' +} + +function Invoke-MigrationScript([string[]]$Arguments, [hashtable]$Environment = @{}) { + return Invoke-EntryScript -Command '' -Environment $Environment -ScriptPath (Get-MigrationScriptPath) -ScriptArguments $Arguments +} + +function New-FakeMigrationGh { + $directory = New-TempDirectory + $script = @' +$line = $args -join ' ' +[IO.File]::AppendAllText($env:FAKE_GH_LOG, "$line`n") +if ($line -like 'api --paginate --slurp repos/*/releases*') { '[[' + $env:FAKE_RELEASES + ']]'; exit 0 } +if ($line -like 'api -X PATCH repos/*/releases/* -F body=@*') { + $id = ($args[3] -split '/')[-1] + $path = ([string]$args[5]).Substring(6) + [IO.File]::WriteAllBytes((Join-Path $env:FAKE_GH_BODIES "$id.md"), [IO.File]::ReadAllBytes($path)) + '{}' + exit 0 +} +"unexpected gh call: $line" +exit 9 +'@ + [IO.File]::WriteAllText((Join-Path $directory 'gh.ps1'), $script, (New-Object System.Text.UTF8Encoding($false))) + return $directory +} + +function Invoke-FakeMigration([string[]]$Arguments, $Releases) { + $fake = New-FakeMigrationGh + $bodies = New-TempDirectory + $log = Join-Path (New-TempDirectory) 'gh.log' + $json = (@($Releases) | ForEach-Object { $_ | ConvertTo-Json -Compress }) -join ',' + $variables = @{ + GITHUB_REPOSITORY = 'o/r' + FAKE_GH_LOG = $log + FAKE_GH_BODIES = $bodies + FAKE_RELEASES = $json + PATH = "$fake;$env:PATH" + } + $result = Invoke-MigrationScript $Arguments $variables + $calls = @() + if (Test-Path -LiteralPath $log) { $calls = @((Read-AllText $log) -split "`n" | Where-Object { $_ }) } + return [pscustomobject]@{ Result = $result; Calls = $calls; Bodies = $bodies } +} + +function Test-MigrationScriptArguments { + $bogus = Invoke-MigrationScript @('--bogus') + Assert-EntryScriptFails $bogus 'Unknown argument --bogus.*Usage' 'an unknown argument' + + $noValue = Invoke-MigrationScript @('--file') + Assert-EntryScriptFails $noValue '--file requires a path' '--file without a value' + + $extra = Invoke-MigrationScript @('--apply', 'notes.md') + Assert-EntryScriptFails $extra 'Unknown argument notes\.md' 'a stray positional argument' + + $missing = Invoke-MigrationScript @('--file', (Join-Path (New-TempDirectory) 'missing.md')) + Assert-EntryScriptFails $missing 'Changelog file not found' '--file pointing at a missing file' +} + +function Test-MigrationScriptWithFakeGh { + $directory = New-TempDirectory + $changelog = Join-Path $directory 'changelog.md' + $bom = [string][char]0xFEFF + Write-AllText $changelog ($bom + ($script:ChangelogFixture -replace "`n", "`r`n")) + $sections = @(ConvertFrom-Changelog -Text $script:ChangelogFixture) + $section091 = Get-ChangelogSectionBody $sections '0.9.1' + $identical = Set-ChangelogBlock -Body 'Automated build for v0.9.0' -Section (Get-ChangelogSectionBody $sections '0.9.0') + $releases = Get-MigrationFixtureReleases $identical + + $dry = Invoke-FakeMigration @('--file', $changelog) $releases + Assert-Equal -Expected 0 -Actual $dry.Result.ExitCode -Name "a dry run exits zero ($($dry.Result.Output))" + Assert-True -Condition ($dry.Result.Output -clike '*Would update v0.9.1*') -Name 'a dry run announces the update' + Assert-True -Condition ($dry.Result.Output -clike '**keep WebView2 visible (#341)**') -Name 'a dry run prints the block' + Assert-True -Condition ($dry.Result.Output -clike '*Up to date: v0.9.0*') -Name 'a dry run lists the up to date releases' + Assert-True -Condition ($dry.Result.Output -clike '*Empty changelog sections: 0.8.7*') -Name 'a dry run lists the empty sections' + Assert-True -Condition ($dry.Result.Output -clike '*Changelog sections without a stable release: Unreleased, 0.8.1, Notes*') -Name 'a dry run lists the unmatched sections' + Assert-True -Condition ($dry.Result.Output -clike '*Dry run: 1 release(s) would be updated. Pass --apply to write.*') -Name 'a dry run prints the summary' + Assert-Equal -Expected 0 -Actual @($dry.Calls | Where-Object { $_ -clike '*PATCH*' }).Count -Name 'a dry run issues no PATCH' + Assert-Equal -Expected 0 -Actual @(Get-ChildItem -LiteralPath $dry.Bodies -Force).Count -Name 'a dry run writes no body' + + $applied = Invoke-FakeMigration @('--file', $changelog, '--apply') $releases + Assert-Equal -Expected 0 -Actual $applied.Result.ExitCode -Name "an apply run exits zero ($($applied.Result.Output))" + Assert-True -Condition ($applied.Result.Output -clike '*Updating v0.9.1*Updated v0.9.1*') -Name 'an apply run reports the update' + Assert-True -Condition (-not ($applied.Result.Output -clike '*Dry run*')) -Name 'an apply run prints no dry run summary' + $patches = @($applied.Calls | Where-Object { $_ -clike '*PATCH*' }) + Assert-Equal -Expected 1 -Actual $patches.Count -Name 'an apply run patches exactly the one outdated release' + Assert-True -Condition ($patches[0] -clike 'api -X PATCH repos/o/r/releases/101 -F body=@*') -Name 'the PATCH targets the release id with a body file' + Assert-Equal -Expected 0 -Actual @($applied.Calls | Where-Object { $_ -clike 'release create*' -or $_ -clike '*-X POST*' }).Count -Name 'the migration never creates releases' + + $bodyPath = Join-Path $applied.Bodies '101.md' + Assert-True -Condition (Test-Path -LiteralPath $bodyPath) -Name 'the patched body file reached gh' + $bytes = [IO.File]::ReadAllBytes($bodyPath) + Assert-True -Condition (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) -Name 'the body file has no byte order mark' + Assert-Equal -Expected (Set-ChangelogBlock -Body 'Automated build for v0.9.1' -Section $section091) -Actual (Read-AllText $bodyPath) -Name 'the body file holds the new release body' + $tempFile = $patches[0].Substring($patches[0].IndexOf('body=@') + 6) + Assert-True -Condition (-not (Test-Path -LiteralPath $tempFile)) -Name 'the temporary body file is removed afterwards' + + $migrated = @( + (Get-FixtureRelease 'v0.9.1' (Set-ChangelogBlock -Body 'Automated build for v0.9.1' -Section $section091) $false $false 101), + (Get-FixtureRelease 'v0.9.0' $identical $false $false 100) + ) + $again = Invoke-FakeMigration @('--file', $changelog, '--apply') $migrated + Assert-Equal -Expected 0 -Actual $again.Result.ExitCode -Name 'a second apply run exits zero' + Assert-Equal -Expected 0 -Actual @($again.Calls | Where-Object { $_ -clike '*PATCH*' }).Count -Name 'a second apply run patches nothing' + Assert-True -Condition ($again.Result.Output -clike '*Up to date: v0.9.1, v0.9.0*') -Name 'a second apply run finds everything up to date' +} + +function Test-MigrationIntegration { + $token = Get-GitHubTokenForTests + if (-not $token) { + Write-Host 'skipping migration integration: no GitHub token available' + return + } + $run = Invoke-MigrationScript @() @{ GH_TOKEN = $token } + Assert-Equal -Expected 0 -Actual $run.ExitCode -Name "the migration dry run against the real releases exits zero ($($run.Output))" + foreach ($tag in @('v0.9.1', 'v0.9.0', 'v0.7.0', 'v0.3.0', 'v0.2.7', 'v0.2.0')) { + Assert-True -Condition ($run.Output -clike "*Would update $tag`n*") -Name "the dry run would update $tag" + } + Assert-True -Condition ($run.Output -clike '*Changelog sections without a stable release: Unreleased, *0.1.4*0.0.1*') -Name 'the dry run reports Unreleased and 0.0.1 to 0.1.x as unmatched' + Assert-True -Condition ($run.Output -match 'Dry run: \d+ release\(s\) would be updated\. Pass --apply to write\.') -Name 'the dry run prints the summary' +} diff --git a/.github/test-release-flow/process-tests.ps1 b/.github/test-release-flow/process-tests.ps1 new file mode 100644 index 00000000..4f780e66 --- /dev/null +++ b/.github/test-release-flow/process-tests.ps1 @@ -0,0 +1,86 @@ +function Test-Utf8ConsoleEncoding { + $skipped = $false + try { + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) + } + catch [System.IO.IOException] { + $skipped = $true + } + if ($skipped) { + Write-Host 'skipping console encoding check: no console handle' + return + } + Assert-Equal -Expected 65001 -Actual ([Console]::OutputEncoding.CodePage) -Name 'importing the module leaves the console output encoding at UTF-8' +} + +function Test-InvokeNative { + $failing = @('/c', 'echo oops 1>&2 & exit 3') + $result = Invoke-Native -FilePath 'cmd.exe' -ArgumentList $failing + Assert-Equal -Expected 3 -Actual $result.ExitCode -Name 'Invoke-Native returns the exit code' + Assert-True -Condition ($result.Output -clike '*oops*') -Name 'Invoke-Native captures stderr in Output' + Assert-Equal -Expected 'ExitCode,Output' -Actual ($result.PSObject.Properties.Name -join ',') -Name 'Invoke-Native result has the documented shape' + + $stderrOnly = Invoke-Native -FilePath 'cmd.exe' -ArgumentList @('/c', 'echo warn 1>&2') + Assert-Equal -Expected 0 -Actual $stderrOnly.ExitCode -Name 'a command that only writes to stderr exits 0' + Assert-True -Condition ($stderrOnly.Output -clike '*warn*') -Name 'stderr from a successful command is captured' + + $stdout = Invoke-Native -FilePath 'cmd.exe' -ArgumentList @('/c', 'echo hello') + Assert-Equal -Expected 'hello' -Actual $stdout.Output -Name 'stdout is captured and trimmed' + + $mixed = Invoke-Native -FilePath 'cmd.exe' -ArgumentList @('/c', 'echo one& echo two 1>&2& echo three') + Assert-Equal -Expected 3 -Actual @($mixed.Output -split "`n").Count -Name 'output lines from both streams are kept' + + $none = Invoke-Native -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0') + Assert-Equal -Expected '' -Actual $none.Output -Name 'a silent command has empty output' + Assert-Equal -Expected 0 -Actual (Invoke-Native -FilePath 'whoami.exe' -ArgumentList $null).ExitCode -Name 'a null argument list is accepted' + + Assert-Throws -Action { Invoke-NativeChecked -FilePath 'cmd.exe' -ArgumentList $failing } -MessagePattern 'oops' -Name 'Invoke-NativeChecked includes the output' + Assert-Throws -Action { Invoke-NativeChecked -FilePath 'cmd.exe' -ArgumentList $failing } -MessagePattern 'exit code 3' -Name 'Invoke-NativeChecked includes the exit code' + Assert-Equal -Expected 0 -Actual (Invoke-NativeChecked -FilePath 'cmd.exe' -ArgumentList @('/c', 'echo warn 1>&2')).ExitCode -Name 'Invoke-NativeChecked passes a stderr-only success' +} + +function Test-WriteActionOutput { + $directory = New-TempDirectory + $file = Join-Path $directory 'output.txt' + $saved = $env:GITHUB_OUTPUT + try { + $env:GITHUB_OUTPUT = $file + Write-ActionOutput -Name 'tag' -Value 'v0.9.1' + Write-ActionOutput -Name 'notes_start' -Value '' + Write-ActionOutput -Name 'text' -Value ([string][char]0x00E5 + 'ngstr' + [char]0x00F6 + 'm') + $bytes = [IO.File]::ReadAllBytes($file) + Assert-True -Condition ($bytes[0] -ne 0xEF) -Name 'the output file starts without a BOM' + $lines = @((New-Object System.Text.UTF8Encoding($false)).GetString($bytes) -split "`n" | Where-Object { $_ }) + Assert-Equal -Expected 'tag=v0.9.1' -Actual $lines[0] -Name 'the first output line is name=value' + Assert-Equal -Expected 'notes_start=' -Actual $lines[1] -Name 'an empty value is written as name=' + Assert-Equal -Expected ('text=' + [char]0x00E5 + 'ngstr' + [char]0x00F6 + 'm') -Actual $lines[2] -Name 'non-ASCII values are written as UTF-8' + Assert-Equal -Expected 3 -Actual $lines.Count -Name 'outputs are appended one per line' + Assert-Equal -Expected 0 -Actual (Get-CarriageReturnCount $bytes) -Name 'output lines end with LF only' + + Assert-Throws -Action { Write-ActionOutput -Name 'a' -Value "x`ny=1" } -MessagePattern 'single line' -Name 'a multi-line value is rejected' + Assert-Throws -Action { Write-ActionOutput -Name 'a=b' -Value 'x' } -MessagePattern 'single line' -Name 'a name containing = is rejected' + Assert-Equal -Expected 3 -Actual @((New-Object System.Text.UTF8Encoding($false)).GetString([IO.File]::ReadAllBytes($file)) -split "`n" | Where-Object { $_ }).Count -Name 'rejected outputs write nothing' + + $env:GITHUB_OUTPUT = $null + Assert-Throws -Action { Write-ActionOutput -Name 'a' -Value 'x' } -MessagePattern 'GITHUB_OUTPUT is required' -Name 'writing without GITHUB_OUTPUT throws' + } + finally { + $env:GITHUB_OUTPUT = $saved + } +} + +function Test-GitHelpers { + $head = (Invoke-NativeChecked -FilePath 'git' -ArgumentList @('rev-parse', 'HEAD')).Output + Assert-Equal -Expected $head -Actual (Get-TagCommit -Tag 'HEAD') -Name 'Get-TagCommit resolves a ref to the full commit' + Assert-True -Condition ($null -eq (Get-TagCommit -Tag 'v0.0.0-no-such-tag')) -Name 'Get-TagCommit returns null for an unknown tag' + + $parent = Invoke-Native -FilePath 'git' -ArgumentList @('rev-parse', '--verify', '-q', 'HEAD~1') + if ($parent.ExitCode -ne 0) { + Write-Host 'skipping ancestry checks: no HEAD~1 in this checkout' + return + } + Assert-Equal -Expected $true -Actual (Test-GitAncestor -Ancestor 'HEAD~1' -Descendant 'HEAD') -Name 'the parent is an ancestor of HEAD' + Assert-Equal -Expected $false -Actual (Test-GitAncestor -Ancestor 'HEAD' -Descendant 'HEAD~1') -Name 'HEAD is not an ancestor of its parent' + Assert-Equal -Expected $true -Actual (Test-GitAncestor -Ancestor 'HEAD' -Descendant 'HEAD') -Name 'a commit is an ancestor of itself' + Assert-Throws -Action { Test-GitAncestor -Ancestor 'no-such-ref-anywhere' -Descendant 'HEAD' } -MessagePattern 'exit code 128' -Name 'an unknown ref throws instead of answering false' +} diff --git a/.github/test-release-flow/publish-command-tests.ps1 b/.github/test-release-flow/publish-command-tests.ps1 new file mode 100644 index 00000000..0723c037 --- /dev/null +++ b/.github/test-release-flow/publish-command-tests.ps1 @@ -0,0 +1,120 @@ +function Test-WriteOverviewCommand { + $root = New-VersionRepository + $readme = "# CodeScene `u{00E9}`n`n![x](screenshots/b.png)`n" + Write-AllText (Join-Path $root 'README.md') $readme + $path = Write-MarketplaceOverview -RepositoryPath $root + $expectedPath = Join-Path $root 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\overview.md' + Assert-Equal -Expected $expectedPath -Actual $path -Name 'the overview is written next to the version files' + $bytes = [IO.File]::ReadAllBytes($path) + Assert-True -Condition (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) -Name 'the overview has no byte order mark' + Assert-Equal -Expected (ConvertTo-MarketplaceOverview -Readme $readme) -Actual (Read-AllText $path) -Name 'the overview is the converted readme' + Assert-True -Condition ((Read-AllText $path).Contains("`u{00E9}")) -Name 'the overview keeps non-ASCII text as UTF-8' + Assert-True -Condition ((Read-AllText $path).Contains('src="https://raw.githubusercontent.com/codescene-oss/codescene-vs/main/screenshots/a.png"')) -Name 'the overview rewrites html screenshot links' + + $viaScript = New-VersionRepository + Write-AllText (Join-Path $viaScript 'README.md') $readme + $run = Invoke-EntryScript -Command 'write-overview' -Environment @{ REPO_PATH = $viaScript } -WorkingDirectory (New-TempDirectory) + Assert-Equal -Expected 0 -Actual $run.ExitCode -Name "write-overview exits zero ($($run.Output))" + Assert-Equal -Expected (Read-AllText $path) -Actual (Read-AllText (Join-Path $viaScript 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\overview.md')) -Name 'write-overview writes the converted readme into REPO_PATH' + + $noReadme = New-VersionRepository + $failed = Invoke-EntryScript -Command 'write-overview' -Environment @{ REPO_PATH = $noReadme } + Assert-True -Condition ($failed.ExitCode -ne 0) -Name 'write-overview without a README fails' +} + +function Test-WriteOverviewOnArchive { + if (-not (Get-TagCommit -Tag 'v0.9.1')) { + Write-Host 'skipping overview archive check: tag v0.9.1 is not available locally' + return + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $work = New-TempDirectory + $zip = Join-Path $work 'tree.zip' + $tree = Join-Path $work 'tree' + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('archive', '--format=zip', '-o', $zip, 'v0.9.1') | Out-Null + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $tree) + Assert-True -Condition (-not (Test-Path -LiteralPath (Join-Path $tree '.github\release-flow.ps1'))) -Name 'the archived tree has no release-flow.ps1 for write-overview' + + $repository = $script:RepositoryRoot + $realOverview = Join-Path $repository 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\overview.md' + $realBefore = Test-Path -LiteralPath $realOverview + $overview = Join-Path $tree 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\overview.md' + Assert-True -Condition (-not (Test-Path -LiteralPath $overview)) -Name 'the archived tree starts without an overview' + + $run = Invoke-EntryScript -Command 'write-overview' -Environment @{ REPO_PATH = $tree } -WorkingDirectory $repository + Assert-Equal -Expected 0 -Actual $run.ExitCode -Name "write-overview works on a tree without the helper ($($run.Output))" + Assert-True -Condition (Test-Path -LiteralPath $overview) -Name 'write-overview writes overview.md into the archived tree' + Assert-Equal -Expected (ConvertTo-MarketplaceOverview -Readme (Read-AllText (Join-Path $tree 'README.md'))) -Actual (Read-AllText $overview) -Name 'the archived overview is the converted readme' + Assert-Equal -Expected $realBefore -Actual (Test-Path -LiteralPath $realOverview) -Name 'write-overview leaves the working tree overview alone' +} + +function New-FakePublisherScript { + $directory = New-TempDirectory + $path = Join-Path $directory 'VsixPublisher.ps1' + $script = @' +[IO.File]::AppendAllText($env:FAKE_PUBLISH_LOG, ($args -join '|') + "`n") +if ($env:FAKE_PUBLISH_OUTPUT) { $env:FAKE_PUBLISH_OUTPUT } +exit ([int]$env:FAKE_PUBLISH_EXIT) +'@ + Write-AllText $path $script + return $path +} + +function Invoke-PublishCommand([hashtable]$Environment, $Tree, [string]$PublisherPath) { + $log = Join-Path (New-TempDirectory) 'publish.log' + $variables = @{ + VSIX_PUBLISHER = $PublisherPath + VSIX_DIR = $Tree.Root + PUBLISH_MANIFEST = $Tree.Manifest + FAKE_PUBLISH_LOG = $log + FAKE_PUBLISH_EXIT = '0' + } + foreach ($name in $Environment.Keys) { $variables[$name] = $Environment[$name] } + $result = Invoke-EntryScript -Command 'publish' -Environment $variables + $calls = @() + if (Test-Path -LiteralPath $log) { $calls = @((Read-AllText $log) -split "`n" | Where-Object { $_ }) } + return [pscustomobject]@{ Result = $result; Calls = $calls } +} + +function Test-PublishCommand { + $tree = New-PublishTree + $publisher = New-FakePublisherScript + $base = @{ VERSION = '0.9.2' } + + foreach ($name in @('VERSION', 'VSIX_PUBLISHER', 'VSIX_DIR', 'PUBLISH_MANIFEST')) { + $variables = @{ VERSION = '0.9.2'; VSIX_PUBLISHER = $publisher; VSIX_DIR = $tree.Root; PUBLISH_MANIFEST = $tree.Manifest } + $variables.Remove($name) + Assert-EntryScriptFails (Invoke-EntryScript -Command 'publish' -Environment $variables) "$name is required" "publish without $name" + } + + $missing = Invoke-PublishCommand @{ VERSION = '0.9.2'; PUBLISH_MANIFEST = (Join-Path $tree.Root 'missing.json') } $tree $publisher + Assert-EntryScriptFails $missing.Result 'publishManifest\.json not found' 'publish with a missing manifest' + Assert-Equal -Expected 0 -Actual $missing.Calls.Count -Name 'publish with a missing manifest never calls the publisher' + + $reachable = $true + try { Test-MarketplaceVersionListed -Version '0.9.1' | Out-Null } catch { $reachable = $false } + if (-not $reachable) { + Write-Host 'skipping publish integration: the Marketplace gallery is not reachable' + return + } + + $listed = Invoke-PublishCommand @{ VERSION = '0.9.1' } $tree $publisher + Assert-Equal -Expected 0 -Actual $listed.Result.ExitCode -Name "publish of an already listed version exits zero ($($listed.Result.Output))" + Assert-True -Condition ($listed.Result.Output -clike '*already listed*') -Name 'publish of a listed version logs the skip' + Assert-Equal -Expected 0 -Actual $listed.Calls.Count -Name 'publish of a listed version never calls the publisher' + + $published = Invoke-PublishCommand @{ VERSION = '99.0.0'; FAKE_PUBLISH_OUTPUT = 'uploaded fine' } $tree $publisher + Assert-Equal -Expected 0 -Actual $published.Result.ExitCode -Name "publish of an unlisted version exits zero ($($published.Result.Output))" + Assert-Equal -Expected "publish|-payload|$($tree.Vsix)|-publishManifest|$($tree.Manifest)" -Actual ($published.Calls -join ';') -Name 'publish calls the publisher with the payload and manifest' + Assert-True -Condition ($published.Result.Output -clike '*uploaded fine*') -Name 'publish echoes the publisher output' + + $relative = Invoke-PublishCommand @{ VERSION = '99.0.0'; REPO_PATH = (Split-Path -Parent $tree.Root); VSIX_DIR = (Split-Path -Leaf $tree.Root) } $tree $publisher + Assert-Equal -Expected 0 -Actual $relative.Result.ExitCode -Name "publish with a VSIX_DIR relative to REPO_PATH exits zero ($($relative.Result.Output))" + Assert-Equal -Expected "publish|-payload|$($tree.Vsix)|-publishManifest|$($tree.Manifest)" -Actual ($relative.Calls -join ';') -Name 'publish resolves a relative VSIX_DIR against REPO_PATH as the workflow passes it' + + $exists = Invoke-PublishCommand @{ VERSION = '99.0.0'; FAKE_PUBLISH_EXIT = '1'; FAKE_PUBLISH_OUTPUT = 'version already exists' } $tree $publisher + Assert-Equal -Expected 0 -Actual $exists.Result.ExitCode -Name "publish treats an existing version as a skip ($($exists.Result.Output))" + + $failed = Invoke-PublishCommand @{ VERSION = '99.0.0'; FAKE_PUBLISH_EXIT = '1'; FAKE_PUBLISH_OUTPUT = 'boom' } $tree $publisher + Assert-EntryScriptFails $failed.Result 'failed with exit code 1.*boom' 'publish with a failing publisher' +} diff --git a/.github/test-release-flow/release-command-tests.ps1 b/.github/test-release-flow/release-command-tests.ps1 new file mode 100644 index 00000000..2c057e44 --- /dev/null +++ b/.github/test-release-flow/release-command-tests.ps1 @@ -0,0 +1,201 @@ +function New-FakeGh { + $directory = New-TempDirectory + $script = @' +$line = $args -join ' ' +[IO.File]::AppendAllText($env:FAKE_GH_LOG, "$line`n") +switch -Wildcard ($line) { + 'api -X POST *generate-notes*' { '{"body":"## Changes\n- a change"}'; exit 0 } + 'release view * --json tagName' { + if ($env:FAKE_RELEASE_EXISTS -eq 'true') { '{"tagName":"x"}'; exit 0 } + 'release not found'; exit 1 + } + 'release view * --json assets' { + $names = @($env:FAKE_ASSETS -split ',' | Where-Object { $_ }) + $items = @($names | ForEach-Object { '{"name":"' + $_ + '"}' }) + '{"assets":[' + ($items -join ',') + ']}'; exit 0 + } + 'auth status' { + if ($env:FAKE_AUTH -eq 'fail') { 'You are not logged into any GitHub hosts.'; exit 1 } + exit 0 + } + 'api --paginate --slurp repos/*/releases*' { '[[' + $env:FAKE_RELEASES + ']]'; exit 0 } + 'api repos/*/releases/latest' { + if ($env:FAKE_LATEST) { '{"tag_name":"' + $env:FAKE_LATEST + '"}'; exit 0 } + '{"message":"Not Found"}'; 'gh: Not Found (HTTP 404)'; exit 1 + } + 'api repos/*/actions/workflows/test.yml/runs*' { + if ($env:FAKE_RUNS -eq 'success') { '{"workflow_runs":[{"status":"completed","conclusion":"success","html_url":"https://github.com/o/r/actions/runs/42"}]}'; exit 0 } + if ($env:FAKE_RUNS -eq 'failure') { '{"workflow_runs":[{"status":"completed","conclusion":"failure","html_url":"https://github.com/o/r/actions/runs/7"}]}'; exit 0 } + '{"message":"Not Found"}'; 'gh: Not Found (HTTP 404)'; exit 1 + } + 'release create *' { exit 0 } + 'release edit *' { exit 0 } + 'release upload *' { exit 0 } + 'release download *' { exit 0 } + default { "unexpected gh call: $line"; exit 9 } +} +'@ + [IO.File]::WriteAllText((Join-Path $directory 'gh.ps1'), $script, (New-Object System.Text.UTF8Encoding($false))) + return $directory +} + +function Invoke-FakeGhCommand([string]$Command, [hashtable]$Environment) { + $fake = New-FakeGh + $log = Join-Path (New-TempDirectory) 'gh.log' + $notes = New-TempDirectory + $variables = @{ + GH_TOKEN = 'unused' + GITHUB_REPOSITORY = 'o/r' + RUNNER_TEMP = $notes + FAKE_GH_LOG = $log + PATH = "$fake;$env:PATH" + } + foreach ($name in $Environment.Keys) { $variables[$name] = $Environment[$name] } + $result = Invoke-EntryScript -Command $Command -Environment $variables + $calls = @() + if (Test-Path -LiteralPath $log) { $calls = @((Read-AllText $log) -split "`n" | Where-Object { $_ }) } + return [pscustomobject]@{ Result = $result; Calls = $calls; Notes = $notes } +} + +function Get-FakeGhCall($Run, [string]$Prefix) { + return , @($Run.Calls | Where-Object { $_.StartsWith($Prefix, [StringComparison]::Ordinal) }) +} + +function Assert-FakeGhPasses($Run, [string]$Name) { + Assert-Equal -Expected 0 -Actual $Run.Result.ExitCode -Name "$Name exits zero ($($Run.Result.Output))" +} + +function Test-CreateReleaseCommand { + $notesApi = 'api -X POST repos/o/r/releases/generate-notes' + + $fresh = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.2'; KIND = 'stable'; NOTES_START = 'v0.9.1' } + Assert-FakeGhPasses $fresh 'create-release of a new stable release' + Assert-Equal -Expected "$notesApi -f tag_name=v0.9.2 -f previous_tag_name=v0.9.1" -Actual (Get-FakeGhCall $fresh $notesApi)[0] -Name 'create-release generates notes since the previous release' + $notesFile = Join-Path $fresh.Notes 'release-notes-v0.9.2.md' + Assert-Equal -Expected "release create v0.9.2 --title v0.9.2 --notes-file $notesFile --verify-tag --latest=false" -Actual (Get-FakeGhCall $fresh 'release create')[0] -Name 'create-release creates a non-latest release' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $fresh 'release edit').Count -Name 'create-release does not edit a new release' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $fresh 'api repos/o/r/releases/latest').Count -Name 'create-release does not read the latest release for a new release' + Assert-Equal -Expected "## Changes`n- a change" -Actual (Read-AllText $notesFile) -Name 'create-release writes the generated notes unchanged for a stable release' + + $noStart = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.2'; KIND = 'stable' } + Assert-FakeGhPasses $noStart 'create-release without a previous release' + Assert-Equal -Expected "$notesApi -f tag_name=v0.9.2" -Actual (Get-FakeGhCall $noStart $notesApi)[0] -Name 'create-release omits the previous tag when the start is empty' + + $latestSame = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.1'; KIND = 'stable'; NOTES_START = 'v0.9.0'; FAKE_RELEASE_EXISTS = 'true'; FAKE_LATEST = 'v0.9.1' } + Assert-FakeGhPasses $latestSame 'create-release of an existing latest release' + $sameNotes = Join-Path $latestSame.Notes 'release-notes-v0.9.1.md' + Assert-Equal -Expected "release edit v0.9.1 --title v0.9.1 --notes-file $sameNotes --latest=true" -Actual (Get-FakeGhCall $latestSame 'release edit')[0] -Name 'create-release keeps the latest marker on the latest release' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $latestSame 'release create').Count -Name 'create-release does not create an existing release' + + $latestOther = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.0'; KIND = 'stable'; FAKE_RELEASE_EXISTS = 'true'; FAKE_LATEST = 'v0.9.1' } + Assert-FakeGhPasses $latestOther 'create-release of an older existing release' + Assert-True -Condition ((Get-FakeGhCall $latestOther 'release edit')[0].EndsWith('--latest=false', [StringComparison]::Ordinal)) -Name 'create-release does not take the latest marker from a newer release' + + $noLatest = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.0'; KIND = 'stable'; FAKE_RELEASE_EXISTS = 'true' } + Assert-FakeGhPasses $noLatest 'create-release of an existing release when no release is latest' + Assert-True -Condition ((Get-FakeGhCall $noLatest 'release edit')[0].EndsWith('--latest=false', [StringComparison]::Ordinal)) -Name 'create-release treats a missing latest release as not latest' + + $sha = '01f198db079248e461264ef79f94e82d8bd6faa8' + $test = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.2-test.01f198d'; KIND = 'test'; NOTES_START = 'v0.9.1'; SHA = $sha; GITHUB_SERVER_URL = 'https://github.com' } + Assert-FakeGhPasses $test 'create-release of a new test release' + $testNotes = Join-Path $test.Notes 'release-notes-v0.9.2-test.01f198d.md' + Assert-Equal -Expected "release create v0.9.2-test.01f198d --title v0.9.2-test.01f198d --notes-file $testNotes --verify-tag --latest=false --prerelease" -Actual (Get-FakeGhCall $test 'release create')[0] -Name 'create-release creates a test release as a prerelease' + Assert-Equal -Expected "Test build, not published to the Visual Studio Marketplace. Commit: [01f198d](https://github.com/o/r/commit/$sha)`n`n## Changes`n- a change" -Actual (Read-AllText $testNotes) -Name 'create-release prefixes the test banner' + + $existingTest = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.2-test.01f198d'; KIND = 'test'; SHA = $sha; GITHUB_SERVER_URL = 'https://github.com'; FAKE_RELEASE_EXISTS = 'true'; FAKE_LATEST = 'v0.9.1' } + Assert-FakeGhPasses $existingTest 'create-release of an existing test release' + Assert-True -Condition ((Get-FakeGhCall $existingTest 'release edit')[0].EndsWith('--latest=false', [StringComparison]::Ordinal)) -Name 'create-release never marks a test release latest' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $existingTest 'api repos/o/r/releases/latest').Count -Name 'create-release does not read the latest release for a test release' + + $noSha = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.2-test.01f198d'; KIND = 'test'; GITHUB_SERVER_URL = 'https://github.com' } + Assert-EntryScriptFails $noSha.Result 'SHA is required' 'create-release of a test release without a sha' + Assert-Equal -Expected 0 -Actual $noSha.Calls.Count -Name 'create-release without a sha makes no gh call' + + $mismatch = Invoke-FakeGhCommand 'create-release' @{ TAG = 'v0.9.2'; KIND = 'test' } + Assert-EntryScriptFails $mismatch.Result 'does not match tag v0\.9\.2' 'create-release with a kind that differs from the tag' + Assert-Equal -Expected 0 -Actual $mismatch.Calls.Count -Name 'create-release with a wrong kind makes no gh call' + + $noTag = Invoke-FakeGhCommand 'create-release' @{ KIND = 'stable' } + Assert-EntryScriptFails $noTag.Result 'TAG is required' 'create-release without a tag' + + $badTag = Invoke-FakeGhCommand 'create-release' @{ TAG = '..\evil'; KIND = 'stable' } + Assert-EntryScriptFails $badTag.Result 'Malformed tag' 'create-release with a malformed tag' + Assert-Equal -Expected 0 -Actual $badTag.Calls.Count -Name 'create-release with a malformed tag makes no gh call' +} + +function Test-UploadCommand { + $assetName = 'Codescene.VSExtension.VS2022.vsix' + $directory = New-TempDirectory + $vsix = Join-Path $directory $assetName + [IO.File]::WriteAllText($vsix, 'x') + [IO.File]::WriteAllText((Join-Path $directory 'notes.txt'), 'y') + + $upload = Invoke-FakeGhCommand 'upload' @{ TAG = 'v0.9.2'; ASSET_DIR = $directory } + Assert-FakeGhPasses $upload 'upload of a missing asset' + Assert-Equal -Expected "release upload v0.9.2 $vsix" -Actual (Get-FakeGhCall $upload 'release upload')[0] -Name 'upload attaches the vsix with its file name' + Assert-Equal -Expected 1 -Actual (Get-FakeGhCall $upload 'release upload').Count -Name 'upload attaches only vsix files' + + $skip = Invoke-FakeGhCommand 'upload' @{ TAG = 'v0.9.2'; ASSET_DIR = $directory; FAKE_ASSETS = $assetName } + Assert-FakeGhPasses $skip 'upload of an attached asset' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $skip 'release upload').Count -Name 'upload skips an asset that is attached already' + Assert-True -Condition ($skip.Result.Output -clike "*Skipping $assetName*") -Name 'upload logs the skipped asset' + + $other = Invoke-FakeGhCommand 'upload' @{ TAG = 'v0.9.2'; ASSET_DIR = $directory; FAKE_ASSETS = 'other.zip' } + Assert-Equal -Expected 1 -Actual (Get-FakeGhCall $other 'release upload').Count -Name 'upload attaches the vsix next to unrelated assets' + + $empty = Invoke-FakeGhCommand 'upload' @{ TAG = 'v0.9.2'; ASSET_DIR = (New-TempDirectory) } + Assert-EntryScriptFails $empty.Result 'No \.vsix file found' 'upload from a directory without a vsix' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $empty 'release upload').Count -Name 'upload from an empty directory attaches nothing' + + $noDirectory = Invoke-FakeGhCommand 'upload' @{ TAG = 'v0.9.2' } + Assert-EntryScriptFails $noDirectory.Result 'ASSET_DIR is required' 'upload without an asset directory' +} + +function Test-DownloadAssetCommand { + $directory = New-TempDirectory + $missing = Invoke-FakeGhCommand 'download-asset' @{ TAG = 'v0.9.2'; ASSET_DIR = $directory } + Assert-Equal -Expected "release download v0.9.2 --pattern Codescene.VSExtension.VS2022.vsix --dir $directory --clobber" -Actual (Get-FakeGhCall $missing 'release download')[0] -Name 'download-asset runs the download arguments' + Assert-EntryScriptFails $missing.Result 'No \.vsix file found' 'download-asset when the asset is missing' + + $noDirectory = Invoke-FakeGhCommand 'download-asset' @{ TAG = 'v0.9.2' } + Assert-EntryScriptFails $noDirectory.Result 'ASSET_DIR is required' 'download-asset without an asset directory' +} + +function Test-TestGateCommand { + $sha = '01f198db079248e461264ef79f94e82d8bd6faa8' + $pass = Invoke-FakeGhCommand 'test-gate' @{ SHA = $sha; FAKE_RUNS = 'success' } + Assert-FakeGhPasses $pass 'test-gate with a green run' + Assert-True -Condition ($pass.Result.Output -clike '*actions/runs/42*') -Name 'test-gate prints the pass message' + Assert-Equal -Expected "api repos/o/r/actions/workflows/test.yml/runs?head_sha=$sha&per_page=100" -Actual (Get-FakeGhCall $pass 'api')[0] -Name 'test-gate queries the runs of the sha' + + $failed = Invoke-FakeGhCommand 'test-gate' @{ SHA = $sha; FAKE_RUNS = 'failure' } + Assert-EntryScriptFails $failed.Result "No successful 'Run tests' run for commit $sha" 'test-gate with a red run' + + $absent = Invoke-FakeGhCommand 'test-gate' @{ SHA = $sha } + Assert-EntryScriptFails $absent.Result "No 'Run tests' run found for commit $sha" 'test-gate when the workflow is absent' + + $noSha = Invoke-FakeGhCommand 'test-gate' @{} + Assert-EntryScriptFails $noSha.Result 'SHA is required' 'test-gate without a sha' +} + +function Test-PromoteCommand { + $first = Invoke-FakeGhCommand 'promote' @{ TAG = 'v0.9.2' } + Assert-FakeGhPasses $first 'promote when no release is latest' + Assert-Equal -Expected 'release edit v0.9.2 --latest' -Actual (Get-FakeGhCall $first 'release edit')[0] -Name 'promote marks the release latest when none is' + + $newer = Invoke-FakeGhCommand 'promote' @{ TAG = 'v0.10.0'; FAKE_LATEST = 'v0.9.9' } + Assert-Equal -Expected 'release edit v0.10.0 --latest' -Actual (Get-FakeGhCall $newer 'release edit')[0] -Name 'promote marks a higher version latest' + + $same = Invoke-FakeGhCommand 'promote' @{ TAG = 'v0.9.2'; FAKE_LATEST = 'v0.9.2' } + Assert-FakeGhPasses $same 'promote of the latest release' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $same 'release edit').Count -Name 'promote leaves the latest release unchanged' + + $superseded = Invoke-FakeGhCommand 'promote' @{ TAG = 'v0.9.1'; FAKE_LATEST = 'v0.10.0' } + Assert-FakeGhPasses $superseded 'promote of a superseded release' + Assert-Equal -Expected 0 -Actual (Get-FakeGhCall $superseded 'release edit').Count -Name 'promote does not demote a newer latest release' + Assert-True -Condition ($superseded.Result.Output -clike '*superseded by v0.10.0*') -Name 'promote logs the superseding release' + + $testTag = Invoke-FakeGhCommand 'promote' @{ TAG = 'v0.9.2-test.01f198d'; FAKE_LATEST = 'v0.9.1' } + Assert-EntryScriptFails $testTag.Result 'Only a stable release can be promoted' 'promote of a test tag' + Assert-Equal -Expected 0 -Actual $testTag.Calls.Count -Name 'promote of a test tag makes no gh call' +} diff --git a/.github/test-release-flow/release-decision-tests.ps1 b/.github/test-release-flow/release-decision-tests.ps1 new file mode 100644 index 00000000..189b8ba4 --- /dev/null +++ b/.github/test-release-flow/release-decision-tests.ps1 @@ -0,0 +1,207 @@ +function Test-TestReleaseNotes { + $sha = '0123456789abcdef0123456789abcdef01234567' + $generated = "## What's Changed`n* Fix thing by @someone in #1`n`n**Full Changelog**: https://example.test/compare/a...b" + $notes = Format-TestReleaseNotes -Generated $generated -Sha $sha -ServerUrl 'https://github.com' -Repository 'codescene-oss/codescene-vs' + $lines = $notes -split "`n" + Assert-Equal -Expected 'Test build, not published to the Visual Studio Marketplace. Commit: [0123456](https://github.com/codescene-oss/codescene-vs/commit/0123456789abcdef0123456789abcdef01234567)' -Actual $lines[0] -Name 'test notes start with the banner and commit link' + Assert-Equal -Expected '' -Actual $lines[1] -Name 'test notes have a blank line after the banner' + Assert-Equal -Expected $generated -Actual ($lines[2..($lines.Count - 1)] -join "`n") -Name 'test notes keep the generated text verbatim' + Assert-Equal -Expected 1 -Actual @($lines | Where-Object { $_ -clike 'Test build*' }).Count -Name 'test notes carry a single banner' + + $empty = Format-TestReleaseNotes -Generated '' -Sha $sha -ServerUrl 'https://github.com' -Repository 'o/r' + Assert-True -Condition ($empty -cmatch '^Test build.*\(https://github\.com/o/r/commit/0123456789abcdef0123456789abcdef01234567\)\n\n$') -Name 'empty generated notes still yield the banner' + + Assert-Equal -Expected $generated -Actual (Get-ReleaseNotesBody -Kind 'stable' -Generated $generated -Sha $sha -ServerUrl 'https://github.com' -Repository 'o/r') -Name 'stable notes are unchanged' + Assert-Equal -Expected $generated -Actual (Get-ReleaseNotesBody -Kind 'stable' -Generated $generated) -Name 'stable notes need no commit details' + Assert-Equal -Expected $notes -Actual (Get-ReleaseNotesBody -Kind 'test' -Generated $generated -Sha $sha -ServerUrl 'https://github.com' -Repository 'codescene-oss/codescene-vs') -Name 'test notes body is the formatted notes' + Assert-Throws -Action { Get-ReleaseNotesBody -Kind 'beta' -Generated $generated } -MessagePattern 'Unknown tag kind' -Name 'an unknown kind throws for the notes body' +} + +function Get-ReleaseConfigSection([string[]]$Lines, [string]$Start, [string]$End) { + $from = [array]::IndexOf($Lines, $Start) + if ($from -lt 0) { return @() } + $to = if ($End) { [array]::IndexOf($Lines, $End) } else { $Lines.Count } + if ($to -lt $from) { return @() } + return @($Lines[($from + 1)..($to - 1)]) +} + +function Test-ReleaseNotesConfig { + $path = Join-Path $script:RepositoryRoot '.github\release.yml' + Assert-True -Condition (Test-Path -LiteralPath $path) -Name 'the release notes configuration exists' + $text = [System.IO.File]::ReadAllText($path) + $lines = @($text -split '\r?\n' | Where-Object { $_ -ne '' }) + + Assert-Equal -Expected 'changelog:' -Actual $lines[0] -Name 'the configuration is rooted at changelog' + Assert-Equal -Expected 0 -Actual @($lines | Where-Object { $_ -match '#' }).Count -Name 'the configuration carries no comments' + + $titles = @($lines | Where-Object { $_ -cmatch '^ - title: ' } | ForEach-Object { $_ -creplace '^ - title: ', '' }) + Assert-Equal -Expected 'Breaking Changes|Features|Bug Fixes|Security|Documentation|Dependencies|Other Changes' -Actual ($titles -join '|') -Name 'the seven categories appear in order' + + $exclude = Get-ReleaseConfigSection -Lines $lines -Start ' exclude:' -End ' categories:' + $excludedLabels = Get-ReleaseConfigSection -Lines $exclude -Start ' labels:' -End ' authors:' + $excludedAuthors = Get-ReleaseConfigSection -Lines $exclude -Start ' authors:' -End $null + Assert-Equal -Expected ' - ignore-for-release| - skip-changelog' -Actual ($excludedLabels -join '|') -Name 'the excluded labels are listed' + Assert-Equal -Expected ' - dependabot| - github-actions' -Actual ($excludedAuthors -join '|') -Name 'the excluded authors are listed' + + $expectedLabels = [ordered]@{ + 'Breaking Changes' = 'breaking-change|breaking' + 'Features' = 'feature|enhancement' + 'Bug Fixes' = 'bug|fix|defect' + 'Security' = 'security' + 'Documentation' = 'documentation|docs' + 'Dependencies' = 'dependencies' + 'Other Changes' = '"*"' + } + foreach ($title in $expectedLabels.Keys) { + $start = " - title: $title" + $next = @($lines | Where-Object { $_ -cmatch '^ - title: ' -and $_ -cne $start } | Where-Object { [array]::IndexOf($lines, $_) -gt [array]::IndexOf($lines, $start) } | Select-Object -First 1) + $end = if ($next.Count -gt 0) { $next[0] } else { $null } + $section = Get-ReleaseConfigSection -Lines $lines -Start $start -End $end + Assert-Equal -Expected ' labels:' -Actual $section[0] -Name "$title lists labels" + $labels = @($section | Select-Object -Skip 1 | ForEach-Object { $_ -creplace '^ - ', '' }) + Assert-Equal -Expected $expectedLabels[$title] -Actual ($labels -join '|') -Name "$title has the expected labels" + } + + $wildcards = @($lines | Where-Object { $_ -match '\*' }) + Assert-Equal -Expected 1 -Actual $wildcards.Count -Name 'the wildcard appears exactly once' + Assert-Equal -Expected ' - "*"' -Actual $wildcards[0] -Name 'the wildcard is quoted' + Assert-Equal -Expected ($lines.Count - 1) -Actual ([array]::IndexOf($lines, $wildcards[0])) -Name 'the wildcard is the last entry, under Other Changes' +} + +function Test-ReleaseCommandArguments { + $createStable = @(Get-ReleaseCommandArguments -Exists $false -Kind 'stable' -Tag 'v0.9.2' -NotesFile 'n.md' -IsLatest $false) + Assert-Equal -Expected 'create v0.9.2 --title v0.9.2 --notes-file n.md --verify-tag --latest=false' -Actual ($createStable -join ' ') -Name 'create stable arguments' + + $createTest = @(Get-ReleaseCommandArguments -Exists $false -Kind 'test' -Tag 'v0.9.2-test.abc1234' -NotesFile 'n.md' -IsLatest $false) + Assert-Equal -Expected 'create v0.9.2-test.abc1234 --title v0.9.2-test.abc1234 --notes-file n.md --verify-tag --latest=false --prerelease' -Actual ($createTest -join ' ') -Name 'create test arguments' + + $createIgnoresLatest = @(Get-ReleaseCommandArguments -Exists $false -Kind 'stable' -Tag 'v0.9.2' -NotesFile 'n.md' -IsLatest $true) + Assert-Equal -Expected ($createStable -join ' ') -Actual ($createIgnoresLatest -join ' ') -Name 'a new release is never created as latest' + + $editLatest = @(Get-ReleaseCommandArguments -Exists $true -Kind 'stable' -Tag 'v0.9.2' -NotesFile 'n.md' -IsLatest $true) + Assert-Equal -Expected 'edit v0.9.2 --title v0.9.2 --notes-file n.md --latest=true' -Actual ($editLatest -join ' ') -Name 'edit while latest keeps latest' + + $editOlder = @(Get-ReleaseCommandArguments -Exists $true -Kind 'stable' -Tag 'v0.9.0' -NotesFile 'n.md' -IsLatest $false) + Assert-Equal -Expected 'edit v0.9.0 --title v0.9.0 --notes-file n.md --latest=false' -Actual ($editOlder -join ' ') -Name 'edit while not latest keeps it not latest' + + $editTest = @(Get-ReleaseCommandArguments -Exists $true -Kind 'test' -Tag 'v0.9.2-test.abc1234' -NotesFile 'n.md' -IsLatest $false) + foreach ($arguments in @($editLatest, $editOlder, $editTest)) { + Assert-True -Condition (($arguments -contains '--prerelease') -eq $false) -Name 'edit never passes --prerelease' + Assert-True -Condition (($arguments -contains '--verify-tag') -eq $false) -Name 'edit never passes --verify-tag' + } + Assert-Throws -Action { Get-ReleaseCommandArguments -Exists $false -Kind 'beta' -Tag 'v0.9.2' -NotesFile 'n.md' -IsLatest $false } -MessagePattern 'Unknown tag kind' -Name 'an unknown kind throws for release arguments' +} + +function Test-MissingAssets { + $paths = @('C:\out\a.vsix', 'C:\out\publishManifest.json') + Assert-Equal -Expected ($paths -join ',') -Actual (@(Select-MissingAsset -ExistingNames @() -Paths $paths) -join ',') -Name 'no attached assets returns all paths' + Assert-Equal -Expected ($paths -join ',') -Actual (@(Select-MissingAsset -ExistingNames $null -Paths $paths) -join ',') -Name 'a null attached list returns all paths' + Assert-Equal -Expected 'C:\out\publishManifest.json' -Actual (@(Select-MissingAsset -ExistingNames @('a.vsix') -Paths $paths) -join ',') -Name 'an attached name is skipped whatever its directory' + Assert-Equal -Expected 0 -Actual @(Select-MissingAsset -ExistingNames @('a.vsix', 'publishManifest.json') -Paths $paths).Count -Name 'everything attached returns nothing' + Assert-Equal -Expected 'C:\out\A.vsix' -Actual (@(Select-MissingAsset -ExistingNames @('a.vsix') -Paths @('C:\out\A.vsix')) -join ',') -Name 'asset names are compared case-sensitively' + Assert-Equal -Expected 0 -Actual @(Select-MissingAsset -ExistingNames @('a.vsix') -Paths @()).Count -Name 'no paths returns nothing' +} + +function New-Run([string]$Status, $Conclusion, [string]$Event = 'push', [string]$Url = 'https://github.com/o/r/actions/runs/1') { + return [pscustomobject]@{ status = $Status; conclusion = $Conclusion; event = $Event; html_url = $Url } +} + +function Test-TestGateVerdict { + $sha = '0123456789abcdef0123456789abcdef01234567' + $success = New-Run 'completed' 'success' 'push' 'https://github.com/o/r/actions/runs/42' + $failure = New-Run 'completed' 'failure' 'pull_request' 'https://github.com/o/r/actions/runs/7' + $running = New-Run 'in_progress' $null 'push' 'https://github.com/o/r/actions/runs/8' + + $verdict = Get-TestGateVerdict -Runs @($failure, $success) -Sha $sha + Assert-Equal -Expected $true -Actual $verdict.Pass -Name 'a success among failures passes' + Assert-Equal -Expected 'pass' -Actual $verdict.Reason -Name 'a passing gate has the pass reason' + Assert-True -Condition ($verdict.Message -clike '*actions/runs/42*') -Name 'a passing message includes the run url' + + $withRunning = Get-TestGateVerdict -Runs @($failure, $running) -Sha $sha + Assert-Equal -Expected $false -Actual $withRunning.Pass -Name 'a failure plus an in-progress run does not pass' + Assert-Equal -Expected 'in-progress' -Actual $withRunning.Reason -Name 'a failure plus an in-progress run is in-progress' + Assert-True -Condition ($withRunning.Message -clike '*tests still running; dispatch again when done*') -Name 'the in-progress message asks to dispatch again' + + $queued = Get-TestGateVerdict -Runs @(New-Run 'queued' $null) -Sha $sha + Assert-Equal -Expected 'in-progress' -Actual $queued.Reason -Name 'a queued run is in-progress' + + $successBeatsRunning = Get-TestGateVerdict -Runs @($running, $success) -Sha $sha + Assert-Equal -Expected 'pass' -Actual $successBeatsRunning.Reason -Name 'a success wins over an in-progress run' + + $failed = Get-TestGateVerdict -Runs @($failure, (New-Run 'completed' 'cancelled')) -Sha $sha + Assert-Equal -Expected $false -Actual $failed.Pass -Name 'only failures do not pass' + Assert-Equal -Expected 'failed' -Actual $failed.Reason -Name 'only failures are failed' + Assert-True -Condition ($failed.Message -clike '*failure*cancelled*') -Name 'the failed message lists the conclusions' + Assert-True -Condition ($failed.Message -clike '*not the bot commit*') -Name 'the failed message warns against the bot commit' + + foreach ($runs in @($null, @())) { + $absent = Get-TestGateVerdict -Runs $runs -Sha $sha + Assert-Equal -Expected $false -Actual $absent.Pass -Name 'no runs do not pass' + Assert-Equal -Expected 'absent' -Actual $absent.Reason -Name 'no runs are absent' + Assert-True -Condition ($absent.Message -cmatch 'Run tests') -Name 'the absent message names Run tests' + Assert-True -Condition ($absent.Message -clike '*tested PR merge commit, not the bot commit*') -Name 'the absent message states the tagging rule' + } + + $single = Get-TestGateVerdict -Runs $success -Sha $sha + Assert-Equal -Expected 'pass' -Actual $single.Reason -Name 'a single run object is accepted' +} + +function Test-PromotionAction { + Assert-Equal -Expected 'unchanged' -Actual (Get-PromotionAction -Tag 'v0.9.1' -LatestTag 'v0.9.1') -Name 'the latest release being this tag is unchanged' + Assert-Equal -Expected 'promote' -Actual (Get-PromotionAction -Tag 'v0.9.1' -LatestTag '') -Name 'no latest release promotes' + Assert-Equal -Expected 'promote' -Actual (Get-PromotionAction -Tag 'v0.9.1' -LatestTag $null) -Name 'a null latest release promotes' + Assert-Equal -Expected 'promote' -Actual (Get-PromotionAction -Tag 'v0.9.1' -LatestTag 'v0.9.0') -Name 'a lower latest release promotes' + Assert-Equal -Expected 'superseded' -Actual (Get-PromotionAction -Tag 'v0.9.1' -LatestTag 'v0.10.0') -Name 'a higher latest release supersedes' + Assert-Equal -Expected 'promote' -Actual (Get-PromotionAction -Tag 'v0.10.0' -LatestTag 'v0.9.9') -Name 'versions compare numerically, not textually' + Assert-Throws -Action { Get-PromotionAction -Tag 'v0.9.1' -LatestTag 'v0.9.1-test.abc1234' } -MessagePattern 'not a stable release tag' -Name 'a test latest tag throws' + Assert-Throws -Action { Get-PromotionAction -Tag 'v0.9.1' -LatestTag 'latest' } -MessagePattern 'Malformed tag' -Name 'a malformed latest tag throws' + Assert-Throws -Action { Get-PromotionAction -Tag 'nope' -LatestTag 'v0.9.1' } -MessagePattern 'Malformed tag' -Name 'a malformed tag throws' +} + +function Test-VersionRecordNeededDecision { + Assert-Equal -Expected $true -Actual (Test-VersionRecordNeeded -MainVersion '0.9.1' -Version '0.9.2') -Name 'a higher version needs a record' + Assert-Equal -Expected $false -Actual (Test-VersionRecordNeeded -MainVersion '0.9.2' -Version '0.9.2') -Name 'an equal version needs no record' + Assert-Equal -Expected $false -Actual (Test-VersionRecordNeeded -MainVersion '0.10.0' -Version '0.9.2') -Name 'a lower version needs no record' + Assert-Equal -Expected $true -Actual (Test-VersionRecordNeeded -MainVersion '0.9.9' -Version '0.10.0') -Name 'the record comparison is numeric' + Assert-Throws -Action { Test-VersionRecordNeeded -MainVersion '0.9' -Version '0.9.2' } -MessagePattern 'Malformed version' -Name 'a malformed main version throws' +} + +function Test-PublisherVersionExistsDecision { + Assert-Equal -Expected $true -Actual (Test-PublisherVersionExists -Output 'ERROR: CodeScene.codescene-vs v0.9.2 already exists.') -Name 'an already exists message is detected' + Assert-Equal -Expected $true -Actual (Test-PublisherVersionExists -Output 'The extension ALREADY EXISTS in the marketplace') -Name 'already exists is matched case-insensitively' + Assert-Equal -Expected $true -Actual (Test-PublisherVersionExists -Output 'Error: Version 0.9.2 of the extension exists') -Name 'a version exists message is detected' + Assert-Equal -Expected $true -Actual (Test-PublisherVersionExists -Output @('Publishing...', 'Failed: the version already exists')) -Name 'multi-line output is searched' + Assert-Equal -Expected $false -Actual (Test-PublisherVersionExists -Output 'ERROR: 401 Unauthorized. Failed to authenticate with the publisher credential.') -Name 'an auth failure is not an existing version' + Assert-Equal -Expected $false -Actual (Test-PublisherVersionExists -Output "Checking version`nThe publisher exists") -Name 'version and exists on different lines are not combined' + Assert-Equal -Expected $false -Actual (Test-PublisherVersionExists -Output '') -Name 'empty output is not an existing version' + Assert-Equal -Expected $false -Actual (Test-PublisherVersionExists -Output $null) -Name 'null output is not an existing version' +} + +function Test-GalleryHasVersionDecision { + $fixture = '{"results":[{"extensions":[{"extensionName":"codescene-vs","versions":[{"version":"0.9.1","flags":"validated"},{"version":"0.9.0","flags":"validated"}]}],"resultMetadata":[]}]}' + $response = $fixture | ConvertFrom-Json + Assert-Equal -Expected $true -Actual (Test-GalleryHasVersion -Response $response -Version '0.9.1') -Name 'a listed gallery version is found' + Assert-Equal -Expected $true -Actual (Test-GalleryHasVersion -Response $response -Version '0.9.0') -Name 'an older listed gallery version is found' + Assert-Equal -Expected $false -Actual (Test-GalleryHasVersion -Response $response -Version '0.9.2') -Name 'an unlisted gallery version is not found' + Assert-Equal -Expected $false -Actual (Test-GalleryHasVersion -Response $response -Version '0.9') -Name 'a version prefix is not a match' + + $none = '{"results":[{"extensions":[],"resultMetadata":[]}]}' | ConvertFrom-Json + Assert-Equal -Expected $false -Actual (Test-GalleryHasVersion -Response $none -Version '0.9.1') -Name 'no gallery extensions has no version' + + $noVersions = '{"results":[{"extensions":[{"extensionName":"codescene-vs"}]}]}' | ConvertFrom-Json + Assert-Equal -Expected $false -Actual (Test-GalleryHasVersion -Response $noVersions -Version '0.9.1') -Name 'an extension without versions has no version' + + $noResults = '{"results":[]}' | ConvertFrom-Json + Assert-Equal -Expected $false -Actual (Test-GalleryHasVersion -Response $noResults -Version '0.9.1') -Name 'no gallery results has no version' + Assert-Equal -Expected $false -Actual (Test-GalleryHasVersion -Response $null -Version '0.9.1') -Name 'a null gallery response has no version' +} + +function Test-MarketplaceOverview { + $baseUrl = 'https://raw.githubusercontent.com/codescene-oss/codescene-vs/main' + $readme = "`n![b](screenshots/b.png)`n[docs](https://codescene.io/docs)`n[local](docs/screenshots/c.md)`n[shot](screenshots-other/d.png)" + $overview = ConvertTo-MarketplaceOverview -Readme $readme + $expected = "`n![b]($baseUrl/screenshots/b.png)`n[docs](https://codescene.io/docs)`n[local](docs/screenshots/c.md)`n[shot](screenshots-other/d.png)" + Assert-Equal -Expected $expected -Actual $overview -Name 'both image syntaxes are rewritten and other links are untouched' + Assert-Equal -Expected 'plain text' -Actual (ConvertTo-MarketplaceOverview -Readme 'plain text') -Name 'text without screenshots is unchanged' + Assert-Equal -Expected $overview -Actual (ConvertTo-MarketplaceOverview -Readme $overview) -Name 'rewriting is idempotent' +} diff --git a/.github/test-release-flow/selection-tests.ps1 b/.github/test-release-flow/selection-tests.ps1 new file mode 100644 index 00000000..71f84aa7 --- /dev/null +++ b/.github/test-release-flow/selection-tests.ps1 @@ -0,0 +1,64 @@ +function New-Release([string]$Tag, [bool]$Draft = $false, [bool]$Prerelease = $false, [int]$Id = 1) { + return [pscustomobject]@{ tag_name = $Tag; draft = $Draft; prerelease = $Prerelease; id = $Id; body = '' } +} + +function Get-TagNames($Releases) { + return (@($Releases | ForEach-Object { $_.tag_name }) -join ',') +} + +function Test-ReleaseSelection { + $releases = @( + (New-Release 'v0.8.7'), + (New-Release 'v0.8.1' $true), + (New-Release 'v0.9.0'), + (New-Release 'v0.9.0-test.015c6a0' $false $true), + (New-Release 'v0.9.1-test.abc1234' $false $false), + (New-Release 'not-a-tag'), + (New-Release 'v0.10.0'), + (New-Release 'v0.9.2' $false $true), + (New-Release 'v0.9.1') + ) + Assert-Equal -Expected 'v0.8.7,v0.9.0,v0.10.0,v0.9.1' -Actual (Get-TagNames (Select-PublishedStableRelease -Releases $releases -ExcludeTag '')) -Name 'published stable skips draft, prerelease, test-shaped and malformed' + Assert-Equal -Expected 'v0.8.7,v0.9.0,v0.10.0' -Actual (Get-TagNames (Select-PublishedStableRelease -Releases $releases -ExcludeTag 'v0.9.1')) -Name 'published stable skips the excluded tag' + Assert-Equal -Expected 'v0.8.7,v0.9.0,v0.10.0,v0.9.1' -Actual (Get-TagNames (Select-PublishedStableRelease -Releases $releases -ExcludeTag 'v9.9.9')) -Name 'excluding an unknown tag changes nothing' + Assert-Equal -Expected 0 -Actual @(Select-PublishedStableRelease -Releases @() -ExcludeTag '').Count -Name 'published stable of an empty list is empty' + Assert-Equal -Expected 0 -Actual @(Select-PublishedStableRelease -Releases $null -ExcludeTag '').Count -Name 'published stable of null is empty' + Assert-Equal -Expected 1 -Actual @(Select-PublishedStableRelease -Releases (New-Release 'v0.9.0') -ExcludeTag '').Count -Name 'published stable accepts a single release' + + Assert-Equal -Expected 'v0.10.0' -Actual (Select-HighestStableRelease -Releases @((New-Release 'v0.8.7'), (New-Release 'v0.9.0'), (New-Release 'v0.10.0'))).tag_name -Name 'highest compares numerically' + Assert-Equal -Expected 'v0.10.0' -Actual (Select-HighestStableRelease -Releases @((New-Release 'v0.10.0'), (New-Release 'v0.9.0'), (New-Release 'v0.8.7'))).tag_name -Name 'highest ignores list order' + Assert-Equal -Expected 'v0.10.0' -Actual (Select-HighestStableRelease -Releases $releases).tag_name -Name 'highest ignores drafts and prereleases' + Assert-Equal -Expected 7 -Actual (Select-HighestStableRelease -Releases @((New-Release 'v0.9.0' $false $false 7))).id -Name 'highest keeps the release object' + Assert-True -Condition ($null -eq (Select-HighestStableRelease -Releases @())) -Name 'highest of an empty list is null' + Assert-True -Condition ($null -eq (Select-HighestStableRelease -Releases @((New-Release 'v0.8.1' $true)))) -Name 'highest of only drafts is null' + + $stable = @((New-Release 'v0.8.7'), (New-Release 'v0.9.0'), (New-Release 'v0.9.1')) + $ancestry = @{ 'v0.8.7' = $true; 'v0.9.0' = $true; 'v0.9.1' = $false } + $isAncestor = { param($Tag) $ancestry[$Tag] }.GetNewClosure() + Assert-Equal -Expected 'v0.9.0' -Actual (Select-PreviousStableRelease -Releases $stable -IsAncestor $isAncestor).tag_name -Name 'previous skips a release that is not an ancestor' + $ancestry = @{ 'v0.8.7' = $false; 'v0.9.0' = $false; 'v0.9.1' = $false } + $none = { param($Tag) $ancestry[$Tag] }.GetNewClosure() + Assert-True -Condition ($null -eq (Select-PreviousStableRelease -Releases $stable -IsAncestor $none)) -Name 'previous is null when nothing is an ancestor' + Assert-True -Condition ($null -eq (Select-PreviousStableRelease -Releases @() -IsAncestor $isAncestor)) -Name 'previous of an empty list is null' + $ancestry = @{ 'v0.8.7' = $true; 'v0.9.0' = $false; 'v0.9.1' = $false } + $oldest = { param($Tag) $ancestry[$Tag] }.GetNewClosure() + Assert-Equal -Expected 'v0.8.7' -Actual (Select-PreviousStableRelease -Releases $stable -IsAncestor $oldest).tag_name -Name 'previous falls back to an older ancestor' + $queried = New-Object System.Collections.Generic.List[string] + $recording = { param($Tag) $queried.Add($Tag); $false }.GetNewClosure() + Select-PreviousStableRelease -Releases @($releases) -IsAncestor $recording | Out-Null + Assert-Equal -Expected 'v0.10.0,v0.9.1,v0.9.0,v0.8.7' -Actual ($queried -join ',') -Name 'previous only queries published stable releases, highest first' +} + +function Test-ComparisonMode { + Assert-Equal -Expected 'fresh' -Actual (Get-ComparisonMode -Kind 'stable' -EventName 'push' -ReleaseExists $false) -Name 'stable push is fresh' + Assert-Equal -Expected 'fresh' -Actual (Get-ComparisonMode -Kind 'stable' -EventName 'push' -ReleaseExists $true) -Name 'stable push is fresh even when a release exists' + Assert-Equal -Expected 'fresh' -Actual (Get-ComparisonMode -Kind 'stable' -EventName 'workflow_dispatch' -ReleaseExists $false) -Name 'stable dispatch without a release is fresh' + Assert-Equal -Expected 'recovery' -Actual (Get-ComparisonMode -Kind 'stable' -EventName 'workflow_dispatch' -ReleaseExists $true) -Name 'stable dispatch with a release is recovery' + foreach ($eventName in @('push', 'workflow_dispatch')) { + foreach ($exists in @($false, $true)) { + Assert-Equal -Expected 'recovery' -Actual (Get-ComparisonMode -Kind 'test' -EventName $eventName -ReleaseExists $exists) -Name "test tag on $eventName (release exists: $exists) is recovery" + } + } + Assert-Throws -Action { Get-ComparisonMode -Kind 'stable' -EventName 'pull_request' -ReleaseExists $false } -MessagePattern 'Unsupported event' -Name 'unsupported event throws' + Assert-Throws -Action { Get-ComparisonMode -Kind 'beta' -EventName 'push' -ReleaseExists $false } -MessagePattern 'Unknown tag kind' -Name 'unknown kind throws' +} diff --git a/.github/test-release-flow/tag-script-tests.ps1 b/.github/test-release-flow/tag-script-tests.ps1 new file mode 100644 index 00000000..9e5a842f --- /dev/null +++ b/.github/test-release-flow/tag-script-tests.ps1 @@ -0,0 +1,209 @@ +function Get-TagScriptPath { + return Join-Path $script:RepositoryRoot 'tag-test-release.ps1' +} + +function Invoke-GitChecked([string]$Directory, [string[]]$Arguments) { + return (Invoke-NativeChecked -FilePath 'git' -ArgumentList (@('-C', $Directory, '-c', 'user.name=release-flow-test', '-c', 'user.email=release-flow-test@example.com') + $Arguments)).Output +} + +function New-TagScriptRepository { + $source = $script:RepositoryRoot + $origin = Join-Path (New-TempDirectory) 'origin.git' + $work = Join-Path (New-TempDirectory) 'work' + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('clone', '--bare', '--local', '--quiet', $source, $origin) | Out-Null + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('clone', '--local', '--quiet', $origin, $work) | Out-Null + New-Item -ItemType Directory -Path (Join-Path $work '.github') -Force | Out-Null + Copy-Item -LiteralPath (Get-TagScriptPath) -Destination (Join-Path $work 'tag-test-release.ps1') -Force + Copy-Item -LiteralPath (Join-Path $script:GitHubDirectory 'release-flow.psm1') -Destination (Join-Path $work '.github\release-flow.psm1') -Force + Copy-Item -LiteralPath (Join-Path $script:GitHubDirectory 'release-flow') -Destination (Join-Path $work '.github') -Recurse -Force + Invoke-GitChecked $work @('add', 'tag-test-release.ps1', '.github/release-flow.psm1', '.github/release-flow') | Out-Null + Invoke-GitChecked $work @('commit', '--quiet', '--allow-empty', '-m', 'test fixture') | Out-Null + $head = Invoke-GitChecked $work @('rev-parse', 'HEAD') + return [pscustomobject]@{ Work = $work; Origin = $origin; Head = $head } +} + +function Invoke-TagScript { + param( + [Parameter(Mandatory)][string]$Directory, + [string[]]$Arguments = @(), + [hashtable]$Environment = @{} + ) + + $cleared = @('GH_TOKEN', 'GITHUB_TOKEN', 'GH_REPO', 'FAKE_GH_LOG', 'FAKE_AUTH', 'FAKE_RELEASES') + $restored = @($cleared) + @($Environment.Keys | Where-Object { $cleared -cnotcontains $_ }) + $saved = @{} + foreach ($name in $restored) { $saved[$name] = [Environment]::GetEnvironmentVariable($name) } + try { + foreach ($name in $cleared) { [Environment]::SetEnvironmentVariable($name, $null) } + foreach ($name in $Environment.Keys) { [Environment]::SetEnvironmentVariable($name, [string]$Environment[$name]) } + $shell = (Get-Process -Id $PID).Path + $script = Join-Path $Directory 'tag-test-release.ps1' + return Invoke-Native -FilePath $shell -ArgumentList (@('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $script) + $Arguments) + } + finally { + foreach ($name in $restored) { [Environment]::SetEnvironmentVariable($name, $saved[$name]) } + } +} + +function Invoke-TagScriptWithFakeGh { + param( + [Parameter(Mandatory)][string]$Directory, + [string[]]$Arguments = @(), + [string]$Releases = '', + [string]$Auth = 'ok' + ) + + $fake = New-FakeGh + $log = Join-Path (New-TempDirectory) 'gh.log' + $environment = @{ FAKE_GH_LOG = $log; FAKE_AUTH = $Auth; FAKE_RELEASES = $Releases; PATH = "$fake;$env:PATH" } + $result = Invoke-TagScript -Directory $Directory -Arguments $Arguments -Environment $environment + $calls = @() + if (Test-Path -LiteralPath $log) { $calls = @((Read-AllText $log) -split "`n" | Where-Object { $_ }) } + return [pscustomobject]@{ Result = $result; Calls = $calls } +} + +function Get-TagScriptReleases { + $items = @( + '{"tag_name":"v0.9.1","draft":false,"prerelease":false}', + '{"tag_name":"v0.9.0","draft":false,"prerelease":false}', + '{"tag_name":"v0.9.2-test.abc1234","draft":false,"prerelease":true}', + '{"tag_name":"v1.0.0","draft":true,"prerelease":false}' + ) + return $items -join ',' +} + +function Get-FixtureTagList($Fixture, [string]$Side) { + return Invoke-GitChecked $Fixture.$Side @('tag', '-l', '--points-at', $Fixture.Head) +} + +function Test-TagTestReleaseScriptPrerequisites { + $emptyPath = New-TempDirectory + $work = New-TempDirectory + Copy-Item -LiteralPath (Get-TagScriptPath) -Destination (Join-Path $work 'tag-test-release.ps1') + $result = Invoke-TagScript -Directory $work -Arguments @('-WhatIf') -Environment @{ PATH = $emptyPath } + Assert-EntryScriptFails $result 'GitHub CLI \(gh\) is required' 'tag-test-release without gh' + Assert-True -Condition ($result.Output -notmatch 'not a git repository') -Name 'tag-test-release without gh fails before any git call' + + $fixture = New-TagScriptRepository + $unauthenticated = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases (Get-TagScriptReleases) -Auth 'fail' + Assert-EntryScriptFails $unauthenticated.Result 'gh is not authenticated' 'tag-test-release with gh logged out' + Assert-Equal -Expected 'auth status' -Actual ($unauthenticated.Calls -join '|') -Name 'tag-test-release with gh logged out stops after the auth check' + + Set-Content -LiteralPath (Join-Path $fixture.Work 'stray.txt') -Value 'dirty' + $dirty = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases (Get-TagScriptReleases) + Assert-EntryScriptFails $dirty.Result 'requires a clean worktree' 'tag-test-release with an untracked file' + Assert-EntryScriptFails $dirty.Result 'stray\.txt' 'tag-test-release names the offending file' + Assert-Equal -Expected 'auth status' -Actual ($dirty.Calls -join '|') -Name 'tag-test-release with a dirty worktree does not load releases' + Remove-Item -LiteralPath (Join-Path $fixture.Work 'stray.txt') + + Add-Content -LiteralPath (Join-Path $fixture.Work 'tag-test-release.ps1') -Value '# edited' + $modified = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases (Get-TagScriptReleases) + Assert-EntryScriptFails $modified.Result 'requires a clean worktree' 'tag-test-release with a modified tracked file' + Invoke-GitChecked $fixture.Work @('checkout', '--', 'tag-test-release.ps1') | Out-Null + + $bogus = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf', '-Bump', 'bogus') -Releases (Get-TagScriptReleases) + Assert-EntryScriptFails $bogus.Result 'does not belong to the set' 'tag-test-release rejects an unknown bump' + Assert-Equal -Expected 0 -Actual $bogus.Calls.Count -Name 'tag-test-release rejects an unknown bump before calling gh' +} + +function Test-TagTestReleaseScriptReleases { + $fixture = New-TagScriptRepository + $none = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases '' + Assert-EntryScriptFails $none.Result 'No published stable release found' 'tag-test-release with no releases' + + $unpublished = '{"tag_name":"v0.9.2-test.abc1234","draft":false,"prerelease":true},{"tag_name":"v1.0.0","draft":true,"prerelease":false},{"tag_name":"v1.1.0","draft":false,"prerelease":true}' + $onlyUnpublished = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases $unpublished + Assert-EntryScriptFails $onlyUnpublished.Result 'No published stable release found' 'tag-test-release with only test, draft and prerelease entries' + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Work') -Name 'tag-test-release without releases tags nothing' + + $sha = Invoke-GitChecked $fixture.Work @('rev-parse', '--short=7', 'HEAD') + $expected = @{ patch = '0.9.2'; minor = '0.10.0'; major = '1.0.0' } + foreach ($bump in @('patch', 'minor', 'major')) { + $run = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf', '-Bump', $bump) -Releases (Get-TagScriptReleases) + $version = $expected[$bump] + Assert-Equal -Expected 0 -Actual $run.Result.ExitCode -Name "tag-test-release -WhatIf -Bump $bump exits zero ($($run.Result.Output))" + Assert-Equal -Expected "Would create tag: v$version-test.$sha (next version $version after v0.9.1)" -Actual $run.Result.Output.Trim() -Name "tag-test-release -WhatIf -Bump $bump output" + } + + $default = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases (Get-TagScriptReleases) + Assert-Equal -Expected "Would create tag: v0.9.2-test.$sha (next version 0.9.2 after v0.9.1)" -Actual $default.Result.Output.Trim() -Name 'tag-test-release defaults to a patch bump' + + $longSha = Invoke-GitChecked $fixture.Work @('rev-parse', '--short=10', 'HEAD') + $long = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf', '-ShaLength', '10') -Releases (Get-TagScriptReleases) + Assert-Equal -Expected "Would create tag: v0.9.2-test.$longSha (next version 0.9.2 after v0.9.1)" -Actual $long.Result.Output.Trim() -Name 'tag-test-release honours -ShaLength' + + $short = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf', '-ShaLength', '4') -Releases (Get-TagScriptReleases) + Assert-EntryScriptFails $short.Result 'Malformed tag' 'tag-test-release with a sha length below seven' + + $ordering = '{"tag_name":"v0.9.9","draft":false,"prerelease":false},{"tag_name":"v0.10.0","draft":false,"prerelease":false}' + $numeric = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases $ordering + Assert-Equal -Expected "Would create tag: v0.10.1-test.$sha (next version 0.10.1 after v0.10.0)" -Actual $numeric.Result.Output.Trim() -Name 'tag-test-release compares versions numerically' + + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Work') -Name 'tag-test-release -WhatIf creates no local tag' + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Origin') -Name 'tag-test-release -WhatIf pushes nothing' +} + +function Test-TagTestReleaseScriptTagging { + $fixture = New-TagScriptRepository + $sha = Invoke-GitChecked $fixture.Work @('rev-parse', '--short=7', 'HEAD') + $head = Invoke-GitChecked $fixture.Work @('rev-parse', 'HEAD') + $tag = "v0.9.2-test.$sha" + + $noPush = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-NoPush') -Releases (Get-TagScriptReleases) + Assert-Equal -Expected 0 -Actual $noPush.Result.ExitCode -Name "tag-test-release -NoPush exits zero ($($noPush.Result.Output))" + Assert-Equal -Expected $tag -Actual (Get-FixtureTagList $fixture 'Work') -Name 'tag-test-release -NoPush creates the local tag' + Assert-Equal -Expected 'tag' -Actual (Invoke-GitChecked $fixture.Work @('cat-file', '-t', $tag)) -Name 'the test tag is annotated' + Assert-Equal -Expected $head -Actual (Invoke-GitChecked $fixture.Work @('rev-parse', "$tag^{commit}")) -Name 'the test tag points at HEAD' + Assert-Equal -Expected "Test release 0.9.2 ($sha)" -Actual (Invoke-GitChecked $fixture.Work @('tag', '-l', '--format=%(contents:subject)', $tag)) -Name 'the test tag message' + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Origin') -Name 'tag-test-release -NoPush leaves origin untouched' + Assert-True -Condition ($noPush.Result.Output -match 'Skipped push') -Name 'tag-test-release -NoPush reports the skipped push' + + $again = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases (Get-TagScriptReleases) + Assert-EntryScriptFails $again.Result "Tag '$tag' already exists locally" 'tag-test-release with the tag present locally' + + Invoke-GitChecked $fixture.Work @('tag', '-d', $tag) | Out-Null + $pushed = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Releases (Get-TagScriptReleases) + Assert-Equal -Expected 0 -Actual $pushed.Result.ExitCode -Name "tag-test-release pushes by default ($($pushed.Result.Output))" + Assert-Equal -Expected $tag -Actual (Get-FixtureTagList $fixture 'Origin') -Name 'tag-test-release pushes the tag to origin' + + Invoke-GitChecked $fixture.Work @('tag', '-d', $tag) | Out-Null + $remote = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases (Get-TagScriptReleases) + Assert-EntryScriptFails $remote.Result "Tag '$tag' already exists on origin" 'tag-test-release with the tag present on origin' + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Work') -Name 'tag-test-release with the tag on origin creates nothing locally' + + Invoke-GitChecked $fixture.Work @('remote', 'set-url', 'origin', (Join-Path (New-TempDirectory) 'missing.git')) | Out-Null + $unreachable = Invoke-TagScriptWithFakeGh -Directory $fixture.Work -Arguments @('-WhatIf') -Releases (Get-TagScriptReleases) + Assert-EntryScriptFails $unreachable.Result 'Failed to check remote' 'tag-test-release with an unreachable origin' + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Work') -Name 'tag-test-release with an unreachable origin creates nothing' +} + +function Test-TagTestReleaseScriptIntegration { + $token = Get-GitHubTokenForTests + if (-not $token) { + Write-Host 'skipping tag-test-release GitHub integration: no GitHub token available' + return + } + $env:GH_TOKEN = $token + try { + $releases = @(Get-GitHubRelease -Repository 'codescene-oss/codescene-vs') + } + finally { + $env:GH_TOKEN = $null + } + $highest = Select-HighestStableRelease -Releases $releases + if (-not $highest) { + Write-Host 'skipping tag-test-release GitHub integration: no published stable release' + return + } + $current = (Get-ReleaseTagInfo -Tag $highest.tag_name).Version + $fixture = New-TagScriptRepository + $sha = Invoke-GitChecked $fixture.Work @('rev-parse', '--short=7', 'HEAD') + foreach ($bump in @('patch', 'minor', 'major')) { + $next = Get-NextReleaseVersion -Version $current -Bump $bump + $run = Invoke-TagScript -Directory $fixture.Work -Arguments @('-WhatIf', '-Bump', $bump) -Environment @{ GH_TOKEN = $token } + Assert-Equal -Expected 0 -Actual $run.ExitCode -Name "real tag-test-release -WhatIf -Bump $bump exits zero ($($run.Output))" + Assert-Equal -Expected "Would create tag: v$next-test.$sha (next version $next after $($highest.tag_name))" -Actual $run.Output.Trim() -Name "real tag-test-release -WhatIf -Bump $bump output" + } + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Work') -Name 'real tag-test-release -WhatIf creates no local tag' + Assert-Equal -Expected '' -Actual (Get-FixtureTagList $fixture 'Origin') -Name 'real tag-test-release -WhatIf pushes nothing' +} diff --git a/.github/test-release-flow/tag-tests.ps1 b/.github/test-release-flow/tag-tests.ps1 new file mode 100644 index 00000000..15833646 --- /dev/null +++ b/.github/test-release-flow/tag-tests.ps1 @@ -0,0 +1,68 @@ +function Assert-TagAccepted([string]$Tag, [string]$Kind, [string]$Version, $Sha) { + $info = Get-ReleaseTagInfo -Tag $Tag + Assert-True -Condition ($info.PSObject.Properties.Name -join ',' -ceq 'Tag,Kind,Version,Sha') -Name "$Tag has the documented shape" + Assert-Equal -Expected $Tag -Actual $info.Tag -Name "$Tag tag" + Assert-Equal -Expected $Kind -Actual $info.Kind -Name "$Tag kind" + Assert-Equal -Expected $Version -Actual $info.Version -Name "$Tag version" + Assert-Equal -Expected $Sha -Actual $info.Sha -Name "$Tag sha" +} + +function Assert-TagRejected([string]$Tag) { + Assert-Throws -Action { Get-ReleaseTagInfo -Tag $Tag }.GetNewClosure() -MessagePattern 'Malformed tag' -Name "reject tag '$Tag'" +} + +function Test-ReleaseTagInfo { + $sha40 = '0123456789abcdef0123456789abcdef01234567' + Assert-TagAccepted 'v0.9.1' 'stable' '0.9.1' $null + Assert-TagAccepted 'v10.0.0' 'stable' '10.0.0' $null + Assert-TagAccepted 'v0.9.2-test.abc1234' 'test' '0.9.2' 'abc1234' + Assert-TagAccepted "v1.2.3-test.$sha40" 'test' '1.2.3' $sha40 + Assert-TagAccepted 'v65534.0.0' 'stable' '65534.0.0' $null + Assert-TagAccepted 'v0.65534.65534' 'stable' '0.65534.65534' $null + Assert-TagAccepted 'v0.0.0' 'stable' '0.0.0' $null + + foreach ($tag in @( + 'v1.2', 'v1.2.3.4', '1.2.3', 'v01.2.3', 'v1.02.3', 'v1.2.03', 'v1.2.3-beta', + 'v1.2.3-test.', 'v1.2.3-test', 'v1.2.3-test.abc123', "v1.2.3-test.${sha40}0", + 'v1.2.3-test.ABC1234', 'v1.2.3-test.abc123g', 'v65535.0.0', 'v0.65535.0', 'v0.0.65535', + 'v0.0.99999999999999999999', 'V1.2.3', 'v1.2.3 ', ' v1.2.3', "v1.2.3`n", 'v-1.2.3', 'v1.2.3-test.abc1234-x')) { + Assert-TagRejected $tag + } + Assert-Throws -Action { Get-ReleaseTagInfo -Tag 'v1.2' } -MessagePattern 'vX\.Y\.Z.*vX\.Y\.Z-test' -Name 'malformed tag message names both shapes' +} + +function Test-CompareReleaseVersion { + Assert-Equal -Expected 1 -Actual (Compare-ReleaseVersion -Left '0.10.0' -Right '0.9.9') -Name '0.10.0 > 0.9.9' + Assert-Equal -Expected 1 -Actual (Compare-ReleaseVersion -Left '1.0.0' -Right '0.99.99') -Name '1.0.0 > 0.99.99' + Assert-Equal -Expected 0 -Actual (Compare-ReleaseVersion -Left '0.9.1' -Right '0.9.1') -Name '0.9.1 == 0.9.1' + Assert-Equal -Expected -1 -Actual (Compare-ReleaseVersion -Left '0.9.0' -Right '0.9.1') -Name '0.9.0 < 0.9.1' + Assert-Equal -Expected -1 -Actual (Compare-ReleaseVersion -Left '0.9.9' -Right '0.10.0') -Name '0.9.9 < 0.10.0' + Assert-Equal -Expected -1 -Actual (Compare-ReleaseVersion -Left '9.0.0' -Right '10.0.0') -Name '9.0.0 < 10.0.0' + Assert-Throws -Action { Compare-ReleaseVersion -Left '1.2' -Right '1.2.3' } -MessagePattern 'Malformed version' -Name 'compare rejects short left' + Assert-Throws -Action { Compare-ReleaseVersion -Left '1.2.3' -Right 'v1.2.3' } -MessagePattern 'Malformed version' -Name 'compare rejects v prefix' +} + +function Test-NextReleaseVersion { + Assert-Equal -Expected '0.9.2' -Actual (Get-NextReleaseVersion -Version '0.9.1' -Bump 'patch') -Name 'bump patch' + Assert-Equal -Expected '0.10.0' -Actual (Get-NextReleaseVersion -Version '0.9.1' -Bump 'minor') -Name 'bump minor' + Assert-Equal -Expected '1.0.0' -Actual (Get-NextReleaseVersion -Version '0.9.1' -Bump 'major') -Name 'bump major' + Assert-Equal -Expected '1.2.4' -Actual (Get-NextReleaseVersion -Version '1.2.3' -Bump 'patch') -Name 'bump patch keeps higher components' + Assert-Equal -Expected '1.3.0' -Actual (Get-NextReleaseVersion -Version '1.2.3' -Bump 'minor') -Name 'bump minor resets patch' + Assert-Equal -Expected '2.0.0' -Actual (Get-NextReleaseVersion -Version '1.2.3' -Bump 'major') -Name 'bump major resets minor and patch' + Assert-Throws -Action { Get-NextReleaseVersion -Version '0.9.1' -Bump 'huge' } -MessagePattern 'Invalid bump' -Name 'invalid bump throws' + Assert-Throws -Action { Get-NextReleaseVersion -Version '0.9.1' -Bump 'Patch' } -MessagePattern 'Invalid bump' -Name 'bump is case-sensitive' + Assert-Throws -Action { Get-NextReleaseVersion -Version '0.9' -Bump 'patch' } -MessagePattern 'Malformed version' -Name 'bump rejects malformed version' + Assert-Throws -Action { Get-NextReleaseVersion -Version '0.0.65534' -Bump 'patch' } -MessagePattern 'at most 65534' -Name 'bump past the component limit throws' +} + +function Test-TestReleaseTagName { + Assert-Equal -Expected 'v0.9.2-test.abc1234' -Actual (Get-TestReleaseTagName -Version '0.9.2' -ShortSha 'abc1234') -Name 'the test tag name' + $info = Get-ReleaseTagInfo -Tag (Get-TestReleaseTagName -Version '0.9.2' -ShortSha 'abc1234') + Assert-Equal -Expected 'test' -Actual $info.Kind -Name 'the test tag name parses as a test tag' + Assert-Equal -Expected '0.9.2' -Actual $info.Version -Name 'the test tag name round-trips the version' + Assert-Equal -Expected 'abc1234' -Actual $info.Sha -Name 'the test tag name round-trips the sha' + Assert-Equal -Expected 'v1.0.0-test.0123456789' -Actual (Get-TestReleaseTagName -Version '1.0.0' -ShortSha '0123456789') -Name 'the test tag name accepts a longer sha' + Assert-Throws -Action { Get-TestReleaseTagName -Version '0.9.2' -ShortSha 'abc12' } -MessagePattern 'Malformed tag' -Name 'a sha shorter than seven characters is rejected' + Assert-Throws -Action { Get-TestReleaseTagName -Version '0.9.2' -ShortSha 'ABC1234' } -MessagePattern 'Malformed tag' -Name 'an uppercase sha is rejected' + Assert-Throws -Action { Get-TestReleaseTagName -Version '0.9' -ShortSha 'abc1234' } -MessagePattern 'Malformed tag' -Name 'a malformed version is rejected' +} diff --git a/.github/test-release-flow/version-command-tests.ps1 b/.github/test-release-flow/version-command-tests.ps1 new file mode 100644 index 00000000..ea156126 --- /dev/null +++ b/.github/test-release-flow/version-command-tests.ps1 @@ -0,0 +1,121 @@ +function Test-SetVersionIntegration { + $repository = $script:RepositoryRoot + $paths = Get-VersionFilePaths -RepositoryPath $repository + $before = @($paths.Manifest, $paths.Source | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) -join ',' + $version = Get-ReleaseVersion -RepositoryPath $repository + $gitBefore = (Invoke-NativeChecked -FilePath 'git' -ArgumentList @('status', '--porcelain', '--', $paths.Manifest, $paths.Source)).Output + + $result = Invoke-EntryScript -Command 'set-version' -Environment @{ VERSION = $version } + Assert-Equal -Expected 0 -Actual $result.ExitCode -Name "set-version with the committed version exits zero ($($result.Output))" + $after = @($paths.Manifest, $paths.Source | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) -join ',' + Assert-Equal -Expected $before -Actual $after -Name 'set-version with the committed version leaves both files unchanged' + $gitAfter = (Invoke-NativeChecked -FilePath 'git' -ArgumentList @('status', '--porcelain', '--', $paths.Manifest, $paths.Source)).Output + Assert-Equal -Expected $gitBefore -Actual $gitAfter -Name 'git status for the version files is unchanged' + + $bad = Invoke-EntryScript -Command 'set-version' -Environment @{ VERSION = 'v1.2.3' } + Assert-EntryScriptFails $bad 'Malformed version' 'set-version with a malformed version' + Assert-Equal -Expected $before -Actual (@($paths.Manifest, $paths.Source | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) -join ',') -Name 'a rejected version leaves both files unchanged' + + $root = New-VersionRepository + $elsewhere = Invoke-EntryScript -Command 'set-version' -Environment @{ VERSION = '7.8.9'; REPO_PATH = $root } -WorkingDirectory (New-TempDirectory) + Assert-Equal -Expected 0 -Actual $elsewhere.ExitCode -Name "set-version honours REPO_PATH from another directory ($($elsewhere.Output))" + Assert-Equal -Expected '7.8.9' -Actual (Get-ReleaseVersion -RepositoryPath $root) -Name 'REPO_PATH decides which tree is changed' + Assert-Equal -Expected $before -Actual (@($paths.Manifest, $paths.Source | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) -join ',') -Name 'the working tree is untouched when REPO_PATH points elsewhere' +} + +function New-VerifyTree { + $root = New-TempDirectory + $directory = Join-Path $root 'Codescene.VSExtension.VS2022' + New-Item -ItemType Directory -Path $directory | Out-Null + return [pscustomobject]@{ Root = $root; Directory = $directory } +} + +function Invoke-VerifyVsix([string]$Root, [string]$Version) { + $output = Join-Path (New-TempDirectory) 'output.txt' + $result = Invoke-EntryScript -Command 'verify-vsix' -Environment @{ VERSION = $Version; REPO_PATH = $Root; GITHUB_OUTPUT = $output } + return [pscustomobject]@{ Result = $result; Outputs = (Read-ActionOutput $output) } +} + +function Test-VerifyVsixIntegration { + $none = New-VerifyTree + $missing = Invoke-VerifyVsix $none.Root '0.9.1' + Assert-EntryScriptFails $missing.Result 'No \.vsix file found' 'verify-vsix with no vsix' + Assert-Equal -Expected 0 -Actual $missing.Outputs.Count -Name 'verify-vsix with no vsix writes no output' + + $single = New-VerifyTree + $vsix = New-VsixFile $single.Directory 'one.vsix' '0.9.1' + $match = Invoke-VerifyVsix $single.Root '0.9.1' + Assert-Equal -Expected 0 -Actual $match.Result.ExitCode -Name "verify-vsix accepts a matching version ($($match.Result.Output))" + Assert-Equal -Expected $vsix -Actual $match.Outputs['path'] -Name 'verify-vsix writes the vsix path' + $mismatch = Invoke-VerifyVsix $single.Root '0.9.2' + Assert-EntryScriptFails $mismatch.Result 'VSIX version mismatch.*0\.9\.1.*0\.9\.2' 'verify-vsix with a differing version' + Assert-Equal -Expected 0 -Actual $mismatch.Outputs.Count -Name 'verify-vsix with a differing version writes no output' + + $nested = New-VerifyTree + New-Item -ItemType Directory -Path (Join-Path $nested.Directory 'Codescene.VSExtension.VS2022\bin\Release') -Force | Out-Null + $nestedVsix = New-VsixFile (Join-Path $nested.Directory 'Codescene.VSExtension.VS2022\bin\Release') 'Codescene.VSExtension.VS2022.vsix' '0.9.1' + $found = Invoke-VerifyVsix $nested.Root '0.9.1' + Assert-Equal -Expected $nestedVsix -Actual $found.Outputs['path'] -Name 'verify-vsix finds a vsix in the nested build output' + + $two = New-VerifyTree + New-VsixFile $two.Directory 'a.vsix' '0.9.1' | Out-Null + New-VsixFile $two.Directory 'b.vsix' '0.9.1' | Out-Null + $many = Invoke-VerifyVsix $two.Root '0.9.1' + Assert-EntryScriptFails $many.Result 'exactly one \.vsix.*found 2' 'verify-vsix with two vsix files' + + $repository = $script:RepositoryRoot + $built = Join-Path $repository 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\bin\Release\Codescene.VSExtension.VS2022.vsix' + if (Test-Path -LiteralPath $built) { + $real = New-VerifyTree + Copy-Item -LiteralPath $built -Destination $real.Directory + $embedded = Get-VsixManifestVersion -Path $built + $realMatch = Invoke-VerifyVsix $real.Root $embedded + Assert-Equal -Expected 0 -Actual $realMatch.Result.ExitCode -Name "verify-vsix accepts the local Release build ($($realMatch.Result.Output))" + Assert-Equal -Expected (Join-Path $real.Directory (Split-Path -Leaf $built)) -Actual $realMatch.Outputs['path'] -Name 'verify-vsix writes the path of the local Release build' + $other = if ($embedded -ceq '0.9.2') { '0.9.1' } else { '0.9.2' } + Assert-EntryScriptFails (Invoke-VerifyVsix $real.Root $other).Result 'VSIX version mismatch' 'verify-vsix with the local Release build and another version' + } + else { + Write-Host 'skipping local Release vsix check: no local Release build' + } +} + +function Get-TreeSnapshot([string]$Root) { + $snapshot = @{} + foreach ($file in Get-ChildItem -LiteralPath $Root -Recurse -File -Force) { + $snapshot[$file.FullName.Substring($Root.Length)] = "$($file.Length):$($file.LastWriteTimeUtc.Ticks)" + } + return $snapshot +} + +function Test-HelperIndependentOfTaggedTree { + if (-not (Get-TagCommit -Tag 'v0.9.1')) { + Write-Host 'skipping archive check: tag v0.9.1 is not available locally' + return + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $work = New-TempDirectory + $zip = Join-Path $work 'tree.zip' + $tree = Join-Path $work 'tree' + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('archive', '--format=zip', '-o', $zip, 'v0.9.1') | Out-Null + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $tree) + Assert-True -Condition (-not (Test-Path -LiteralPath (Join-Path $tree '.github\release-flow.ps1'))) -Name 'the archived tree has no release-flow.ps1' + Assert-Equal -Expected '0.9.1' -Actual (Get-ReleaseVersion -RepositoryPath $tree) -Name 'the archived tree carries version 0.9.1' + + $repository = $script:RepositoryRoot + $paths = Get-VersionFilePaths -RepositoryPath $tree + $committed = Get-VersionFilePaths -RepositoryPath $repository + $committedBefore = @($committed.Manifest, $committed.Source | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) -join ',' + $before = Get-TreeSnapshot $tree + $result = Invoke-EntryScript -Command 'set-version' -Environment @{ VERSION = '0.9.2'; REPO_PATH = $tree } -WorkingDirectory $repository + Assert-Equal -Expected 0 -Actual $result.ExitCode -Name "set-version works on a tree without the helper ($($result.Output))" + Assert-Equal -Expected '0.9.2' -Actual (Get-ReleaseVersion -RepositoryPath $tree) -Name 'the archived manifest is updated' + Assert-True -Condition ((Read-AllText $paths.Source).Contains('Version = "0.9.2";')) -Name 'the archived source is updated' + + $after = Get-TreeSnapshot $tree + Assert-Equal -Expected $before.Count -Actual $after.Count -Name 'no file is added to or removed from the archived tree' + $changed = @($after.Keys | Where-Object { $before[$_] -cne $after[$_] } | Sort-Object) + $expected = @($paths.Manifest, $paths.Source | ForEach-Object { $_.Substring($tree.Length) } | Sort-Object) + Assert-Equal -Expected ($expected -join '|') -Actual ($changed -join '|') -Name 'only the two version files change in the archived tree' + Assert-Equal -Expected $committedBefore -Actual (@($committed.Manifest, $committed.Source | ForEach-Object { (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash }) -join ',') -Name 'the working tree version files are untouched' +} diff --git a/.github/test-release-flow/version-file-tests.ps1 b/.github/test-release-flow/version-file-tests.ps1 new file mode 100644 index 00000000..a9c79697 --- /dev/null +++ b/.github/test-release-flow/version-file-tests.ps1 @@ -0,0 +1,201 @@ +function New-VersionRepository { + $root = New-TempDirectory + $real = Get-VersionFilePaths -RepositoryPath $script:RepositoryRoot + $copy = Get-VersionFilePaths -RepositoryPath $root + New-Item -ItemType Directory -Path (Split-Path -Parent $copy.Manifest) | Out-Null + Copy-Item -LiteralPath $real.Manifest -Destination $copy.Manifest + Copy-Item -LiteralPath $real.Source -Destination $copy.Source + return $root +} + +function Read-AllText([string]$Path) { + return (New-Object System.Text.UTF8Encoding($false)).GetString([IO.File]::ReadAllBytes($Path)) +} + +function Write-AllText([string]$Path, [string]$Text) { + [IO.File]::WriteAllBytes($Path, (New-Object System.Text.UTF8Encoding($false)).GetBytes($Text)) +} + +function Get-DifferingLineCount([string]$Left, [string]$Right) { + $leftLines = $Left -split "`n" + $rightLines = $Right -split "`n" + Assert-Equal -Expected $leftLines.Count -Actual $rightLines.Count -Name 'compared texts have the same line count' + $count = 0 + for ($i = 0; $i -lt $leftLines.Count; $i++) { + if ($leftLines[$i] -cne $rightLines[$i]) { $count++ } + } + return $count +} + +function Get-CarriageReturnCount([byte[]]$Bytes) { + return @($Bytes | Where-Object { $_ -eq 13 }).Count +} + +function New-VsixFile([string]$Directory, [string]$Name, [string]$Version) { + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $path = Join-Path $Directory $Name + $manifest = "" + $archive = [System.IO.Compression.ZipFile]::Open($path, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($item in @(@('extension.vsixmanifest', $manifest), @('other.txt', 'x'))) { + $entry = $archive.CreateEntry($item[0]) + $writer = New-Object System.IO.StreamWriter($entry.Open()) + try { + $writer.Write($item[1]) + } + finally { + $writer.Dispose() + } + } + } + finally { + $archive.Dispose() + } + return $path +} + +function Test-VersionFilePaths { + $paths = Get-VersionFilePaths -RepositoryPath 'C:\repo' + Assert-Equal -Expected 'C:\repo\Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\source.extension.vsixmanifest' -Actual $paths.Manifest -Name 'manifest path' + Assert-Equal -Expected 'C:\repo\Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\source.extension.cs' -Actual $paths.Source -Name 'source path' + $real = Get-VersionFilePaths -RepositoryPath $script:RepositoryRoot + Assert-True -Condition (Test-Path -LiteralPath $real.Manifest) -Name 'the manifest path exists in this repository' + Assert-True -Condition (Test-Path -LiteralPath $real.Source) -Name 'the source path exists in this repository' +} + +function Test-ReleaseVersionFiles { + $root = New-VersionRepository + $paths = Get-VersionFilePaths -RepositoryPath $root + $original = Get-ReleaseVersion -RepositoryPath $root + Assert-True -Condition ($original -cmatch '^\d+\.\d+\.\d+$') -Name 'the committed manifest version is well formed' + $manifestBefore = Read-AllText $paths.Manifest + $sourceBefore = Read-AllText $paths.Source + + Set-ReleaseVersion -RepositoryPath $root -Version '1.2.3' + + Assert-Equal -Expected '1.2.3' -Actual (Get-ReleaseVersion -RepositoryPath $root) -Name 'get returns the rewritten version' + $manifestAfter = Read-AllText $paths.Manifest + $sourceAfter = Read-AllText $paths.Source + Assert-Equal -Expected 1 -Actual (Get-DifferingLineCount $manifestBefore $manifestAfter) -Name 'manifest rewrite changes exactly one line' + Assert-Equal -Expected 1 -Actual (Get-DifferingLineCount $sourceBefore $sourceAfter) -Name 'source rewrite changes exactly one line' + Assert-Equal -Expected $manifestBefore -Actual $manifestAfter.Replace("Version=`"1.2.3`" Language", "Version=`"$original`" Language") -Name 'manifest text elsewhere is identical' + Assert-Equal -Expected $sourceBefore -Actual $sourceAfter.Replace('Version = "1.2.3";', "Version = `"$original`";") -Name 'source text elsewhere is identical' + Assert-True -Condition ($manifestAfter.Contains('')) -Name 'InstallationTarget version is untouched' + Assert-True -Condition ($manifestAfter.Contains('Version="[17.14,)"')) -Name 'Prerequisite version is untouched' + Assert-True -Condition ($sourceAfter.EndsWith($sourceBefore.Substring($sourceBefore.IndexOf('public const string Author')))) -Name 'source tail including trailing blank lines is preserved' + + Set-ReleaseVersion -RepositoryPath $root -Version $original + Assert-True -Condition ($manifestBefore -ceq (Read-AllText $paths.Manifest)) -Name 'restoring the version restores the manifest' + Assert-True -Condition ($sourceBefore -ceq (Read-AllText $paths.Source)) -Name 'restoring the version restores the source' +} + +function Test-ReleaseVersionPreservesEncoding { + $root = New-VersionRepository + $paths = Get-VersionFilePaths -RepositoryPath $root + $manifestBytes = [IO.File]::ReadAllBytes($paths.Manifest) + Assert-True -Condition ($manifestBytes[0] -ne 0xEF) -Name 'the manifest fixture has no BOM' + $sourceBefore = [IO.File]::ReadAllBytes($paths.Source) + Assert-True -Condition ((Get-CarriageReturnCount $sourceBefore) -gt 0) -Name 'the source fixture uses CRLF' + + Set-ReleaseVersion -RepositoryPath $root -Version '2.0.0' + + $manifestAfter = [IO.File]::ReadAllBytes($paths.Manifest) + Assert-True -Condition ($manifestAfter[0] -ne 0xEF) -Name 'no BOM is added to the manifest' + $sourceAfter = [IO.File]::ReadAllBytes($paths.Source) + Assert-Equal -Expected (Get-CarriageReturnCount $sourceBefore) -Actual (Get-CarriageReturnCount $sourceAfter) -Name 'CRLF count is preserved' + Assert-True -Condition ($sourceAfter[0] -ne 0xEF) -Name 'no BOM is added to the source' + + $bomRoot = New-VersionRepository + $bomPaths = Get-VersionFilePaths -RepositoryPath $bomRoot + [IO.File]::WriteAllBytes($bomPaths.Manifest, [byte[]](0xEF, 0xBB, 0xBF) + [IO.File]::ReadAllBytes($bomPaths.Manifest)) + Set-ReleaseVersion -RepositoryPath $bomRoot -Version '2.0.0' + $bomAfter = [IO.File]::ReadAllBytes($bomPaths.Manifest) + Assert-True -Condition ($bomAfter[0] -eq 0xEF -and $bomAfter[1] -eq 0xBB -and $bomAfter[2] -eq 0xBF) -Name 'an existing BOM is preserved' + Assert-Equal -Expected '2.0.0' -Actual (Get-ReleaseVersion -RepositoryPath $bomRoot) -Name 'a manifest with a BOM is still read' +} + +function Test-ReleaseVersionFailures { + $duplicated = New-VersionRepository + $duplicatedPaths = Get-VersionFilePaths -RepositoryPath $duplicated + $source = Read-AllText $duplicatedPaths.Source + $line = ([regex]::Match($source, 'public const string Version = "[^"]*";\r?\n')).Value + Write-AllText $duplicatedPaths.Source $source.Replace($line, $line + $line) + Assert-Throws -Action { Set-ReleaseVersion -RepositoryPath $duplicated -Version '1.2.3' } -MessagePattern 'expected exactly one version' -Name 'a duplicated source version throws' + + $noSource = New-VersionRepository + $noSourcePaths = Get-VersionFilePaths -RepositoryPath $noSource + Write-AllText $noSourcePaths.Source ((Read-AllText $noSourcePaths.Source) -replace 'public const string Version = "[^"]*";', '') + Assert-Throws -Action { Set-ReleaseVersion -RepositoryPath $noSource -Version '1.2.3' } -MessagePattern 'expected exactly one version' -Name 'a missing source version throws' + + $noIdentity = New-VersionRepository + $noIdentityPaths = Get-VersionFilePaths -RepositoryPath $noIdentity + Write-AllText $noIdentityPaths.Manifest ((Read-AllText $noIdentityPaths.Manifest) -replace '(]*?)\sVersion="[^"]*"', '$1') + Assert-Throws -Action { Get-ReleaseVersion -RepositoryPath $noIdentity } -MessagePattern 'No Identity version' -Name 'a manifest without an Identity version throws on read' + Assert-Throws -Action { Set-ReleaseVersion -RepositoryPath $noIdentity -Version '1.2.3' } -MessagePattern 'expected exactly one version' -Name 'a manifest without an Identity version throws on write' + + $emptyIdentity = New-VersionRepository + $emptyPaths = Get-VersionFilePaths -RepositoryPath $emptyIdentity + Write-AllText $emptyPaths.Manifest ((Read-AllText $emptyPaths.Manifest) -replace '(]*?\sVersion=")[^"]*"', '$1"') + Assert-Throws -Action { Get-ReleaseVersion -RepositoryPath $emptyIdentity } -MessagePattern 'No Identity version' -Name 'an empty Identity version throws on read' + + $malformed = New-VersionRepository + $malformedPaths = Get-VersionFilePaths -RepositoryPath $malformed + $before = [IO.File]::ReadAllBytes($malformedPaths.Manifest) + Assert-Throws -Action { Set-ReleaseVersion -RepositoryPath $malformed -Version 'v1.2.3' } -MessagePattern 'Malformed version' -Name 'a malformed version is rejected' + Assert-Throws -Action { Set-ReleaseVersion -RepositoryPath $malformed -Version '1.2.65535' } -MessagePattern 'Malformed version' -Name 'an over-limit version is rejected' + Assert-Equal -Expected ($before -join ',') -Actual ([IO.File]::ReadAllBytes($malformedPaths.Manifest) -join ',') -Name 'a rejected version leaves the manifest untouched' +} + +function Test-VsixVerification { + $directory = New-TempDirectory + $vsix = New-VsixFile $directory 'a.vsix' '0.9.2' + Assert-Equal -Expected '0.9.2' -Actual (Get-VsixManifestVersion -Path $vsix) -Name 'vsix manifest version is read' + Assert-VsixVersion -Path $vsix -Version '0.9.2' + $script:Passed++ + Assert-Throws -Action { Assert-VsixVersion -Path $vsix -Version '0.9.3' } -MessagePattern 'mismatch.*0\.9\.2.*0\.9\.3' -Name 'a differing vsix version throws' + + $plain = Join-Path $directory 'plain.zip' + $archive = [System.IO.Compression.ZipFile]::Open($plain, [System.IO.Compression.ZipArchiveMode]::Create) + $archive.CreateEntry('readme.txt') | Out-Null + $archive.Dispose() + Assert-Throws -Action { Get-VsixManifestVersion -Path $plain } -MessagePattern 'does not contain extension\.vsixmanifest' -Name 'an archive without a manifest throws' +} + +function Test-FindSingleVsix { + $empty = New-TempDirectory + Assert-Throws -Action { Find-SingleVsix -Root $empty } -MessagePattern 'No \.vsix' -Name 'no vsix throws' + + $one = New-TempDirectory + New-Item -ItemType Directory -Path (Join-Path $one 'nested\deeper') | Out-Null + $file = Join-Path $one 'nested\deeper\x.vsix' + Set-Content -LiteralPath $file -Value 'x' + Set-Content -LiteralPath (Join-Path $one 'x.vsix.txt') -Value 'x' + Set-Content -LiteralPath (Join-Path $one 'notes.txt') -Value 'x' + Assert-Equal -Expected $file -Actual (Find-SingleVsix -Root $one) -Name 'a single nested vsix is returned' + + $two = New-TempDirectory + Set-Content -LiteralPath (Join-Path $two 'a.vsix') -Value 'x' + New-Item -ItemType Directory -Path (Join-Path $two 'sub') | Out-Null + Set-Content -LiteralPath (Join-Path $two 'sub\b.vsix') -Value 'x' + Assert-Throws -Action { Find-SingleVsix -Root $two } -MessagePattern 'exactly one \.vsix.*found 2' -Name 'two vsix files throw' +} + +function Test-BuiltVsixIntegration { + $repository = $script:RepositoryRoot + $built = Join-Path $repository 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\bin\Release\Codescene.VSExtension.VS2022.vsix' + if (-not (Test-Path -LiteralPath $built)) { + Write-Host 'skipping built vsix check: no local Release build' + return + } + $embedded = Get-VsixManifestVersion -Path $built + Assert-True -Condition ($embedded -cmatch '^\d+\.\d+\.\d+$') -Name 'the built vsix embeds a well formed version' + $committed = Get-ReleaseVersion -RepositoryPath $repository + if ($embedded -cne $committed) { + Write-Host "built vsix embeds $embedded but the committed version is $committed (stale local build)" + return + } + Assert-VsixVersion -Path $built -Version $committed + $script:Passed++ +} diff --git a/.github/test-release-flow/version-record-tests.ps1 b/.github/test-release-flow/version-record-tests.ps1 new file mode 100644 index 00000000..577d0f9f --- /dev/null +++ b/.github/test-release-flow/version-record-tests.ps1 @@ -0,0 +1,124 @@ +function New-RecordFixture { + $work = New-TempDirectory + $origin = Join-Path $work 'origin.git' + $seed = New-VersionRepository + Set-ReleaseVersion -RepositoryPath $seed -Version '0.9.1' + $clone = Join-Path $work 'clone' + $identity = @('-c', 'user.name=Seeder', '-c', 'user.email=seed@example.com') + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('init', '--bare', '--initial-branch=main', $origin) | Out-Null + Invoke-GitChecked $seed @('init', '--initial-branch=main') | Out-Null + Invoke-GitChecked $seed @('add', '-A') | Out-Null + Invoke-GitChecked $seed ($identity + @('commit', '-m', 'seed')) | Out-Null + Invoke-GitChecked $seed @('push', $origin, 'main') | Out-Null + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('clone', $origin, $clone) | Out-Null + return [pscustomobject]@{ Work = $work; Origin = $origin; Clone = $clone } +} + +function Set-OriginHook($Fixture, [string]$Body) { + $hook = Join-Path $Fixture.Origin 'hooks/pre-receive' + [IO.File]::WriteAllBytes($hook, (New-Object System.Text.UTF8Encoding($false)).GetBytes("#!/bin/sh`n$Body`n")) +} + +function Get-OriginCommitCount($Fixture) { + return [int](Invoke-GitChecked $Fixture.Origin @('rev-list', '--count', 'main')) +} + +function Test-VersionRecordIntegration { + $fixture = New-RecordFixture + $before = Get-OriginCommitCount $fixture + $record = Invoke-VersionRecord -RepositoryPath $fixture.Clone -Version '0.9.2' + Assert-Equal -Expected $true -Actual $record.Changed -Name 'recording a higher version changes main' + Assert-Equal -Expected 1 -Actual $record.Attempt -Name 'recording a higher version succeeds on the first attempt' + Assert-Equal -Expected ($before + 1) -Actual (Get-OriginCommitCount $fixture) -Name 'origin gains exactly one commit' + Assert-Equal -Expected 'github-actions[bot]|41898282+github-actions[bot]@users.noreply.github.com|chore(release): set version to 0.9.2' -Actual (Invoke-GitChecked $fixture.Origin @('log', '-1', '--format=%an|%ae|%s', 'main')) -Name 'the origin commit has the bot author and message' + Assert-Equal -Expected 'github-actions[bot]' -Actual (Invoke-GitChecked $fixture.Origin @('log', '-1', '--format=%cn', 'main')) -Name 'the origin commit has the bot committer' + $changed = @((Invoke-GitChecked $fixture.Origin @('diff', '--name-only', 'main~1', 'main')) -split "`n" | Sort-Object) + Assert-Equal -Expected 'Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.cs|Codescene.VSExtension.VS2022/Codescene.VSExtension.VS2022/source.extension.vsixmanifest' -Actual ($changed -join '|') -Name 'the origin commit changes exactly the two version files' + $oneLine = @((Invoke-GitChecked $fixture.Origin @('diff', '--numstat', 'main~1', 'main')) -split "`n" | ForEach-Object { ($_ -split "`t")[0] }) + Assert-Equal -Expected '1|1' -Actual ($oneLine -join '|') -Name 'each version file gains and loses exactly one line' + Assert-Equal -Expected '0.9.2' -Actual (Get-ReleaseVersion -RepositoryPath $fixture.Clone) -Name 'the clone carries the recorded version' + Assert-Equal -Expected '' -Actual (Invoke-GitChecked $fixture.Clone @('status', '--porcelain')) -Name 'the clone is clean after recording' + + $again = Invoke-VersionRecord -RepositoryPath $fixture.Clone -Version '0.9.2' + Assert-Equal -Expected $false -Actual $again.Changed -Name 'recording the same version again changes nothing' + Assert-Equal -Expected ($before + 1) -Actual (Get-OriginCommitCount $fixture) -Name 'recording the same version again adds no commit' + + $lower = Invoke-VersionRecord -RepositoryPath $fixture.Clone -Version '0.9.0' + Assert-Equal -Expected $false -Actual $lower.Changed -Name 'recording a lower version changes nothing' + Assert-Equal -Expected '0.9.2' -Actual (Get-ReleaseVersion -RepositoryPath $fixture.Clone) -Name 'a lower version never lowers main' + Assert-Equal -Expected ($before + 1) -Actual (Get-OriginCommitCount $fixture) -Name 'recording a lower version adds no commit' + + Assert-Throws -Action { Invoke-VersionRecord -RepositoryPath $fixture.Clone -Version 'v0.9.3' } -MessagePattern 'Malformed version' -Name 'a malformed version is rejected before touching git' +} + +function Test-VersionRecordSyncsWithOrigin { + $fixture = New-RecordFixture + $other = Join-Path $fixture.Work 'other' + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('clone', $fixture.Origin, $other) | Out-Null + Invoke-VersionRecord -RepositoryPath $other -Version '0.9.5' | Out-Null + $stale = Invoke-VersionRecord -RepositoryPath $fixture.Clone -Version '0.9.3' + Assert-Equal -Expected $false -Actual $stale.Changed -Name 'a stale clone is brought up to origin before deciding' + Assert-Equal -Expected '0.9.5' -Actual (Get-ReleaseVersion -RepositoryPath $fixture.Clone) -Name 'the stale clone now carries the version recorded by another run' +} + +function Test-VersionRecordStrayFile { + $fixture = New-RecordFixture + Write-AllText (Join-Path $fixture.Clone 'stray.txt') 'x' + $count = Get-OriginCommitCount $fixture + Assert-Throws -Action { Invoke-VersionRecord -RepositoryPath $fixture.Clone -Version '0.9.2' } -MessagePattern 'stray\.txt' -Name 'an untracked file stops the version record' + Assert-Equal -Expected $count -Actual (Get-OriginCommitCount $fixture) -Name 'a stray file adds no origin commit' +} + +function Test-VersionRecordRetry { + $once = New-RecordFixture + $marker = (Join-Path $once.Work 'rejected-once').Replace('\', '/') + Set-OriginHook $once "if [ -f '$marker' ]; then exit 0; fi`ntouch '$marker'`necho 'test hook rejects the first push' >&2`nexit 1" + $before = Get-OriginCommitCount $once + $record = Invoke-VersionRecord -RepositoryPath $once.Clone -Version '0.9.2' + Assert-Equal -Expected $true -Actual $record.Changed -Name 'a rejected first push is retried to success' + Assert-Equal -Expected 2 -Actual $record.Attempt -Name 'the retry succeeds on attempt 2' + Assert-Equal -Expected ($before + 1) -Actual (Get-OriginCommitCount $once) -Name 'a retried record adds exactly one origin commit' + Assert-Equal -Expected '0.9.2' -Actual (Get-ReleaseVersion -RepositoryPath $once.Clone) -Name 'the retried record leaves the version recorded' + + $always = New-RecordFixture + Set-OriginHook $always "echo 'test hook rejects every push' >&2`nexit 1" + $count = Get-OriginCommitCount $always + Assert-Throws -Action { Invoke-VersionRecord -RepositoryPath $always.Clone -Version '0.9.2' -Attempts 2 } -MessagePattern 'Could not record version 0\.9\.2 on main after 2 attempts' -Name 'an always-rejecting origin exhausts the attempts' + Assert-Equal -Expected $count -Actual (Get-OriginCommitCount $always) -Name 'an always-rejecting origin gains no commit' + + $racing = New-RecordFixture + $racer = Join-Path $racing.Work 'racer' + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('clone', $racing.Origin, $racer) | Out-Null + Write-AllText (Join-Path $racer 'other.txt') 'unrelated' + Invoke-GitChecked $racer @('add', '-A') | Out-Null + Invoke-GitChecked $racer @('-c', 'user.name=Racer', '-c', 'user.email=racer@example.com', 'commit', '-m', 'unrelated change') | Out-Null + Invoke-GitChecked $racer @('push', 'origin', 'main') | Out-Null + $viaRace = Invoke-VersionRecord -RepositoryPath $racing.Clone -Version '0.9.2' + Assert-Equal -Expected $true -Actual $viaRace.Changed -Name 'a clone behind origin still records the version' + Assert-Equal -Expected 'unrelated' -Actual (Read-AllText (Join-Path $racing.Clone 'other.txt')) -Name 'the record is made on top of the newest origin main' + + $unreachable = New-RecordFixture + Invoke-GitChecked $unreachable.Clone @('remote', 'set-url', 'origin', (Join-Path $unreachable.Work 'no-such-origin.git')) | Out-Null + Assert-Throws -Action { Invoke-VersionRecord -RepositoryPath $unreachable.Clone -Version '0.9.2' } -MessagePattern 'fetch' -Name 'an unreachable origin fails the fetch instead of retrying' + + $denied = New-RecordFixture + Invoke-GitChecked $denied.Clone @('remote', 'set-url', '--push', 'origin', (Join-Path $denied.Work 'no-such-origin.git')) | Out-Null + Assert-Throws -Action { Invoke-VersionRecord -RepositoryPath $denied.Clone -Version '0.9.2' -Attempts 3 } -MessagePattern 'git push origin HEAD:refs/heads/main failed' -Name 'a push failure that is not a rejection throws immediately' +} + +function Test-RecordVersionCommand { + $fixture = New-RecordFixture + $noVersion = Invoke-EntryScript -Command 'record-version' -Environment @{ REPO_PATH = $fixture.Clone } + Assert-EntryScriptFails $noVersion 'VERSION is required' 'record-version without a version' + + $run = Invoke-EntryScript -Command 'record-version' -Environment @{ VERSION = '0.9.2'; REPO_PATH = $fixture.Clone } -WorkingDirectory (New-TempDirectory) + Assert-Equal -Expected 0 -Actual $run.ExitCode -Name "record-version exits zero ($($run.Output))" + Assert-Equal -Expected 'chore(release): set version to 0.9.2' -Actual (Invoke-GitChecked $fixture.Origin @('log', '-1', '--format=%s', 'main')) -Name 'record-version pushes the version commit' + + $again = Invoke-EntryScript -Command 'record-version' -Environment @{ VERSION = '0.9.2'; REPO_PATH = $fixture.Clone } + Assert-Equal -Expected 0 -Actual $again.ExitCode -Name 'record-version of the recorded version exits zero' + Assert-True -Condition ($again.Output -clike '*main already at 0.9.2*') -Name 'record-version logs that main is already at the version' + + $bad = Invoke-EntryScript -Command 'record-version' -Environment @{ VERSION = '1.2'; REPO_PATH = $fixture.Clone } + Assert-EntryScriptFails $bad 'Malformed version' 'record-version with a malformed version' +} diff --git a/.github/validate-test-release-tag.ps1 b/.github/validate-test-release-tag.ps1 deleted file mode 100644 index 30b81eaa..00000000 --- a/.github/validate-test-release-tag.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -$ErrorActionPreference = 'Stop' - -$tag = $env:GITHUB_REF_NAME -if ([string]::IsNullOrWhiteSpace($tag)) { - throw 'GITHUB_REF_NAME is not set.' -} - -$repoRoot = Split-Path $PSScriptRoot -Parent -$manifest = Join-Path $repoRoot 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\source.extension.vsixmanifest' - -$m = [regex]::Match($tag, '^v(?\d+\.\d+\.\d+)-test\.(?[0-9a-f]+)$', 'IgnoreCase') -if (-not $m.Success) { - throw "Tag '$tag' does not match vX.Y.Z-test.." -} - -$tagSha = $m.Groups['sha'].Value -$tagVersion = $m.Groups['ver'].Value -$head = (git -C $repoRoot rev-parse HEAD).Trim() - -if (-not $head.StartsWith($tagSha, [StringComparison]::OrdinalIgnoreCase)) { - throw "Tag SHA prefix '$tagSha' does not match commit $head." -} - -if (-not (Test-Path $manifest)) { - throw "VSIX manifest not found: $manifest" -} - -[xml]$xml = Get-Content $manifest -$manifestVersion = $xml.PackageManifest.Metadata.Identity.Version -if ([string]::IsNullOrWhiteSpace($manifestVersion)) { - throw 'Could not read Identity/@Version from VSIX manifest.' -} - -if ($manifestVersion -ne $tagVersion) { - throw "VSIX manifest version '$manifestVersion' does not match tag version '$tagVersion'." -} - -Write-Host "Test release tag validated: $tag (VSIX $manifestVersion, commit $head)" diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 156a0b6d..af4761bd 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -1,85 +1,104 @@ -name: Fetch, Build and Test, VSIX (Release on tag) +name: Release on: push: - branches: - - main tags: - - 'v*' - pull_request: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-test.[0-9a-f]+' + workflow_dispatch: + inputs: + tag: + type: string + required: true + description: Existing release tag to build and publish + dry_run: + type: boolean + default: false + description: Resolve the metadata and sign in to the Marketplace without releasing + +permissions: + contents: read + +concurrency: + group: github-release + cancel-in-progress: false + queue: max jobs: - # ------------------------------- - # Job 1: Fetch required assets - # ------------------------------- - fetch-docs: - name: Fetch Docs and Web Assets + metadata: + name: Resolve Release Metadata runs-on: windows-2025-vs2026 + permissions: + contents: read + outputs: + tag: ${{ steps.metadata.outputs.tag }} + version: ${{ steps.metadata.outputs.version }} + kind: ${{ steps.metadata.outputs.kind }} + notes_start: ${{ steps.metadata.outputs.notes_start }} + sha: ${{ steps.metadata.outputs.sha }} + dry_run: ${{ steps.metadata.outputs.dry_run }} steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Download latest cs-webview release (private repo) + - name: Resolve release metadata + id: metadata shell: pwsh - run: powershell.exe -File .github/cwf.ps1 + run: powershell.exe -NoProfile -File .github/release-flow.ps1 metadata env: - CODESCENE_IDE_DOCS_AND_WEBVIEW_TOKEN: ${{ secrets.CODESCENE_IDE_DOCS_AND_WEBVIEW_TOKEN }} + TAG: ${{ inputs.tag || github.ref_name }} + EVENT: ${{ github.event_name }} + RUN_REF: ${{ github.ref }} + DRY_RUN: ${{ inputs.dry_run }} + GH_TOKEN: ${{ github.token }} - - name: Get latest Codescene CLI version - shell: pwsh - run: powershell.exe -File .github/cli.ps1 - - - name: Upload fetched assets - uses: actions/upload-artifact@v4 - with: - name: fetched-assets - path: | - cs-cwf/** - cs-ide/** - - # ------------------------------- - # Job 2: Build and Test - # ------------------------------- - build-test: - name: Build and Test VSIX + build: + name: Build VSIX runs-on: windows-2025-vs2026 - needs: fetch-docs - # env: - # Feature flags here. Example: - # FEATURE_ACE: true + needs: metadata + if: ${{ needs.metadata.outputs.dry_run != 'true' }} + permissions: + contents: read steps: - - name: Checkout code + - name: Checkout tagged code uses: actions/checkout@v4 with: + ref: refs/tags/${{ needs.metadata.outputs.tag }} fetch-depth: 0 - - name: Check PR size - if: github.event_name == 'pull_request' - shell: pwsh - run: | - git fetch origin main:main - powershell.exe -File .github/pr-size.ps1 + - name: Checkout release tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: release-tools + sparse-checkout: .github + fetch-depth: 1 - - name: Validate test release tag - if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-test.') + - name: Download latest cs-webview release (private repo) shell: pwsh - run: powershell.exe -File .github/validate-test-release-tag.ps1 + run: powershell.exe -NoProfile -File .github/cwf.ps1 + env: + CODESCENE_IDE_DOCS_AND_WEBVIEW_TOKEN: ${{ secrets.CODESCENE_IDE_DOCS_AND_WEBVIEW_TOKEN }} - - name: Download fetched assets - uses: actions/download-artifact@v4 - with: - name: fetched-assets - path: . + - name: Get latest Codescene CLI version + shell: pwsh + run: powershell.exe -NoProfile -File .github/cli.ps1 - name: Setup MSBuild uses: microsoft/setup-msbuild@v2 - - name: Setup VSTest - uses: darenm/Setup-VSTest@v1 - - name: Copy web assets into project shell: pwsh - run: powershell.exe -File .github/copy-assets.ps1 + run: powershell.exe -NoProfile -File .github/copy-assets.ps1 + + - name: Set release version + shell: pwsh + run: powershell.exe -NoProfile -File release-tools/.github/release-flow.ps1 set-version + env: + VERSION: ${{ needs.metadata.outputs.version }} + REPO_PATH: ${{ github.workspace }} - name: Restore NuGet packages run: dotnet restore Codescene.VSExtension.VS2022/Codescene.VSExtension.sln @@ -87,72 +106,50 @@ jobs: - name: Build solution run: msbuild Codescene.VSExtension.VS2022/Codescene.VSExtension.sln /p:Configuration=Release /p:Platform="Any CPU" - - name: Run unit tests - if: ${{ hashFiles('**/*Tests.dll') != '' }} - run: dotnet test Codescene.VSExtension.VS2022/Codescene.VSExtension.sln --configuration Release --no-build --results-directory ./Codescene.VSExtension.VS2022/TestResults/ --logger trx --collect "XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=lcov - - - name: Test Report - uses: dorny/test-reporter@v1 - if: always() - with: - name: Test Results - path: "**/*.trx" - reporter: dotnet-trx - - - name: Install ReportGenerator - if: always() - shell: pwsh - run: dotnet tool install -g dotnet-reportgenerator-globaltool - - - name: Merge coverage - if: always() + - name: Verify VSIX version + id: verify shell: pwsh - run: | - reportgenerator ` - -reports:"**/TestResults/**/coverage.info" ` - -targetdir:./Codescene.VSExtension.VS2022/TestResults/coverage-report/ ` - -reporttypes:lcov - - - name: Upload test results - uses: actions/upload-artifact@v4 - with: - name: test-results - path: | - **/*.trx - **/coverage-report/lcov.info - + run: powershell.exe -NoProfile -File release-tools/.github/release-flow.ps1 verify-vsix + env: + VERSION: ${{ needs.metadata.outputs.version }} + REPO_PATH: ${{ github.workspace }} + - name: Upload VSIX artifact uses: actions/upload-artifact@v4 with: name: vsix-package - path: '**/*.vsix' - - - name: Check code coverage - uses: ./.github/actions/check-coverage - if: always() - with: - access-token: ${{ secrets.CODESCENE_CODE_COVERAGE_API_TOKEN }} - project-url: "https://api.codescene.io/v2/projects/66574" - coverage-files: "**/coverage-report/lcov.info" - - # ------------------------------- - # Job 3: Release - # ------------------------------- - release: - name: Create GitHub Release + path: ${{ steps.verify.outputs.path }} + + create-release: + name: Create Release runs-on: windows-2025-vs2026 - needs: build-test - if: startsWith(github.ref, 'refs/tags/v') + needs: metadata + if: ${{ needs.metadata.outputs.dry_run != 'true' }} permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 - - name: Write release notes - if: contains(github.ref_name, '-test.') + - name: Create or update GitHub release shell: pwsh - run: powershell.exe -File .github/write-test-release-notes.ps1 + run: powershell.exe -NoProfile -File .github/release-flow.ps1 create-release + env: + TAG: ${{ needs.metadata.outputs.tag }} + KIND: ${{ needs.metadata.outputs.kind }} + NOTES_START: ${{ needs.metadata.outputs.notes_start }} + SHA: ${{ needs.metadata.outputs.sha }} + GH_TOKEN: ${{ github.token }} + + upload: + name: Upload Release Assets + runs-on: windows-2025-vs2026 + needs: [metadata, build, create-release] + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 - name: Download VSIX artifact uses: actions/download-artifact@v4 @@ -160,25 +157,152 @@ jobs: name: vsix-package path: vsix - - name: Publish GitHub test release - if: contains(github.ref_name, '-test.') - uses: softprops/action-gh-release@v1 + - name: Upload VSIX to the release + shell: pwsh + run: powershell.exe -NoProfile -File .github/release-flow.ps1 upload + env: + TAG: ${{ needs.metadata.outputs.tag }} + ASSET_DIR: vsix + GH_TOKEN: ${{ github.token }} + + test-gate: + name: Verify Tests Passed + runs-on: windows-2025-vs2026 + needs: [metadata, upload] + if: ${{ needs.metadata.outputs.kind == 'stable' && needs.metadata.outputs.dry_run != 'true' }} + permissions: + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify the tagged commit passed the tests + shell: pwsh + run: powershell.exe -NoProfile -File .github/release-flow.ps1 test-gate + env: + SHA: ${{ needs.metadata.outputs.sha }} + GH_TOKEN: ${{ github.token }} + + publish: + name: Publish to Visual Studio Marketplace + runs-on: windows-2025-vs2026 + needs: [metadata, test-gate] + if: ${{ !cancelled() && needs.metadata.result == 'success' && needs.metadata.outputs.kind == 'stable' && (needs.metadata.outputs.dry_run == 'true' || needs.test-gate.result == 'success') }} + permissions: + contents: read + id-token: write + steps: + - name: Find VsixPublisher.exe + id: find-vsixpub + shell: pwsh + run: | + $vsix = Get-ChildItem "C:\Program Files\Microsoft Visual Studio\18" -Recurse -Filter VsixPublisher.exe -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $vsix) { throw "VsixPublisher.exe not found on runner." } + echo "path=$vsix" >> $env:GITHUB_OUTPUT + Write-Host "Found VsixPublisher at: $vsix" + + - name: Azure login (OIDC) + uses: azure/login@v3 with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} - body_path: release-notes.md - prerelease: true - files: vsix/**/*.vsix + client-id: ${{ secrets.AZURE_MARKETPLACE_PUBLISHING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_MARKETPLACE_PUBLISHING_TENANT_ID }} + allow-no-subscriptions: true + + - name: Sign in to Visual Studio Marketplace + shell: pwsh + run: | + $token = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv + if (-not $token) { throw "Failed to obtain a Microsoft Entra access token for the Marketplace." } + Write-Output "::add-mask::$token" + + & "$env:VSIX_PUBLISHER" login ` + -personalAccessToken "$token" ` + -publisherName "CodeScene" + if ($LASTEXITCODE -ne 0) { throw "VsixPublisher login failed with exit code $LASTEXITCODE." } env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VSIX_PUBLISHER: ${{ steps.find-vsixpub.outputs.path }} - - name: Publish GitHub release - if: ${{ !contains(github.ref_name, '-test.') }} - uses: softprops/action-gh-release@v1 + - name: Checkout tagged code + if: ${{ needs.metadata.outputs.dry_run != 'true' }} + uses: actions/checkout@v4 with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} - body: "Automated build for ${{ github.ref_name }}" - files: vsix/**/*.vsix + ref: refs/tags/${{ needs.metadata.outputs.tag }} + fetch-depth: 0 + + - name: Checkout release tooling + if: ${{ needs.metadata.outputs.dry_run != 'true' }} + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: release-tools + sparse-checkout: .github + fetch-depth: 1 + + - name: Download VSIX from the release + if: ${{ needs.metadata.outputs.dry_run != 'true' }} + shell: pwsh + run: powershell.exe -NoProfile -File release-tools/.github/release-flow.ps1 download-asset + env: + TAG: ${{ needs.metadata.outputs.tag }} + ASSET_DIR: vsix + REPO_PATH: ${{ github.workspace }} + GH_TOKEN: ${{ github.token }} + + - name: Write Marketplace overview + if: ${{ needs.metadata.outputs.dry_run != 'true' }} + shell: pwsh + run: powershell.exe -NoProfile -File release-tools/.github/release-flow.ps1 write-overview + env: + REPO_PATH: ${{ github.workspace }} + + - name: Publish to Visual Studio Marketplace + if: ${{ needs.metadata.outputs.dry_run != 'true' }} + shell: pwsh + run: powershell.exe -NoProfile -File release-tools/.github/release-flow.ps1 publish + env: + VERSION: ${{ needs.metadata.outputs.version }} + VSIX_PUBLISHER: ${{ steps.find-vsixpub.outputs.path }} + VSIX_DIR: vsix + PUBLISH_MANIFEST: ${{ github.workspace }}\Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\publishManifest.json + REPO_PATH: ${{ github.workspace }} + + record-version: + name: Record Released Version + runs-on: windows-2025-vs2026 + needs: [metadata, publish] + if: ${{ needs.metadata.outputs.kind == 'stable' && needs.metadata.outputs.dry_run != 'true' }} + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Record the released version on main + shell: pwsh + run: powershell.exe -NoProfile -File .github/release-flow.ps1 record-version + env: + VERSION: ${{ needs.metadata.outputs.version }} + REPO_PATH: ${{ github.workspace }} + + promote: + name: Mark Release Latest + runs-on: windows-2025-vs2026 + needs: [metadata, record-version] + if: ${{ needs.metadata.outputs.kind == 'stable' && needs.metadata.outputs.dry_run != 'true' }} + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Mark the release as latest + shell: pwsh + run: powershell.exe -NoProfile -File .github/release-flow.ps1 promote env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.metadata.outputs.tag }} + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..e1d7e70e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,129 @@ +name: Run tests + +on: + push: + branches: + - main + pull_request: + +jobs: + # ------------------------------- + # Job 1: Fetch required assets + # ------------------------------- + fetch-docs: + name: Fetch Docs and Web Assets + runs-on: windows-2025-vs2026 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download latest cs-webview release (private repo) + shell: pwsh + run: powershell.exe -File .github/cwf.ps1 + env: + CODESCENE_IDE_DOCS_AND_WEBVIEW_TOKEN: ${{ secrets.CODESCENE_IDE_DOCS_AND_WEBVIEW_TOKEN }} + + - name: Get latest Codescene CLI version + shell: pwsh + run: powershell.exe -File .github/cli.ps1 + + - name: Upload fetched assets + uses: actions/upload-artifact@v4 + with: + name: fetched-assets + path: | + cs-cwf/** + cs-ide/** + + # ------------------------------- + # Job 2: Build and Test + # ------------------------------- + build-test: + name: Build and Test VSIX + runs-on: windows-2025-vs2026 + needs: fetch-docs + # env: + # Feature flags here. Example: + # FEATURE_ACE: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check PR size + if: github.event_name == 'pull_request' + shell: pwsh + run: | + git fetch origin main:main + powershell.exe -File .github/pr-size.ps1 + + - name: Download fetched assets + uses: actions/download-artifact@v4 + with: + name: fetched-assets + path: . + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Setup VSTest + uses: darenm/Setup-VSTest@v1 + + - name: Copy web assets into project + shell: pwsh + run: powershell.exe -File .github/copy-assets.ps1 + + - name: Restore NuGet packages + run: dotnet restore Codescene.VSExtension.VS2022/Codescene.VSExtension.sln + + - name: Build solution + run: msbuild Codescene.VSExtension.VS2022/Codescene.VSExtension.sln /p:Configuration=Release /p:Platform="Any CPU" + + - name: Run unit tests + if: ${{ hashFiles('**/*Tests.dll') != '' }} + run: dotnet test Codescene.VSExtension.VS2022/Codescene.VSExtension.sln --configuration Release --no-build --results-directory ./Codescene.VSExtension.VS2022/TestResults/ --logger trx --collect "XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=lcov + + - name: Test Report + uses: dorny/test-reporter@v1 + if: always() + with: + name: Test Results + path: "**/*.trx" + reporter: dotnet-trx + + - name: Install ReportGenerator + if: always() + shell: pwsh + run: dotnet tool install -g dotnet-reportgenerator-globaltool + + - name: Merge coverage + if: always() + shell: pwsh + run: | + reportgenerator ` + -reports:"**/TestResults/**/coverage.info" ` + -targetdir:./Codescene.VSExtension.VS2022/TestResults/coverage-report/ ` + -reporttypes:lcov + + - name: Upload test results + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + **/*.trx + **/coverage-report/lcov.info + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: vsix-package + path: '**/*.vsix' + + - name: Check code coverage + uses: ./.github/actions/check-coverage + if: always() + with: + access-token: ${{ secrets.CODESCENE_CODE_COVERAGE_API_TOKEN }} + project-url: "https://api.codescene.io/v2/projects/66574" + coverage-files: "**/coverage-report/lcov.info" diff --git a/.github/write-test-release-notes.ps1 b/.github/write-test-release-notes.ps1 deleted file mode 100644 index c3c26748..00000000 --- a/.github/write-test-release-notes.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -$ErrorActionPreference = 'Stop' - -$tag = $env:GITHUB_REF_NAME -$sha = $env:GITHUB_SHA -$repository = $env:GITHUB_REPOSITORY - -if ([string]::IsNullOrWhiteSpace($tag)) { - throw 'GITHUB_REF_NAME is not set.' -} - -$repoRoot = Split-Path $PSScriptRoot -Parent -$manifest = Join-Path $repoRoot 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\source.extension.vsixmanifest' -$outputPath = Join-Path $repoRoot 'release-notes.md' - -[xml]$xml = Get-Content $manifest -$vsixVersion = $xml.PackageManifest.Metadata.Identity.Version - -$commitUrl = "https://github.com/$repository/commit/$sha" -$body = @" -Automated test build for ``$tag``. - -- VSIX extension version: **$vsixVersion** -- Commit: [$sha]($commitUrl) - -Install the ``.vsix`` from this release. The extension version shown in Visual Studio is **$vsixVersion**. -"@ - -Set-Content -Path $outputPath -Value $body -Encoding UTF8 -Write-Host "Wrote $outputPath" diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index f9b9f030..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,370 +0,0 @@ -# CodeScene Visual Studio Extension Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.9.1] - 2026-10-28 -### Fixed -- keep WebView2 visible when auto-hide flyouts are collapsed (#341) - -## [0.9.0] - 2026-10-27 -### Added -- run cs-ide as a persistent JSON-RPC server (#325) - -## [0.8.7] - 2026-10-24 -### Changed -- bump IDE CLI to 1.0.80 (#337) - -## [0.8.6] - 2026-10-19 -### Changed -- bump IDE CLI to 1.0.76 (#334) - -## [0.8.5] - 2026-10-09 -### Fixed -- bump CLI version with bugfixes - -## [0.8.4] - 2026-10-08 -### Fixed -- bump MessagePack to clear NuGet audit failures -- bump CLI version with updated Bouncycastle version -### Changed -- build coverage tests with Visual Studio MSBuild -- Enable scheduled code coverage workflow -- publish to Visual Studio Marketplace with Entra ID instead of a PAT (#328) - - -## [0.8.3] - 2026-09-23 -### Fixed -- exclude merge-commit files from Code Health Monitor (#323) -- hide WebView2 when auto-hidden tool windows overlay it (#322) -### Changed -- Review tracked files that match gitignore (#321) - - -## [0.8.2] - 2026-09-07 -### Fixed -- bumped CodeScene CLI version - -## [0.8.1] - 2026-09-07 -### Fixed -- clarify editor instructions in release script (#317) - - -## [0.8.0] - 2026-08-07 - -### Fixed -- ensure tags are pushed to remote (#315) -### Changed -- Update workflow to use windows-2022 for publishing - - -## [0.7.7] - 2026-08-06 - -### Added -- add CPU-based throttling to GitChangeLister periodic scan -- remove stale files from Code Health Monitor after GitChangeLister runs -- add CPU monitoring to throttle CLI commands during high load -- cache baseline commit to eliminate redundant git merge-base calls -- cache `GetDefaultBranch` calls to reduce redundant git operations -- skip `GitChangeLister` and `GitChangeObserver` on default branch -- watch global gitignore (`core.excludesfile`) for cache invalidation (#306) -- batch git ignore checks to reduce Repository overhead -- skip GitChangeLister when analyses are running -- dynamically increase GitChangeLister period based on execution time (#303) -- skip periodic GitChangeLister when VS window is not focused - -### Fixed -- resolve issues from `CpuSampler` refactoring code review -- filter invalid paths before cache cleanup in GitChangeLister -- check CPU after acquiring semaphore, not before -- catch exceptions in `TakeSample` to ensure graceful fallback -- align CPU throttling with codescene-vscode parity -- use system-wide CPU metrics instead of process-specific -- handle exceptions in CPU monitoring gracefully in `CpuUsageThrottler` -- wait for CPU availability before acquiring semaphore in `CliExecutor` -- synchronize access to `_previousSnapshot` in `CpuMonitor` -- prevent concurrent processing in `FileChangeEventProcessor` -- compute baseline commit once per batch in `OnGitChangeListerFilesDetected` -- thread `baselineCommit` through `GetChangedFilesVsBaselineAsync` -- forward `baselineCommit` to inner reviewer in `ComputeAndCacheDeltaAsync` -- add missing `baselineCommit` parameter to Moq setup expressions -- thread `baselineCommit` through internal delta computation methods -- compute baseline commit once per batch instead of per-event -- add missing `baselineCommit` parameter to `DeltaAsync` Moq expressions -- use `baselineCommit` parameter in `CodeReviewer.GetOrComputeBaselineRawScoreAsync` -- synchronize `DefaultBranchGate` cache access - -### Changed -- remove default branch skipping from GitChangeLister -- assert calculated CPU usage in `TakeSampleSync_ReturnsExpectedCpuUsage` -- extract common CPU sampling logic into `CpuSampler` -- add coverage for `CpuMonitor.TakeSampleSync` -- add coverage for stale file removal preservation logic -- consolidate duplicate tests using DataRow -- add comprehensive CPU monitoring threshold and edge case tests -- add edge case coverage for `CpuMonitor` -- cover outer exception handler in `FileChangeEventProcessor` -- reduce cyclomatic complexity in git change detection -- consolidate baseline commit resolution into `IGitService` -- remove redundant FilterIgnoredFiles call in CollectFilesFromRepoState -- add coverage for ignored files not counting toward threshold - - -## [0.7.6] - 2026-06-09 -### Fixed -- use configured baseline exclusively for merge-base resolution (#300) - -## [0.7.5] - 2026-06-03 -### Added -- improve main branch detection using refs/remotes/origin/HEAD (#279) -### Fixed -- hide ACE ad in code smell docs when no auth token (#299) -- Fixed some unhandled exceptions (#298) (#297) (#296) -- Misc security improvements - -## [0.7.4] - 2026-05-12 -### Added -- add editor-version to telemetry payload (#277) - -## [0.7.3] - 2026-04-22 -### Fixed -- solution switch error + improved error logs (#276) -- delta baseline selection for stacked branches (#275) - -## [0.7.2] - 2026-04-10 -### Fixed -- code-health-rules.json support (#273) - -## [0.7.1] - 2026-04-01 -### Fixed -- duplicated cli commands on same file (#270) -- editor suggestion bugfix (#269) -- Exception cleanup (#267) -- observer start on solution/project open (#264) -- cleanup status bar messages and logging (#265) -- improved exception management and logging (#263) - -## [0.7.0] - 2026-03-16 -### Fixed -- improved text contrast in Code Health Monitor -- increase document debounce, lower polling (#260) -- stabilize review and delta polling on branch/solution switch (#261) (#259) (#258) (#257) (#255) (#254) (#249) (#247) (#246) (#238) -- refactored caching infrastructure (#251) (#240) (#242) (#244) (#233) (#231) -- margin text for unreviewable file (#252) -- open folder event triggers (#245) -- error list formatting (#243) -- code health editor margin multiple windows (#215) -### Added -- VS2026 theme support (#213) -- git change observer and file change tracking () -- CodeScene ACE Refactoring (#239) (#214) (#200) (#110) (#99) (#97) (#95) (#88) -- .gitignore change tracker (#253) (#237) (#109) -- solution workspace support (#250) -- Integrate GitChangeLister with periodic scanning into GitChangeObserver (#203) -- editor margin shows delta (#206) -- add CLI request performance telemetry (ace, review, delta) -- error telemetry (#113) -- Set up Stylecop -- Add CodeRabbit -- Add CLAUDE.md -### Changed -- Revamp Makefile (#105) - -## [0.3.0] - 2025-10-21 - -### Added - -- Bump CLI version to 1.0.14 -- Add Code Health Monitor to Freemium - -## [0.2.7] - 2025-10-07 - -### Fixed - -- Update extension startup to wait for IDE initialization finish - -## [0.2.6] - 2025-09-24 - -### Changed - -- Update ACE wording - -## [0.2.5] - 2025-09-18 - -### Changed - -- Improve code health review time by 50% (CLI version 1.0.8) - -## [0.2.4] - 2025-09-08 - -### Fixed - -- Add missing tags - -### Changed - -- Prepare asset files - -## [0.2.3] - 2025-09-05 - -### Fixed - -- Corrected publishing manifest and asset handling for Visual Studio Marketplace. - -## [0.2.2] - 2025-09-05 - -### Changed - -- Fix for overview gif - -## [0.2.1] - 2025-09-04 - -### Fixed - -- VSIX Publish pipeline fixed - -## [0.2.0] - 2025-09-04 - -### Changed - -- Updated release script -- Refactor and clean up code -- Removed unnecessary .vscode folder - -## [0.1.4] - 2025-08-21 - -### Changed - -- Webview message handling for file focus. -- Review timeout from 10s to 60s. - -### Fixed - -- Code smell finding tooltip coloring on light themes. - -## [0.1.3] - 2025-08-18 - -### Added - -- Introduced a Terms & Policies acceptance step. Users must review and accept before using the extension's analysis capabilities. - -## [0.1.2] - 2025-08-14 - -### Fixed - -- VS2022 compatibility. - -### Changed - -- Updated webview to v1.2.0. -- Bumped cli version to 1.0.5. - -## [0.1.1] - 2025-08-12 - -### Fixed - -- Hovering color on Home links - -### Changed - -- Updated webview to v1.1.1. - -## [0.1.0] - 2025-08-11 - -### Fixed - -- Extension installation issues on lower VS2022 versions. -- Device id generation algorithm -- Whitelisted supporthub.codescene domain - -### Changed - -- Updated webview to v1.1.0. -- Bumped cli version to 1.0.3. - -## [0.0.6] - 2025-07-07 - -### Changed - -- Updated webview to v1.0.1. -- Added more styling support to webviews. - -## [0.0.5] - 2025-07-04 - -### Added - -- Optional extension usage telemetry. - -### Changed - -- Updated webview to v1.0.0. - -## [0.0.4] - 2025-07-01 - -### Added - -- Option to enable and disable showing CodeScene debug logs for more detailed output. - -## [0.0.3] - 2025-06-30 - -### Added - -- Review analysis timeout. - -## [0.0.2] - 2025-06-30 - -### Added - -- Documentation for code smells. -- Code Health visibility in a separate editor margin. - -### Changed - -- Review flow optimization. - -## [0.0.1] - 2025-06-10 - -### Added - -- Code Health Review and diagnostics with squiggly lines. -- Visibility of diagnostics in Error List and detailed hover information. -- Initial CodeScene plugin settings. -- Documentation for code smells. -- Code Health visibility in a separate editor margin. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Makefile b/Makefile index 2a58ae67..d0a9bc3d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ include .github/sha.mk # Lazy-once cache key - computed on first use, then cached for rest of Make invocation CACHE_KEY = $(eval CACHE_KEY := $$(call get_cache_key))$(CACHE_KEY) -.PHONY: test test1 test-mine coverage-mine copy-assets updatecwf restore format format-all format-check class-size-mine no-regions-mine test-cache test-sha install-cli delta pr-size clean prebuild quit-vs benchmark tag-test-release +.PHONY: test test1 test-mine coverage-mine copy-assets updatecwf restore format format-all format-check class-size-mine no-regions-mine test-cache test-sha install-cli delta pr-size clean prebuild quit-vs benchmark tag-test-release test-release-flow # You might need something like: # export PATH="$PATH:/mnt/c/Program Files/dotnet:/mnt/c/Program Files/Microsoft Visual Studio/18/Community/MSBuild/Current/Bin:/mnt/c/Program Files/Microsoft Visual Studio/18/Community/Common7/IDE/Extensions/TestPlatform" @@ -91,7 +91,7 @@ no-regions-mine: # iter - iterate. Good as a prompt: "iterate to success using `make iter`" # `format` temporarily removed. -iter: build class-size-mine no-regions-mine delta test-mine +iter: build class-size-mine no-regions-mine delta test-mine test-release-flow install-cli: @pwsh.exe -Command "Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force; Invoke-WebRequest -Uri 'https://downloads.codescene.io/enterprise/cli/install-cs-tool.ps1' -OutFile install-cs-tool.ps1; .\install-cs-tool.ps1; Remove-Item install-cs-tool.ps1" @@ -111,4 +111,8 @@ benchmark: restore cs-ide.zip @cd Codescene.VSExtension.VS2022 && dotnet.exe run --project .\Codescene.VSExtension.Core.Benchmarks\Codescene.VSExtension.Core.Benchmarks.csproj -c Release -- $(BENCH_ARGS) > benchmark.log 2>&1 && del benchmark.log || (type benchmark.log && del benchmark.log && exit /b 1) tag-test-release: - @pwsh.exe -File tag-test-release.ps1 + @pwsh.exe -NoProfile -File tag-test-release.ps1 -Bump $(or $(BUMP),patch) + +test-release-flow: + @pwsh.exe -NoProfile -File .github/test-release-flow.ps1 > test-release-flow-pwsh.log 2>&1 && del test-release-flow-pwsh.log || (type test-release-flow-pwsh.log && del test-release-flow-pwsh.log && exit /b 1) + @powershell.exe -NoProfile -ExecutionPolicy Bypass -File .github/test-release-flow.ps1 > test-release-flow-winps.log 2>&1 && del test-release-flow-winps.log || (type test-release-flow-winps.log && del test-release-flow-winps.log && exit /b 1) diff --git a/README.md b/README.md index 933f191f..80bed49e 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,7 @@ Location: `Code Health Monitor` > `Extension Settings` ### Telemetry ### We collect anonymized telemetry for tracking usage and feature engagement. CodeScene adheres to Visual Studio's privacy settings to enable/disable this. + +## Release notes ## + +See the [GitHub Releases](https://github.com/codescene-oss/codescene-vs/releases) page for release notes and version history. diff --git a/SETUP.md b/SETUP.md index 48528ce4..cd0856ae 100644 --- a/SETUP.md +++ b/SETUP.md @@ -10,26 +10,28 @@ ## Test releases -GitHub test builds use a tag that includes the commit SHA; the VSIX keeps a numeric `X.Y.Z` version only. +GitHub test builds use a tag that includes the commit SHA; the VSIX keeps a numeric `X.Y.Z` version only, which is the version in the tag. | | Example | |---|---------| -| Git tag | `v0.9.0-test.9c31e24` | -| VSIX `Identity` version | `0.9.0` | +| Git tag | `v0.9.2-test.9c31e24` | +| VSIX `Identity` version | `0.9.2` | -Before tagging, set the manifest (and `source.extension.cs`) to the target `X.Y.Z` on the branch you want to ship. Merge `main` into that branch first so the tagged commit includes the current release workflow. +The tagged commit does not need a manifest edit: the workflow sets the VSIX version (and `source.extension.cs`) from the tag. Merge `main` into the branch you want to ship first so the tagged commit includes the current release workflow. Create and push a test tag from that commit: ```text -make tag-test-release +make tag-test-release BUMP=patch ``` -Or dry-run: `pwsh -File tag-test-release.ps1 -WhatIf`. Use `-NoPush` to create the tag locally only. +`BUMP` is `patch`, `minor` or `major` (default `patch`) and is applied to the highest published stable release to get the test version. Or dry-run: `pwsh -File tag-test-release.ps1 -WhatIf`. Use `-NoPush` to create the tag locally only. -CI validates that the tag matches `vX.Y.Z-test.`, that `` prefixes the tagged commit, and that `X.Y.Z` matches the VSIX manifest. The GitHub release is marked **Pre-release** and attaches the `.vsix`. +Prerequisites: the GitHub CLI (`gh`) installed and authenticated (`gh auth login`), and a clean git worktree. -Production releases are unchanged: `release.ps1` and tag `vX.Y.Z`. Marketplace publish (`vsix-publish.yml`) uses the latest non–pre-release. +The workflow validates that the tag matches `vX.Y.Z-test.`, that `` prefixes the tagged commit, and that the base version exceeds the previous stable release. It creates a **Pre-release** with the `.vsix` attached and never publishes to the Marketplace. + +Production releases are described in [.github/RELEASING.md](.github/RELEASING.md). Repeated test tags may reuse the same VSIX version; Visual Studio may not treat that as an upgrade between builds. diff --git a/release.ps1 b/release.ps1 deleted file mode 100644 index 23129663..00000000 --- a/release.ps1 +++ /dev/null @@ -1,173 +0,0 @@ -# --- SETTINGS --- -$manifest = Join-Path $PSScriptRoot 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\source.extension.vsixmanifest' -$vsix = Join-Path $PSScriptRoot 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\source.extension.cs' -$changelog = Join-Path $PSScriptRoot "CHANGELOG.md" - -function Get-VersionFromChangelog { - param($path) - if (-not (Test-Path $path)) { return $null } - $content = Get-Content $path -Raw - $m = [regex]::Match($content, '## \[(\d+\.\d+\.\d+)\]') - if ($m.Success) { return $m.Groups[1].Value } else { return $null } -} - -function Get-VersionFromManifest { - param($path) - if (-not (Test-Path $path)) { throw "VSIX manifest not found: $path" } - [xml]$xml = Get-Content $path - $ver = $xml.PackageManifest.Metadata.Identity.Version - if ([string]::IsNullOrWhiteSpace($ver)) { throw "Could not read Identity/@Version." } - return $ver -} - -function Bump-Version { - param($version) - $parts = $version.Split('.') - if ($parts.Count -lt 3) { throw "Expected semver (X.Y.Z), got '$version'." } - [int]$major = $parts[0]; [int]$minor = $parts[1]; [int]$patch = $parts[2] - - $releaseType = Read-Host "Release type (patch / minor / major)" - switch ($releaseType) { - 'major' { $major++; $minor=0; $patch=0 } - 'minor' { $minor++; $patch=0 } - 'patch' { $patch++ } - default { throw "Invalid release type '$releaseType'. Use patch/minor/major." } - } - return "$major.$minor.$patch" -} - -function Collect-Commits { - try { $lastTag = (git describe --tags --abbrev=0).Trim() } catch { $lastTag = "" } - if ([string]::IsNullOrWhiteSpace($lastTag)) { - return git log --pretty=format:"%s" - } else { - return git log "$lastTag..HEAD" --pretty=format:"%s" - } -} - -function Group-Commits { - param($rawCommits) - $result = @{ - Added = New-Object System.Collections.Generic.List[string] - Fixed = New-Object System.Collections.Generic.List[string] - Changed = New-Object System.Collections.Generic.List[string] - } - - foreach ($c in $rawCommits) { - if ([string]::IsNullOrWhiteSpace($c)) { continue } - if ($c -like 'Merge*') { continue } - if ($c -match '^chore(\(.+?\))?:') { continue } - - if ($c -match '^feat(\(.+?\))?:\s*(.+)$') { $result.Added.Add("- " + $Matches[2].Trim()); continue } - if ($c -match '^fix(\(.+?\))?:\s*(.+)$') { $result.Fixed.Add("- " + $Matches[2].Trim()); continue } - if ($c -match '^(docs|refactor|style|perf|build|ci|test)(\(.+?\))?:\s*(.+)$') { - $result.Changed.Add("- " + $Matches[3].Trim()); continue - } - $result.Changed.Add("- " + $c.Trim()) - } - return $result -} - -function Update-Changelog { - param($version, $groups) - - $today = Get-Date -Format 'yyyy-MM-dd' - $newSection = "## [$version] - $today`r`n" - if ($groups.Added.Count -gt 0) { $newSection += "### Added`r`n" + ($groups.Added -join "`r`n") + "`r`n" } - if ($groups.Fixed.Count -gt 0) { $newSection += "### Fixed`r`n" + ($groups.Fixed -join "`r`n") + "`r`n" } - if ($groups.Changed.Count -gt 0) { $newSection += "### Changed`r`n" + ($groups.Changed -join "`r`n") + "`r`n" } - - if (-not (Test-Path $changelog)) { - $content = "## [Unreleased]`r`n`r`n$newSection" - Set-Content -Path $changelog -Value $content -Encoding UTF8 - } else { - $old = Get-Content $changelog -Raw - if ($old -notmatch '## \[Unreleased\]') { $old = "## [Unreleased]`r`n`r`n" + $old } - $updated = [regex]::Replace($old, '(## \[Unreleased\])', "`$1`r`n`r`n$newSection", 1) - Set-Content -Path $changelog -Value $updated -Encoding UTF8 - } -} - -function Update-Files { - param($version) - - # Update VSIX manifest version - [xml]$mxml = Get-Content $manifest - $identity = $mxml.PackageManifest.Metadata.Identity - if (-not $identity) { throw "Could not find Identity in manifest." } - $identity.Version = $version - $utf8 = New-Object System.Text.UTF8Encoding($false) - $sw = New-Object System.IO.StreamWriter($manifest, $false, $utf8) - $mxml.Save($sw); $sw.Dispose() - - # Update source.extension.cs version - if (-not (Test-Path $vsix)) { throw "source.extension.cs not found: $vsix" } - $src = Get-Content $vsix -Raw - $updatedSrc = [regex]::Replace( - $src, - 'public const string Version = "\d+\.\d+\.\d+";', - "public const string Version = `"$version`";" - ) - Set-Content -Path $vsix -Value $updatedSrc -Encoding UTF8 -} - -function Open-ChangelogForEdit { - if (Get-Command code -ErrorAction SilentlyContinue) { - & code --wait $changelog - } elseif ($env:EDITOR) { - $parts = $env:EDITOR -split '\s+', 2 - if ($parts.Length -gt 1) { - & $parts[0] $parts[1] $changelog - } else { - & $env:EDITOR $changelog - } - } else { - Start-Process notepad $changelog -Wait - } -} - -function Commit-And-Tag { - param($version) - git add -- $manifest $vsix $changelog | Out-Null - $commitMsg = "chore(release): v$version" - git commit -m $commitMsg - git tag -a "v$version" -m "Release v$version" - Write-Host "" - Write-Host "Release v$version created." - Write-Host "Pushing to remote..." - git push - git push origin "v$version" - Write-Host "Pushed successfully." -} - -function Main { - Push-Location $PSScriptRoot - try { - $currentVersion = Get-VersionFromChangelog $changelog - if (-not $currentVersion) { $currentVersion = Get-VersionFromManifest $manifest } - Write-Host "Current version: $currentVersion" - - $newVersion = Bump-Version $currentVersion - Write-Host "New version: $newVersion" - - $rawCommits = Collect-Commits - $groups = Group-Commits $rawCommits - Update-Changelog $newVersion $groups - - Write-Host "Opening changelog for editing. Please review, make any changes, save, and close the editor." - Open-ChangelogForEdit - $response = Read-Host "Press Enter to continue after closing the editor (or 'q' to abort)" - if ($response -eq 'q') { - git checkout -- $changelog - Write-Host "Aborted. Changelog reverted." - return - } - - Update-Files $newVersion - Commit-And-Tag $newVersion - } finally { - Pop-Location - } -} - -Main diff --git a/tag-test-release.ps1 b/tag-test-release.ps1 index 2289b406..74e10c94 100644 --- a/tag-test-release.ps1 +++ b/tag-test-release.ps1 @@ -1,4 +1,6 @@ param( + [ValidateSet('patch', 'minor', 'major')] + [string]$Bump = 'patch', [int]$ShaLength = 7, [switch]$WhatIf, [switch]$NoPush @@ -6,46 +8,59 @@ param( $ErrorActionPreference = 'Stop' -$manifest = Join-Path $PSScriptRoot 'Codescene.VSExtension.VS2022\Codescene.VSExtension.VS2022\source.extension.vsixmanifest' +$repository = 'codescene-oss/codescene-vs' -function Get-VersionFromManifest { - param($path) - if (-not (Test-Path $path)) { throw "VSIX manifest not found: $path" } - [xml]$xml = Get-Content $path - $ver = $xml.PackageManifest.Metadata.Identity.Version - if ([string]::IsNullOrWhiteSpace($ver)) { throw 'Could not read Identity/@Version.' } - return $ver +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw 'GitHub CLI (gh) is required to find the latest release. Install it from https://cli.github.com/.' +} + +Import-Module (Join-Path $PSScriptRoot '.github\release-flow.psm1') -Force + +if ((Invoke-Native -FilePath 'gh' -ArgumentList @('auth', 'status')).ExitCode -ne 0) { + throw 'gh is not authenticated. Run "gh auth login" first.' } Push-Location $PSScriptRoot try { - $version = Get-VersionFromManifest $manifest - $shortSha = (git rev-parse --short=$ShaLength HEAD).Trim() - $tagName = "v$version-test.$shortSha" + $dirty = (Invoke-NativeChecked -FilePath 'git' -ArgumentList @('status', '--porcelain')).Output + if ($dirty) { + throw "Tagging a test release requires a clean worktree. Commit or stash these changes first:`n$dirty" + } - if ($WhatIf) { - Write-Host "Would create tag: $tagName (VSIX version $version)" - return + $releases = @(Get-GitHubRelease -Repository $repository) + $highest = Select-HighestStableRelease -Releases $releases + if (-not $highest) { + throw "No published stable release found in $repository." } + $highestInfo = Get-ReleaseTagInfo -Tag $highest.tag_name + $nextVersion = Get-NextReleaseVersion -Version $highestInfo.Version -Bump $Bump + $shortSha = (git rev-parse --short=$ShaLength HEAD).Trim() + $tagName = Get-TestReleaseTagName -Version $nextVersion -ShortSha $shortSha + $localTag = git tag -l $tagName if ($localTag) { throw "Tag '$tagName' already exists locally." } - git ls-remote --exit-code --tags origin "refs/tags/$tagName" 2>$null - if ($LASTEXITCODE -eq 0) { + $remoteCheck = Invoke-Native -FilePath 'git' -ArgumentList @('ls-remote', '--exit-code', '--tags', 'origin', "refs/tags/$tagName") + if ($remoteCheck.ExitCode -eq 0) { throw "Tag '$tagName' already exists on origin." } - if ($LASTEXITCODE -ne 2) { - throw "Failed to check remote for tag '$tagName' (exit code $LASTEXITCODE)." + if ($remoteCheck.ExitCode -ne 2) { + throw "Failed to check remote for tag '$tagName' (exit code $($remoteCheck.ExitCode)): $($remoteCheck.Output)" + } + + if ($WhatIf) { + Write-Host "Would create tag: $tagName (next version $nextVersion after $($highest.tag_name))" + return } - git tag -a $tagName -m "Test release $version ($shortSha)" + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('tag', '-a', $tagName, '-m', "Test release $nextVersion ($shortSha)") | Out-Null Write-Host "Created tag $tagName" if (-not $NoPush) { - git push origin $tagName + Invoke-NativeChecked -FilePath 'git' -ArgumentList @('push', 'origin', $tagName) | Out-Null Write-Host "Pushed $tagName to origin." } else { Write-Host "Skipped push (-NoPush)."