Add github release logic when creating a release#3523
Add github release logic when creating a release#3523richardwerkman wants to merge 8 commits intomasterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds automation to the release preparation workflow so that creating a new version also creates a corresponding GitHub Release, and it updates documentation to reflect a move toward a consolidated root CHANGELOG.md.
Changes:
- Update
prepare-release.jsto generate release notes, create an annotated Git tag containing those notes, and create a GitHub Release via theghCLI. - Adjust release procedure docs to mention automatic GitHub Release creation.
- Update the root
CHANGELOG.mdintroduction to position it as the consolidated changelog going forward.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| stryker-net.code-workspace | Minor formatting/structure fix. |
| RELEASING.md | Documents the new release automation behavior. |
| prepare-release.js | Adds changelog generation, annotated tagging, and GitHub Release creation logic. |
| CHANGELOG.md | Rewords intro to a consolidated changelog with links to historical per-package changelogs. |
| .github/copilot-instructions.md | Formatting tweaks and removal of mutator-specific section. |
| if (!versionSuffix) { | ||
| console.log(`Updating changelog`); | ||
| commitMessageLines.push(`- dotnet-stryker@${newVersionNumber}`); | ||
| releaseNotes = execSync(`npx conventional-changelog-cli -p angular --tag-prefix "dotnet-stryker@"`, { encoding: 'utf8' }).trim(); |
There was a problem hiding this comment.
releaseNotes is captured by running conventional-changelog-cli without limiting the output, which typically prints the entire changelog history to stdout. That means the annotated tag message (and therefore gh release ... --notes-from-tag) can become huge/incorrect (entire history instead of just this release) and may even fail due to execSync maxBuffer. Consider generating only the latest release section (e.g., --release-count 1 / -r 1) and ensure the options match whatever you write into CHANGELOG.md so the tag notes and file contents stay consistent.
| releaseNotes = execSync(`npx conventional-changelog-cli -p angular --tag-prefix "dotnet-stryker@"`, { encoding: 'utf8' }).trim(); | |
| const changelogCommand = 'npx conventional-changelog-cli -p angular --tag-prefix "dotnet-stryker@" --release-count 1'; | |
| releaseNotes = execSync(changelogCommand, { encoding: 'utf8' }).trim(); |
| console.log('Updating azure-pipelines.yml'); | ||
| replaceVersionNumber('./azure-pipelines.yml', `VersionBuildNumber: $[counter('${oldVersion}', 1)]`, `VersionBuildNumber: $[counter('${versionPrefix}', 1)]`); | ||
| replaceVersionNumber('./azure-pipelines.yml', `PackageVersion: '${oldVersion}'`, `PackageVersion: '${versionPrefix}'`); | ||
|
|
There was a problem hiding this comment.
The temp file .release-notes.md is deleted only if git tag succeeds; if git tag throws, the file is left behind in the repo root. Using a try/finally cleanup (and ideally a unique temp file location such as os.tmpdir()/fs.mkdtemp) would prevent leftover files and avoid collisions if the script is re-run.
| console.log(`Creating commit`); | ||
| exec('git add .'); | ||
| exec(`git commit ${commitMessageLines.map(entry => `-m "${entry}"`).join(' ')}`); | ||
|
|
||
| console.log(`Pushing commit ${versionSuffix?'':' and tags'}`); |
There was a problem hiding this comment.
newVersionNumber is interpolated into shell commands (git tag ...${newVersionNumber}... and gh release create ...${newVersionNumber}...) executed via execSync with a string, which runs through a shell. Even though this is a maintainer script, it’s safer to validate the version input (e.g., strict semver) and/or use execFileSync/spawnSync with an argument array to avoid shell injection and quoting issues.
| 3. Run `npm run prepare-release` from the root of the repo and enter the new version number. This will automatically create GitHub releases for the new tags with the correct release notes from the changelogs. | ||
| 4. Verify that the commit is on GitHub and that the GitHub releases for the new version have been created. |
There was a problem hiding this comment.
The text says this will create GitHub releases for “the new tags” (plural) with notes from “the changelogs”, but prepare-release.js currently creates a single dotnet-stryker@... tag/release and updates only the root CHANGELOG.md. Please adjust the wording to match the actual behavior (singular), or update the script to create releases for multiple tags if that’s the intent.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
.github/copilot-instructions.md:62
- This PR is focused on release automation, but this file also removes the “Adding a New Mutator” guidance section entirely. If that removal is intentional, it should be called out in the PR description; otherwise, consider moving it to a separate PR to keep changes scoped.
6. Run with `Stryker.CLI` as the startup project
**Note**: Running Stryker on itself doesn't work as assemblies will be in use. To run Stryker on the stryker codebase, use the official nuget release via `dotnet tool install dotnet-stryker` and then `dotnet stryker`.
| - name: Generate changelog | ||
| run: conventional-changelog -p angular -t "dotnet-stryker@" > /tmp/release-notes.md | ||
|
|
There was a problem hiding this comment.
conventional-changelog is run before updating package.json/csproj versions. In prepare-release.js, the changelog is generated after package.json is updated, which suggests the changelog generator relies on the current version from package.json. As written, the generated notes/tag message may be stamped with the old version. Consider moving changelog generation after updating package.json (or passing the intended version explicitly, if supported).
| contents: write | ||
|
|
||
| jobs: | ||
| release: |
There was a problem hiding this comment.
This workflow can be manually dispatched on any branch, but it commits, tags, and pushes directly to the checked-out ref. It should be guarded to run only from the release branch (e.g., master) to avoid accidentally creating release commits/tags from a feature branch.
| release: | |
| release: | |
| if: github.ref == 'refs/heads/master' |
|
|
||
| permissions: | ||
| contents: write | ||
|
|
There was a problem hiding this comment.
Release workflows should use concurrency to prevent two manual dispatches from racing (e.g., two runs creating the same tag or pushing overlapping commits). Consider adding a concurrency group like release-${{ github.ref }} with cancel-in-progress: false.
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false |
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 |
There was a problem hiding this comment.
Other workflows in this repo use actions/checkout@v6 (e.g., .github/workflows/integration-test.yaml). For consistency and to stay on the same major version baseline, consider updating this to actions/checkout@v6 as well.
| uses: actions/checkout@v4 | |
| uses: actions/checkout@v6 |
| node-version: 20 | ||
|
|
||
| - name: Install changelog tools | ||
| run: npm install -g conventional-changelog-cli@5 conventional-recommended-bump@6 semver@7 |
There was a problem hiding this comment.
npm install -g conventional-changelog-cli@5 conventional-recommended-bump@6 semver@7 pins only major versions. For a release workflow, this can lead to non-reproducible releases if a minor/patch introduces breaking behavior. Consider pinning to exact versions (or using a lockfile-based install) to make the release process deterministic.
| run: npm install -g conventional-changelog-cli@5 conventional-recommended-bump@6 semver@7 | |
| run: npm install -g conventional-changelog-cli@5.0.0 conventional-recommended-bump@6.1.0 semver@7.6.3 |
| exec('git push --follow-tags'); | ||
| if (!versionSuffix) { | ||
| try { | ||
| const execSync = require('node:child_process').execSync; |
There was a problem hiding this comment.
execSync is already imported at the top of this file; re-requiring it here (with a different module specifier) is redundant and makes it less clear which execSync is in use. Consider reusing the existing import instead of shadowing it.
| const execSync = require('node:child_process').execSync; |
Removed merge conflict markers and updated changelog for version 4.14.1.
| echo "old_prefix=$OLD_PREFIX" >> "$GITHUB_OUTPUT" | ||
| echo "old_suffix=$OLD_SUFFIX" >> "$GITHUB_OUTPUT" | ||
| echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" | ||
| echo "new_prefix=$NEW_PREFIX" >> "$GITHUB_OUTPUT" | ||
| echo "new_suffix=$NEW_SUFFIX" >> "$GITHUB_OUTPUT" | ||
|
|
There was a problem hiding this comment.
Writing untrusted values directly into $GITHUB_OUTPUT via echo "key=$value" can allow output-injection if inputs.version contains newlines or delimiter characters. Use the documented multiline-safe output format (e.g., key<<EOF ... EOF) or otherwise sanitize/escape NEW_VERSION/suffix before writing to $GITHUB_OUTPUT.
| echo "old_prefix=$OLD_PREFIX" >> "$GITHUB_OUTPUT" | |
| echo "old_suffix=$OLD_SUFFIX" >> "$GITHUB_OUTPUT" | |
| echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "new_prefix=$NEW_PREFIX" >> "$GITHUB_OUTPUT" | |
| echo "new_suffix=$NEW_SUFFIX" >> "$GITHUB_OUTPUT" | |
| write_github_output() { | |
| local key="$1" | |
| local value="$2" | |
| local delimiter | |
| delimiter=$(uuidgen) | |
| { | |
| echo "${key}<<${delimiter}" | |
| echo "$value" | |
| echo "${delimiter}" | |
| } >> "$GITHUB_OUTPUT" | |
| } | |
| write_github_output "old_prefix" "$OLD_PREFIX" | |
| write_github_output "old_suffix" "$OLD_SUFFIX" | |
| write_github_output "new_version" "$NEW_VERSION" | |
| write_github_output "new_prefix" "$NEW_PREFIX" | |
| write_github_output "new_suffix" "$NEW_SUFFIX" |
| run: | | ||
| node << 'EOF' | ||
| const fs = require('fs'); | ||
| const { OLD_PREFIX, OLD_SUFFIX, NEW_PREFIX } = process.env; | ||
| const oldVersion = OLD_SUFFIX ? `${OLD_PREFIX}-${OLD_SUFFIX}` : OLD_PREFIX; | ||
| let content = fs.readFileSync('azure-pipelines.yml', 'utf8'); | ||
| content = content.replace( | ||
| `VersionBuildNumber: $[counter('${oldVersion}', 1)]`, | ||
| `VersionBuildNumber: $[counter('${NEW_PREFIX}', 1)]` | ||
| ); | ||
| content = content.replace( | ||
| `PackageVersion: '${oldVersion}'`, | ||
| `PackageVersion: '${NEW_PREFIX}'` |
There was a problem hiding this comment.
This forces PackageVersion to NEW_PREFIX and ignores NEW_SUFFIX/NEW_VERSION. If inputs.version includes a prerelease (e.g. 4.16.0-beta.1), the tag/release will be prerelease but Azure Pipelines will be updated to build/publish the stable 4.16.0, creating a version mismatch. Use NEW_VERSION (or NEW_PREFIX + NEW_SUFFIX) when updating PackageVersion, and consider whether the counter() key should also incorporate prerelease vs stable to avoid mixing build counters across channels.
| run: | | |
| node << 'EOF' | |
| const fs = require('fs'); | |
| const { OLD_PREFIX, OLD_SUFFIX, NEW_PREFIX } = process.env; | |
| const oldVersion = OLD_SUFFIX ? `${OLD_PREFIX}-${OLD_SUFFIX}` : OLD_PREFIX; | |
| let content = fs.readFileSync('azure-pipelines.yml', 'utf8'); | |
| content = content.replace( | |
| `VersionBuildNumber: $[counter('${oldVersion}', 1)]`, | |
| `VersionBuildNumber: $[counter('${NEW_PREFIX}', 1)]` | |
| ); | |
| content = content.replace( | |
| `PackageVersion: '${oldVersion}'`, | |
| `PackageVersion: '${NEW_PREFIX}'` | |
| NEW_VERSION: ${{ steps.version.outputs.new_version }} | |
| run: | | |
| node << 'EOF' | |
| const fs = require('fs'); | |
| const { OLD_PREFIX, OLD_SUFFIX, NEW_PREFIX, NEW_VERSION } = process.env; | |
| const oldVersion = OLD_SUFFIX ? `${OLD_PREFIX}-${OLD_SUFFIX}` : OLD_PREFIX; | |
| let content = fs.readFileSync('azure-pipelines.yml', 'utf8'); | |
| content = content.replace( | |
| `VersionBuildNumber: $[counter('${oldVersion}', 1)]`, | |
| `VersionBuildNumber: $[counter('${NEW_VERSION}', 1)]` | |
| ); | |
| content = content.replace( | |
| `PackageVersion: '${oldVersion}'`, | |
| `PackageVersion: '${NEW_VERSION}'` |
| - name: Generate changelog | ||
| run: conventional-changelog -p angular -t "dotnet-stryker@" > /tmp/release-notes.md | ||
|
|
||
| - name: Update CHANGELOG.md | ||
| run: | | ||
| node << 'EOF' | ||
| const fs = require('fs'); | ||
| const releaseNotes = fs.readFileSync('/tmp/release-notes.md', 'utf8').trim(); | ||
| const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); | ||
| const marker = '<!-- changelog -->'; | ||
| if (!changelog.includes(marker)) { | ||
| console.error('CHANGELOG.md is missing the <!-- changelog --> marker'); | ||
| process.exit(1); | ||
| } | ||
| fs.writeFileSync('CHANGELOG.md', changelog.replace(marker, `${marker}\n\n${releaseNotes}`)); | ||
| EOF |
There was a problem hiding this comment.
conventional-changelog is executed before the repo is tagged and before package.json is updated, which can cause /tmp/release-notes.md to represent the wrong section (e.g., “Unreleased” or the previous release) depending on existing tags and tool behavior. To ensure the GitHub release notes and inserted changelog section correspond exactly to NEW_VERSION, generate notes in a way that targets the unreleased range for the upcoming version (or update version/tag first and then generate notes against that state).
| for (const file of csprojFiles) { | ||
| let content = fs.readFileSync(file, 'utf8'); | ||
| content = content.replace( | ||
| `<VersionPrefix>${OLD_PREFIX}</VersionPrefix>`, | ||
| `<VersionPrefix>${NEW_PREFIX}</VersionPrefix>` | ||
| ); | ||
| content = content.replace( | ||
| /<VersionSuffix>.*<\/VersionSuffix>/, | ||
| `<VersionSuffix>${NEW_SUFFIX}</VersionSuffix>` | ||
| ); | ||
| fs.writeFileSync(file, content); | ||
| } |
There was a problem hiding this comment.
The release workflow relies on string replacements that can silently do nothing if the file format changes (e.g., whitespace differences, multiple occurrences, missing tags). For release automation, it’s safer to assert that each expected replacement happened (and fail the workflow if not), so you don’t end up tagging/publishing a release with inconsistent version metadata.
| - On **Windows**: Run `.\integration-tests.ps1` in the root of the repo (PowerShell 7 recommended) | ||
| - On **macOS/Linux**: Run `pwsh ./integration-tests.ps1` in the root of the repo (requires [PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell)) | ||
| - Always run unit tests and integration tests before committing changes | ||
| - Always run unit tests and integration after making a change to ensure nothing is broken |
There was a problem hiding this comment.
This sentence is missing a word and reads incorrectly; update 'integration' to 'integration tests' for clarity and grammar.
| - Always run unit tests and integration after making a change to ensure nothing is broken | |
| - Always run unit tests and integration tests after making a change to ensure nothing is broken |
|
|
||
| * **baseline:** S3 Provider Cleanup ([#3491](https://github.com/stryker-mutator/stryker-net/issues/3491)) ([4a40c42](https://github.com/stryker-mutator/stryker-net/commit/4a40c42ded7ffdd343f991388ddc4da111ee9d04)) | ||
| * handle null Messages in TestRunAccumulator.Aggregate ([#3513](https://github.com/stryker-mutator/stryker-net/issues/3513)) ([58a89b7](https://github.com/stryker-mutator/stryker-net/commit/58a89b73236e53e3419bc91fa1f36402c69835c7)), closes [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) No newline at end of file | ||
| * handle null Messages in TestRunAccumulator.Aggregate ([#3513](https://github.com/stryker-mutator/stryker-net/issues/3513)) ([58a89b7](https://github.com/stryker-mutator/stryker-net/commit/58a89b73236e53e3419bc91fa1f36402c69835c7)), closes [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) |
There was a problem hiding this comment.
The “closes” clause repeats #3510 multiple times, which makes the changelog noisy and harder to read. Suggest collapsing it to a single closes #3510 reference (or a single markdown link) to keep the entry clean.
| * handle null Messages in TestRunAccumulator.Aggregate ([#3513](https://github.com/stryker-mutator/stryker-net/issues/3513)) ([58a89b7](https://github.com/stryker-mutator/stryker-net/commit/58a89b73236e53e3419bc91fa1f36402c69835c7)), closes [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) | |
| * handle null Messages in TestRunAccumulator.Aggregate ([#3513](https://github.com/stryker-mutator/stryker-net/issues/3513)) ([58a89b7](https://github.com/stryker-mutator/stryker-net/commit/58a89b73236e53e3419bc91fa1f36402c69835c7)), closes [#3510](https://github.com/stryker-mutator/stryker-net/issues/3510) |
…mutator/stryker-net into feature/github-releases
|
Co-authored-by: Copilot <copilot@github.com>
| - name: Create git tag | ||
| env: | ||
| NEW_VERSION: ${{ steps.version.outputs.new_version }} | ||
| run: git tag -a "dotnet-stryker@${NEW_VERSION}" -F /tmp/release-notes.md |
There was a problem hiding this comment.
The git tag -a ... -F /tmp/release-notes.md uses git’s default message cleanup, which strips lines starting with # (markdown headings). That’s likely why section headers (e.g., Features/Bug Fixes) go missing. Use --cleanup=verbatim here as well (consistent with prepare-release.js) so tag annotations preserve markdown headings.
| run: git tag -a "dotnet-stryker@${NEW_VERSION}" -F /tmp/release-notes.md | |
| run: git tag -a --cleanup=verbatim "dotnet-stryker@${NEW_VERSION}" -F /tmp/release-notes.md |
| - name: Create git tag | ||
| env: | ||
| NEW_VERSION: ${{ steps.version.outputs.new_version }} | ||
| run: git tag -a "dotnet-stryker@${NEW_VERSION}" -F /tmp/release-notes.md | ||
|
|
||
| - name: Push changes and tags | ||
| run: git push --follow-tags | ||
|
|
||
| - name: Create GitHub release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| NEW_VERSION: ${{ steps.version.outputs.new_version }} | ||
| run: | | ||
| { | ||
| cat /tmp/release-notes.md | ||
| printf "\n---\n\n**NuGet:** https://www.nuget.org/packages/dotnet-stryker/%s\n" "${NEW_VERSION}" | ||
| } > /tmp/gh-release-notes.md | ||
| gh release create "dotnet-stryker@${NEW_VERSION}" \ | ||
| --title "dotnet-stryker@${NEW_VERSION}" \ | ||
| --notes-file /tmp/gh-release-notes.md |
There was a problem hiding this comment.
This workflow always creates a tag and a GitHub Release even when NEW_VERSION includes a prerelease suffix (e.g. -alpha.1). The existing prepare-release.js intentionally skips tagging/release creation for suffixed versions, so this would change current behavior and can publish prereleases as normal releases. Consider either (1) skipping tag/release steps when new_suffix is non-empty, or (2) creating the GitHub release with --prerelease (and deciding whether tags should be created for prereleases).
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
| token: ${{ secrets.GITHUB_TOKEN }} |
There was a problem hiding this comment.
Repo workflows consistently use actions/checkout@v6 (see .github/workflows/integration-test.yaml and stryker-on-stryker.yaml), but this new workflow uses actions/checkout@v4. Aligning the version avoids having multiple major versions of the same core action across workflows and makes updates/maintenance easier.
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 |
| 3. Set `Launch` as `Project` | ||
| 4. Set `WorkingDirectory` to a unit test project directory | ||
| 5. You can use projects in `.\integrationtest\TargetProjects` for testing | ||
| 6. Run with `Stryker.CLI` as the startup project | ||
|
|
||
| **Note**: Running Stryker on itself doesn't work as assemblies will be in use. To run Stryker on the stryker codebase, use the official nuget release via `dotnet tool install dotnet-stryker` and then `dotnet stryker`. | ||
|
|
There was a problem hiding this comment.
This PR is scoped to adding GitHub release logic, but this change removes the entire “Adding a New Mutator” section from the Copilot instructions. If that removal is unintentional, it should be reverted or moved to a separate PR to keep the release-related changes focused.


Addresses #3494
Update:
I've tested during the latest release and the script seems to be working. Except for the missing header in the github release for Defects and Features.
Todo: