Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions .github/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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 <sha>
```

2. Tag that commit and push the tag.

```
git tag -a vX.Y.Z -m "Release vX.Y.Z" <sha>
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.<sha>`) 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.<sha>` 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 <path>` 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.
99 changes: 99 additions & 0 deletions .github/migrate-changelog-to-releases.ps1
Original file line number Diff line number Diff line change
@@ -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 <path>]'

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
Loading
Loading