Skip to content

If a continuous course book is loaded, the the step is set to at leas… #30

If a continuous course book is loaded, the the step is set to at leas…

If a continuous course book is loaded, the the step is set to at leas… #30

# VokabelTrainer v1.6
# Copyright (C) 2025-2026 NataljaNeumann@gmx.de
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
name: Build .NET Desktop
env:
APP_NAME: VokabelTrainer
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: write
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Increment build number in AssemblyInfo.cs
id: increment
shell: pwsh
run: |
# Use the application name from the workflow env
$app = $env:APP_NAME
Write-Host "Using application name: $app"
# Find project files and AssemblyInfo files
$projFiles = Get-ChildItem -Path . -Recurse -File -Include *.csproj,*.vbproj,*.fsproj -ErrorAction SilentlyContinue
$assemblyFiles = Get-ChildItem -Path . -Recurse -File -Filter AssemblyInfo.cs -ErrorAction SilentlyContinue
$newVersion = $null
function Increment-Version($major, $minor, $build, $revision) {
$build = [int]$build + 1
return "$major.$minor.$build.0"
}
# Try to find version in project files first
foreach ($proj in $projFiles) {
$text = Get-Content -Raw -LiteralPath $proj.FullName
if ($text -match '<Version>\s*(\d+)\.(\d+)\.(\d+)\.(\d+)\s*</Version>') {
$newVersion = Increment-Version $matches[1] $matches[2] $matches[3] $matches[4]
Write-Host "Found <Version> in $($proj.FullName) -> $newVersion"
break
}
if ($text -match '<AssemblyVersion>\s*(\d+)\.(\d+)\.(\d+)\.(\d+)\s*</AssemblyVersion>') {
$newVersion = Increment-Version $matches[1] $matches[2] $matches[3] $matches[4]
Write-Host "Found <AssemblyVersion> in $($proj.FullName) -> $newVersion"
break
}
if ($text -match '<FileVersion>\s*(\d+)\.(\d+)\.(\d+)\.(\d+)\s*</FileVersion>') {
$newVersion = Increment-Version $matches[1] $matches[2] $matches[3] $matches[4]
Write-Host "Found <FileVersion> in $($proj.FullName) -> $newVersion"
break
}
}
# If not found in project files, look inside AssemblyInfo.cs files
if (-not $newVersion) {
foreach ($a in $assemblyFiles) {
$lines = Get-Content -LiteralPath $a.FullName
foreach ($line in $lines) {
if ($line -match 'AssemblyVersion\("(\d+)\.(\d+)\.(\d+)\.(\d+)"\)') {
$newVersion = Increment-Version $matches[1] $matches[2] $matches[3] $matches[4]
Write-Host "Found AssemblyVersion in $($a.FullName) -> $newVersion"
break
}
if ($line -match 'AssemblyFileVersion\("(\d+)\.(\d+)\.(\d+)\.(\d+)"\)') {
$newVersion = Increment-Version $matches[1] $matches[2] $matches[3] $matches[4]
Write-Host "Found AssemblyFileVersion in $($a.FullName) -> $newVersion"
break
}
}
if ($newVersion) { break }
}
}
if (-not $newVersion) {
Write-Error "Couldn't find any version information in project files or AssemblyInfo.cs"
exit 1
}
# Update project files: <Version>, <AssemblyVersion>, <FileVersion>
foreach ($proj in $projFiles) {
$path = $proj.FullName
$text = Get-Content -Raw -LiteralPath $path
$changed = $false
if ($text -match '<Version>\s*(\d+\.\d+\.\d+\.\d+)\s*</Version>') {
$text = $text -replace '<Version>\s*\d+\.\d+\.\d+\.\d+\s*</Version>', "<Version>$newVersion</Version>"
$changed = $true
Write-Host "Modifying Version in $path"
}
# Replace AssemblyVersion and FileVersion tags if present
if ($text -match '<AssemblyVersion>\s*\d+\.\d+\.\d+\.\d+\s*</AssemblyVersion>') {
$text = $text -replace '<AssemblyVersion>\s*\d+\.\d+\.\d+\.\d+\s*</AssemblyVersion>', "<AssemblyVersion>$newVersion</AssemblyVersion>"
$changed = $true
Write-Host "Modifying AssemblyVersion in $path"
}
if ($text -match '<FileVersion>\s*\d+\.\d+\.\d+\.\d+\s*</FileVersion>') {
$text = $text -replace '<FileVersion>\s*\d+\.\d+\.\d+\.\d+\s*</FileVersion>', "<FileVersion>$newVersion</FileVersion>"
$changed = $true
Write-Host "Modifying FileVersion in $path"
}
if ($changed) {
Set-Content -LiteralPath $path -Value $text -Encoding UTF8
Write-Host "Updated $path"
}
}
# Update AssemblyInfo.cs files found anywhere
foreach ($a in $assemblyFiles) {
$path = $a.FullName
$lines = Get-Content -LiteralPath $path
$newLines = @()
foreach ($line in $lines) {
if ($line -match 'AssemblyVersion\("(\d+)\.(\d+)\.(\d+)\.(\d+)"\)') {
$newLines += "[assembly: AssemblyVersion(`"$newVersion`")]"
} elseif ($line -match 'AssemblyFileVersion\("(\d+)\.(\d+)\.(\d+)\.(\d+)"\)') {
$newLines += "[assembly: AssemblyFileVersion(`"$newVersion`")]"
} else {
$newLines += $line
}
}
Set-Content -LiteralPath $path -Value $newLines -Encoding UTF8
Write-Host "Updated $path"
}
# publish the version as a step output in the new ($GITHUB_OUTPUT) format
Add-Content -Path $env:GITHUB_OUTPUT -Value "version=$newVersion"
- name: Setup NuGet
uses: NuGet/setup-nuget@v2
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2
# with:
# vs-version: '17.8'
- name: Restore NuGet packages
run: nuget restore ${{ env.APP_NAME }}.sln
# - name: Download SNK file from secrets
# run: |
# $snkBytes = [System.Convert]::FromBase64String("${{ secrets.SNK_FILE }}")
# [System.IO.File]::WriteAllBytes("${{ env.APP_NAME }}/MyKey.snk", $snkBytes)
# - name: Build and sign with SNK
# run: msbuild ${{ env.APP_NAME }}.sln /p:Configuration=Release /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=MyKey.snk
# - name: Download PFX file from secrets
# run: |
# $pfxBytes = [System.Convert]::FromBase64String("${{ secrets.PFX_FILE }}")
# [System.IO.File]::WriteAllBytes("${{ env.APP_NAME }}/MyCert.pfx", $pfxBytes)
# - name: Sign executable with PFX
# run: |
# & "C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe" sign /f ${{ env.APP_NAME }}/MyCert.pfx /p ${{ secrets.PFX_PASSWORD }} /tr http://timestamp.digicert.com /td sha256 /fd sha256 ${{ env.APP_NAME }}/bin/Release/${{ env.APP_NAME }}.exe
- name: Build solution
run: msbuild ${{ env.APP_NAME }}.sln /p:Configuration=Release
# /verbosity:diag
# Step: Find all files to commit (only those that exist)
# This step checks for each file/pattern and builds a list of files to add to the commit.
# Threat: If too many files are matched, the commit may be large. If no files are found, commit will be skipped.
- name: Find files to commit
id: find_files_to_commit
shell: pwsh
run: |
$patterns = @('AssemblyInfo.cs', '*.csproj', '*.vbproj', '*.fsproj')
$files = @()
foreach ($pattern in $patterns) {
$found = Get-ChildItem -Path . -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
if ($found -and ($found -notmatch "\\bin\\") -and ($found -notmatch "\\obj\\")) {
$files += $found
}
}
if ($files.Count -eq 0) {
Write-Host "No files found to commit."
echo "files_to_commit=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
} else {
$fileList = $files -join ' '
Write-Host "Files to commit: $fileList"
echo "files_to_commit=$fileList" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
}
# Step: Commit version change only if files exist
# This step commits only the files found in the previous step.
# Useful parameters: message (commit message), add (files to add), github_token (auth token)
# Threat: If no files are found, this step will be skipped and no commit will be made.
- name: Commit version change
uses: EndBug/add-and-commit@v9
if: steps.find_files_to_commit.outputs.files_to_commit != ''
with:
message: "Set version to ${{ steps.increment.outputs.version }}"
add: ${{ steps.find_files_to_commit.outputs.files_to_commit }}
github_token: ${{ secrets.GH_TOKEN }}
#- name: Upload built zip archive
# uses: actions/upload-artifact@v4
# with:
# name: ${{ env.APP_NAME }}-zip
# path: ${{ env.APP_NAME }}/bin/${{ env.APP_NAME }}.zip
- name: Rename output zip to include version
shell: pwsh
run: |
$version = '${{ steps.increment.outputs.version }}'
$app = $env:APP_NAME
$src = Join-Path -Path $app -ChildPath "bin/$($app).zip"
$dest = Join-Path -Path $app -ChildPath "bin/$($app)-v$version.zip"
if (-not (Test-Path $src)) {
Write-Host "Source zip not found: $src (skipping rename)"
if (Test-Path $dest) { Write-Host "Dest zip found: $dest (skipping rename)" }
} else {
if (Test-Path $dest) { Remove-Item $dest -Force }
Move-Item -Path $src -Destination $dest -Force
Write-Host "Renamed $src -> $dest"
}
- name: Upload built zip archive(s) as workflow artifact(s)
uses: actions/upload-artifact@v4
with:
name: ${{ env.APP_NAME }}-v${{ steps.increment.outputs.version }}
path: ${{ env.APP_NAME }}/bin/*v${{ steps.increment.outputs.version }}.zip
- name: Generate release notes (commits since last tag and same major.minor)
id: generate_release_notes
shell: pwsh
run: |
$version = '${{ steps.increment.outputs.version }}'
# Ensure tags are available
git fetch --tags
# Get tags sorted by creation date (newest first)
$tags = git for-each-ref --sort=-creatordate --format '%(refname:short)' refs/tags | ForEach-Object { $_ }
$prevTag = if ($tags.Length -gt 0) { $tags[0] } else { "" }
# Parse current major.minor from incremented version
$major = $null; $minor = $null
if ($version -match '^(\d+)\.(\d+)\.(\d+)\.(\d+)$') {
$major = $matches[1]; $minor = $matches[2]
}
function Get-Commits($range) {
if ([string]::IsNullOrEmpty($range)) {
$list = git log --pretty=format:'%s' --no-merges
} else {
$list = git log $range --pretty=format:'%s' --no-merges
}
if (-not $list) { return @() }
$lines = $list -split "`n" | Where-Object { $_ -notmatch 'Set version to' }
return ,$lines
}
function Get-AuthorsUnique($range) {
if ([string]::IsNullOrEmpty($range)) {
$list = git log --pretty=format:'@%an' --no-merges
} else {
$list = git log $range --pretty=format:'@%an' --no-merges
}
if (-not $list) { return @() }
$authors = $list -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } | Select-Object -Unique
return ,$authors
}
# Part A: commits since last tag
if ($prevTag -ne "") {
$commitsSinceTag = Get-Commits("$prevTag..HEAD")
$authorsSinceTag = Get-AuthorsUnique("$prevTag..HEAD")
} else {
$commitsSinceTag = Get-Commits("")
$authorsSinceTag = Get-AuthorsUnique("")
}
# Prepend '- ' to each commit in commitsSinceTag
if ($commitsSinceTag.Count -eq 0) { $commitsSinceTag = @("(no commits found)") }
if ($commitsSinceTag.Count -gt 0) {
$commitsSinceTag = $commitsSinceTag | ForEach-Object { "- $_" }
}
# Append author(s) Part A
if ($authorsSinceTag.Count -gt 1) {
$commitsSinceTag += ("`n`nContributors: *" + ($authorsSinceTag -join ", ") + "*")
} elseif ($authorsSinceTag.Count -gt 0) {
$commitsSinceTag += ("`n`nContributor: *" + ($authorsSinceTag -join ", ") + "*")
}
# Part B: commits with same major.minor
$sameMinorCommits = @()
$sameMinorAuthors = @()
if ($major -ne $null -and $minor -ne $null) {
# collect commits from all tags that share the same major.minor up to HEAD
$matchingTags = @()
foreach ($t in $tags) {
if ($t -match '.*v(\d+)[\._-](\d+)(?:[\._-]\d+)?(?:[\._-]\d+)?[\.]?') {
$tagMajor = $matches[1]; $tagMinor = $matches[2]
} else { continue }
if ($tagMajor -eq $major -and $tagMinor -eq $minor) { $matchingTags += $t }
}
# If we found matching tags, compute commits since the oldest matching tag; else use all commits
if ($matchingTags.Count -gt 0) {
# oldest matching tag is the last in $tags that matched (tags already sorted by creatordate)
$oldestMatchingTag = $matchingTags[$matchingTags.Count - 1]
$sameMinorCommits = Get-Commits("$oldestMatchingTag..HEAD")
$sameMinorAuthors = Get-AuthorsUnique("$oldestMatchingTag..HEAD")
} else {
$sameMinorCommits = Get-Commits("")
$sameMinorAuthors = Get-AuthorsUnique("")
}
}
# Prepend '- ' to each commit in sameMinorCommits
if ($sameMinorCommits.Count -eq 0) { $sameMinorCommits = @("(no commits found)") }
if ($sameMinorCommits.Count -gt 0) {
$sameMinorCommits = $sameMinorCommits | ForEach-Object { "- $_" }
}
# Append author(s) Part B
if ($sameMinorAuthors.Count -gt 1) { $sameMinorCommits += ("`n`nContributors: *" + ($sameMinorAuthors -join ", ") + "*") }
elseif ($sameMinorAuthors.Count -gt 0) { $sameMinorCommits += ("`n`nContributor: *" + ($sameMinorAuthors -join ", ") + "*") }
$releaseBody = "### Changes since $(($prevTag -replace "_v", " v") -replace "_", ".")`n"
$releaseBody += ($commitsSinceTag -join "`n")
$releaseBody += "`n`n### Changes of ${major}.${minor}:`n"
$releaseBody += ($sameMinorCommits -join "`n")
# Publish as multiline step output
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "release_body<<EOF`n$releaseBody`nEOF`n")
# Creates a tag for the next release
- name: Create and push tag
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# ensure history & tags are present
# Only run --unshallow if the repo is actually shallow to avoid the harmless fatal
if (Test-Path -Path ".git\shallow") {
git fetch --unshallow --tags || git fetch --tags
} else {
git fetch --tags
}
$tag = "$($env:APP_NAME)_v${{ steps.increment.outputs.version }}"
if (git rev-parse --verify $tag 2>$null) {
Write-Host "Tag $tag already exists; skipping"
} else {
git tag -a $tag -m "Release $tag"
git push origin refs/tags/$tag
Write-Host "Created and pushed tag $tag"
}
# Step: Check if a release with the tag already exists using GitHub REST API
# This step uses the GitHub REST API to check if a release for the current tag exists.
# If the release exists, it sets the output 'release_exists' to true, otherwise false.
# Useful parameters: GITHUB_TOKEN (must have repo access)
# Threats: API rate limits, leaking sensitive info if token is misconfigured.
- name: Check if release exists
id: check_release
shell: pwsh
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
$tag = "${{ env.APP_NAME }}_v${{ steps.increment.outputs.version }}"
$headers = @{ Authorization = "token $env:GITHUB_TOKEN"; "Accept" = "application/vnd.github.v3+json" }
$repo = "${{ github.repository }}"
$url = "https://api.github.com/repos/$repo/releases/tags/$tag"
try {
$response = Invoke-RestMethod -Uri $url -Headers $headers -Method Get -ErrorAction Stop
Write-Host "Release for tag $tag exists."
echo "release_exists=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
} catch {
Write-Host "Release for tag $tag does not exist."
echo "release_exists=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
}
# Step: Create GitHub Release only if it does not already exist
# This step creates a new GitHub release for the current tag if it does not already exist.
# Useful parameters:
# - tag_name: The name of the tag for the release
# - release_name: The display name for the release
# - body: The release notes
# - draft: Set to true to create a draft release
# - prerelease: Set to true to mark as pre-release
# - body_path: Path to a file containing release notes
# - target_commitish: Commit SHA or branch to create the release from
# Threats: If secrets are misconfigured, release may expose sensitive info. If a release already exists, this step will fail unless conditionally skipped as below.
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
if: steps.check_release.outputs.release_exists == 'false'
with:
tag_name: ${{ env.APP_NAME }}_v${{ steps.increment.outputs.version }}
release_name: "Version ${{ steps.increment.outputs.version }}"
body: ${{ steps.generate_release_notes.outputs.release_body }}
draft: false # Set to true to create a draft release
prerelease: false # Set to true for pre-release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
# Threat: This action publishes a release. If secrets are misconfigured, it may expose sensitive info.
# Useful parameters: 'body_path' to load release notes from a file, 'target_commitish' to specify commit.
- name: Upload release asset(s) (all bin/*v<version>.zip)
shell: pwsh
env:
UPLOAD_URL: ${{ steps.create_release.outputs.upload_url }}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
APP_NAME: ${{ env.APP_NAME }}
VERSION: ${{ steps.increment.outputs.version }}
run: |
$app = $env:APP_NAME
$version = $env:VERSION
$uploadUrlTemplate = $env:UPLOAD_URL
Write-Host "UPLOAD_URL template: $uploadUrlTemplate"
if (-not $uploadUrlTemplate) {
Write-Host "No upload_url from create_release; nothing to upload."
exit 0
}
# Clean upload URL (remove template part like {?name,label} and ensure valid URI)
$uploadUrl = $uploadUrlTemplate -replace '\{\?name,label\}$',''
Write-Host "Cleaned UPLOAD_URL: $uploadUrl"
if (-not $uploadUrl) {
Write-Host "Upload URL is empty after cleaning. Exiting."
exit 1
}
$pattern = Join-Path -Path $app -ChildPath "bin/*v$version.zip"
Write-Host "Searching for files matching: $pattern"
$files = Get-ChildItem -Path $pattern -File -ErrorAction SilentlyContinue
if (-not $files -or $files.Count -eq 0) {
Write-Host "No files found to upload to release for pattern: $pattern"
exit 0
}
foreach ($f in $files) {
$name = $f.Name
$uri = "${uploadUrl}?name=$([System.Uri]::EscapeDataString(${name}))"
Write-Host "Uploading $($f.FullName) to $uri"
$bytes = [System.IO.File]::ReadAllBytes($f.FullName)
try {
Invoke-RestMethod -Uri $uri -Method Post -Headers @{ Authorization = "token $env:GITHUB_TOKEN"; "Content-Type" = "application/zip" } -Body $bytes
Write-Host "Uploaded $name"
} catch {
Write-Error ("Failed to upload ${name}: " + $_.Exception.Message)
exit 1
}
}