diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b550b17..25e2b20 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,23 +1,27 @@ -# Weekly dependency updates for the GitHub Actions pinned in .github/workflows. +# Dependabot (K22-DEP-20): keeps the SHA-pinned actions and the standards' +# reusable workflows current, weekly and grouped. The 7-day cooldown lets the +# community catch a compromised release before it reaches us; security updates +# are not delayed by it. # -# NuGet packages are deliberately NOT handled here. Dependabot's NuGet updater -# cannot evaluate the WinUI project's Windows-versioned target framework +# NuGet is deliberately NOT handled here. Dependabot's NuGet updater cannot +# evaluate the WinUI project's Windows-versioned target framework # (net10.0-windows10.0.19041.0; dependabot/dependabot-core#13923) and declares -# every newer package "not compatible", which also blocks the same packages in the -# other projects. The NuGet updates workflow (.github/workflows/nuget-updates.yml) -# does that job through `dotnet list package --outdated` instead. Dependabot -# security alerts for NuGet are unaffected: they come from the dependency graph. +# every newer package "not compatible", which also blocks the same packages in +# the other projects. .github/workflows/nuget-updates.yml does that job through +# `dotnet list package --outdated`, with the same cooldown (docs/adr/0004). +# Dependabot security alerts for NuGet are unaffected: they come from the +# dependency graph. version: 2 updates: - package-ecosystem: github-actions - directory: "/" - target-branch: dev + directory: / schedule: interval: weekly day: monday - time: "06:00" - timezone: America/New_York + cooldown: + default-days: 7 groups: - actions-all: - patterns: - - "*" + actions: + patterns: ["*"] + commit-message: + prefix: ci diff --git a/.github/scripts/New-ReleaseNotes.ps1 b/.github/scripts/New-ReleaseNotes.ps1 new file mode 100644 index 0000000..f80f4ee --- /dev/null +++ b/.github/scripts/New-ReleaseNotes.ps1 @@ -0,0 +1,289 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS + Writes release notes for a tag from the Conventional Commit subjects merged since + the previous release (K22-REL-02). + +.DESCRIPTION + Pull requests squash-merge with their Conventional Commit title as the subject, + so the commits between the previous published release and this tag are the + changelog. Subjects are grouped by type with breaking changes and security + fixes first; subjects that are not Conventional Commits land under "Other + changes" so nothing is silently dropped. Each entry links its pull request + ("(#123)" suffix) or, failing that, its commit. When an issue titled + "Release " exists (the release checklist, K22-REL-12) it is linked too. + + Needs the GitHub CLI authenticated for the repository (GH_TOKEN in Actions). + +.PARAMETER Tag + The release tag, e.g. v1.2.3 or v1.2.3-beta.1. + +.PARAMETER Repository + owner/name on GitHub. + +.PARAMETER OutputPath + Where the Markdown notes are written. + +.PARAMETER PreviousTag + The tag to compare from. Default: the most recent published release before + this tag, or the repository's first commit when there is none. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')] + [string] $Tag, + + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')] + [string] $Repository, + + [Parameter(Mandatory)] + [string] $OutputPath, + + [string] $PreviousTag +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +<# +.SYNOPSIS + Calls the GitHub REST API through gh and returns the parsed JSON, or $null when + the call fails (the caller decides whether that matters). +#> +function Invoke-GitHubApi +{ + param( + [Parameter(Mandatory)] [string] $Path, + [switch] $Paginate + ) + + $arguments = @('api', '-H', 'Accept: application/vnd.github+json') + if($Paginate) + { + $arguments += @('--paginate', '--slurp') + } + $arguments += $Path + + $output = & gh @arguments 2>$null + if($LASTEXITCODE -ne 0) + { + return $null + } + return (($output | ForEach-Object { "$_" }) -join "`n") | ConvertFrom-Json -Depth 50 +} + +<# +.SYNOPSIS + Finds the tag of the most recent published release that precedes the one being + released, so the notes cover exactly what changed since users last got a build. +#> +function Find-PreviousReleaseTag +{ + param([Parameter(Mandatory)] [string] $CurrentTag) + + $pages = Invoke-GitHubApi -Path "repos/$Repository/releases?per_page=100" -Paginate + if($null -eq $pages) + { + return $null + } + + $releases = @($pages | ForEach-Object { $_ }) | + Where-Object { -not $_.draft -and $_.tag_name -ne $CurrentTag } | + Sort-Object { [DateTime] $_.created_at } -Descending + if(@($releases).Count -eq 0) + { + return $null + } + return @($releases)[0].tag_name +} + +<# +.SYNOPSIS + Lists the commits between two refs through the compare API: subject, body, sha, + and the pull request number when the subject carries a "(#123)" suffix. +#> +function Get-CommitsBetween +{ + param( + [string] $From, + [Parameter(Mandatory)] [string] $To + ) + + if([string]::IsNullOrEmpty($From)) + { + $pages = Invoke-GitHubApi -Path "repos/$Repository/commits?sha=$To&per_page=100" -Paginate + $commits = @($pages | ForEach-Object { $_ }) + } + else + { + $compare = Invoke-GitHubApi -Path "repos/$Repository/compare/$From...$To" + if($null -eq $compare) + { + throw "Cannot compare $From...$To in $Repository." + } + $commits = @($compare.commits) + } + + foreach($commit in $commits) + { + $lines = $commit.commit.message -split "`r?`n" + $subject = $lines[0].Trim() + if($subject -match '^Merge (pull request|branch) ') + { + continue + } + $pullNumber = $null + if($subject -match '\s\(#(\d+)\)$') + { + $pullNumber = [int] $Matches[1] + $subject = $subject -replace '\s\(#\d+\)$', '' + } + $body = ($lines | Select-Object -Skip 1) -join "`n" + [pscustomobject]@{ Sha = $commit.sha; Subject = $subject; Body = $body; PullNumber = $pullNumber } + } +} + +<# +.SYNOPSIS + Classifies one commit into a notes section from its Conventional Commit header. + Breaking changes ("!" or a BREAKING CHANGE footer) and security fixes come + first; anything that is not a Conventional Commit goes to "Other changes". +#> +function Get-NotesSection +{ + param([Parameter(Mandatory)] [pscustomobject] $Commit) + + $header = '^(?[a-z]+)(\((?[^)]+)\))?(?!)?: (?.+)$' + if($Commit.Subject -notmatch $header) + { + return [pscustomobject]@{ Section = 'Other changes'; Text = $Commit.Subject } + } + + $type = $Matches['type'] + $scope = $Matches['scope'] + $description = $Matches['description'] + $text = if($scope) { "**$scope**: $description" } else { $description } + + if($Matches['bang'] -eq '!' -or $Commit.Body -match '(?m)^BREAKING[ -]CHANGE:') + { + return [pscustomobject]@{ Section = 'Breaking changes'; Text = $text } + } + if($type -eq 'security' -or $scope -eq 'security') + { + return [pscustomobject]@{ Section = 'Security'; Text = $text } + } + + $section = switch($type) + { + 'feat' { 'Features' } + 'fix' { 'Fixes' } + 'perf' { 'Performance' } + 'refactor' { 'Refactoring' } + 'docs' { 'Documentation' } + 'build' { 'Build and dependencies' } + 'ci' { 'CI' } + 'test' { 'Tests' } + 'chore' { 'Chores' } + 'style' { 'Style' } + 'revert' { 'Reverts' } + default { 'Other changes' } + } + return [pscustomobject]@{ Section = $section; Text = $text } +} + +<# +.SYNOPSIS + Finds the release-checklist issue ("Release ") so the notes can link it. +#> +function Find-ChecklistIssue +{ + param([Parameter(Mandatory)] [string] $CurrentTag) + + $query = [uri]::EscapeDataString("repo:$Repository is:issue in:title `"Release $CurrentTag`"") + $result = Invoke-GitHubApi -Path "search/issues?q=$query&per_page=5" + if($null -eq $result) + { + return $null + } + $match = @($result.items) | Where-Object { $_.title -eq "Release $CurrentTag" } | Select-Object -First 1 + return $match +} + +################################################################################ +## Resolve the range, classify every commit, and write the notes in section ## +## order with breaking changes and security fixes first (K22-REL-02). ## +################################################################################ +if([string]::IsNullOrEmpty($PreviousTag)) +{ + $PreviousTag = Find-PreviousReleaseTag -CurrentTag $Tag +} + +$commits = @(Get-CommitsBetween -From $PreviousTag -To $Tag) +$order = @('Breaking changes', 'Security', 'Features', 'Fixes', 'Performance', 'Refactoring', + 'Documentation', 'Build and dependencies', 'CI', 'Tests', 'Style', 'Chores', 'Reverts', 'Other changes') +$grouped = @{} +foreach($commit in $commits) +{ + $entry = Get-NotesSection -Commit $commit + $link = if($null -ne $commit.PullNumber) + { + "[#$($commit.PullNumber)](https://github.com/$Repository/pull/$($commit.PullNumber))" + } + else + { + "[$($commit.Sha.Substring(0, 7))](https://github.com/$Repository/commit/$($commit.Sha))" + } + if(-not $grouped.ContainsKey($entry.Section)) + { + $grouped[$entry.Section] = [System.Collections.Generic.List[string]]::new() + } + $grouped[$entry.Section].Add("- $($entry.Text) ($link)") +} + +$notes = [System.Collections.Generic.List[string]]::new() +foreach($section in $order) +{ + if(-not $grouped.ContainsKey($section)) + { + continue + } + $notes.Add("## $section") + $notes.Add('') + foreach($line in $grouped[$section]) + { + $notes.Add($line) + } + $notes.Add('') +} +if($commits.Count -eq 0) +{ + $notes.Add('No changes since the previous release.') + $notes.Add('') +} + +$checklist = Find-ChecklistIssue -CurrentTag $Tag +if($null -ne $checklist) +{ + $notes.Add("Release checklist: [#$($checklist.number)]($($checklist.html_url))") + $notes.Add('') +} +if(-not [string]::IsNullOrEmpty($PreviousTag)) +{ + $notes.Add("**Full changelog**: https://github.com/$Repository/compare/$PreviousTag...$Tag") +} +else +{ + $notes.Add("**Full changelog**: https://github.com/$Repository/commits/$Tag") +} +$notes.Add('') +$notes.Add('Every asset is listed in `SHA256SUMS` and carries a build-provenance attestation; a CycloneDX SBOM per artifact is attached. See "Verifying a release" in the README.') + +$directory = Split-Path -Parent ([System.IO.Path]::GetFullPath($OutputPath)) +if(-not (Test-Path -LiteralPath $directory)) +{ + New-Item -ItemType Directory -Path $directory | Out-Null +} +[System.IO.File]::WriteAllText($OutputPath, (($notes -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false)) +Write-Verbose "Wrote $($commits.Count) commit(s) of notes to $OutputPath" diff --git a/.github/scripts/Update-NuGetPackages.ps1 b/.github/scripts/Update-NuGetPackages.ps1 index 1f80391..cf86ab1 100644 --- a/.github/scripts/Update-NuGetPackages.ps1 +++ b/.github/scripts/Update-NuGetPackages.ps1 @@ -16,7 +16,9 @@ - minor and patch bumps are applied; - major bumps are reported but NOT applied unless -IncludeMajor is given, so they get a deliberate review; - - prerelease versions are never applied to a package that is on a stable one. + - prerelease versions are never applied to a package that is on a stable one; + - a version published fewer than -CooldownDays days ago is held back + (K22-DEP-20), so the ecosystem has time to catch a bad release first. Versions are edited in place in each .csproj (the exact Include/Version pair), preserving the file's encoding and line endings. @@ -30,6 +32,10 @@ .PARAMETER IncludeMajor Also apply major-version bumps. +.PARAMETER CooldownDays + Hold back versions published fewer than this many days ago. Default: 7. + When nuget.org cannot be asked for the publish date, the version is held. + .OUTPUTS Writes `changed=true|false` and `report=` to $env:GITHUB_OUTPUT when set. Exit code 0 on success (whether or not anything changed), non-zero on error. @@ -38,7 +44,8 @@ param( [string]$Solution = 'gclo.slnx', [string]$ReportPath = 'nuget-update-report.md', - [switch]$IncludeMajor + [switch]$IncludeMajor, + [int]$CooldownDays = 7 ) $ErrorActionPreference = 'Stop' @@ -58,6 +65,18 @@ function Test-Prerelease([string]$version) { return $version.Contains('-') } +function Get-PublishedDate([string]$id, [string]$version) { + # nuget.org's registration leaf carries the publish timestamp of one version. + $url = "https://api.nuget.org/v3/registration5-gz-semver2/$($id.ToLowerInvariant())/$($version.ToLowerInvariant()).json" + try { + $leaf = Invoke-RestMethod -Uri $url -TimeoutSec 30 + return [DateTimeOffset]::Parse($leaf.published, [System.Globalization.CultureInfo]::InvariantCulture) + } + catch { + return $null + } +} + Write-Host "Restoring $Solution" dotnet restore $Solution | Out-Null if ($LASTEXITCODE -ne 0) { throw "dotnet restore failed with exit code $LASTEXITCODE." } @@ -107,6 +126,16 @@ foreach ($row in ($rows.Values | Sort-Object Project, Id)) { elseif ($latest -le $current) { $reason = 'not newer than the requested version' } + elseif ($CooldownDays -gt 0) { + $published = Get-PublishedDate $row.Id $row.Latest + if ($null -eq $published) { + $reason = "publish date unavailable from nuget.org; held for the $CooldownDays-day cooldown" + } + elseif ($published -gt [DateTimeOffset]::UtcNow.AddDays(-$CooldownDays)) { + $age = [int][math]::Floor(([DateTimeOffset]::UtcNow - $published).TotalDays) + $reason = "published $age day(s) ago; waits out the $CooldownDays-day cooldown" + } + } if ($reason) { $held.Add([pscustomobject]@{ Project = $projectName; Id = $row.Id; From = $row.Requested; To = $row.Latest; Reason = $reason }) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28ff7ee..f670a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ name: CI on: push: - branches: [main, dev] + branches: [main] pull_request: # The Release workflow calls this one so a tag is gated by exactly the checks # a pull request is: build, coverage, and formatting, with the UI smoke suite @@ -38,11 +38,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + global-json-file: global.json cache: true cache-dependency-path: '**/packages.lock.json' @@ -121,11 +123,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + global-json-file: global.json cache: true cache-dependency-path: '**/packages.lock.json' @@ -157,11 +161,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + global-json-file: global.json cache: true cache-dependency-path: '**/packages.lock.json' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3ef6231..c656bc2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,59 +1,33 @@ -# CodeQL static analysis for C#. -# -# C# is a compiled language, so CodeQL needs a real build. We use build-mode -# "manual" and run the same x64 solution build the CI workflow uses (without -# -warnaserror - the CI workflow already gates warnings; this workflow's job is -# security analysis, and it should still scan code that has a pending warning). -name: CodeQL +# Static analysis (K22-CI-22): CodeQL with the security-extended suite for C# and +# for the workflows themselves, failing on alerts of high severity or above, +# through the KofTwentyTwo standards' reusable workflow. C# is compiled, so the +# analysis builds the solution exactly as CI does (x64, locked restore); the .NET +# SDK comes from global.json. +name: codeql -# Runs on dev as well as main: feature PRs target dev (CONTRIBUTING.md), so -# scanning only main would let code reach the integration branch unanalyzed. on: - push: - branches: [main, dev] pull_request: - branches: [main, dev] + push: + branches: [main] schedule: - # Weekly scan (Mondays 06:30 UTC) so new CodeQL queries run even when the - # code has not changed. - - cron: '30 6 * * 1' + - cron: "29 6 * * 1" + workflow_dispatch: -permissions: - contents: read +permissions: {} + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - analyze: - name: Analyze (csharp) - runs-on: windows-latest - timeout-minutes: 120 + codeql: + uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@ae174f54959ba4297f2ae174356c4481918fdf44 # main 2026-10-09 (pre-1.0 draft, standards#4) permissions: - # Required for github/codeql-action to upload scan results. - security-events: write - # Required to fetch public CodeQL packs. - packages: read - actions: read contents: read - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.0.x - cache: true - cache-dependency-path: '**/packages.lock.json' - - - name: Initialize CodeQL - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4 - with: - languages: csharp - build-mode: manual - - - name: Build solution (x64) - run: dotnet build gclo.slnx -p:Platform=x64 -p:RestoreLockedMode=true - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4 - with: - category: '/language:csharp' + security-events: write + with: + languages: '["csharp", "actions"]' + build-mode: manual + build-command: dotnet build gclo.slnx -p:Platform=x64 -p:RestoreLockedMode=true + runs-on: windows-latest + setup-dotnet: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 3747490..8d26e2a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,3 +1,7 @@ +# Superseded by pr-gates.yml (`pr / dependency-review`, K22-CI-01); kept only +# until the protect-main ruleset lists the new required contexts (#66), then +# removed. +# # Blocks pull requests that add dependencies with known vulnerabilities or with # licenses outside the allowlist below. Only dependencies *added or changed* by # the PR are checked (via the GitHub dependency graph diff), so existing @@ -23,6 +27,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Dependency review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.github/workflows/nuget-updates.yml b/.github/workflows/nuget-updates.yml index 299a369..25a8821 100644 --- a/.github/workflows/nuget-updates.yml +++ b/.github/workflows/nuget-updates.yml @@ -1,4 +1,4 @@ -# Weekly NuGet dependency updates, as a pull request against dev. +# Weekly NuGet dependency updates, as a pull request against main. # # This replaces Dependabot's NuGet ecosystem for this repository. Dependabot's NuGet # updater cannot evaluate Windows-versioned target frameworks (the WinUI project's @@ -9,17 +9,18 @@ # another way". `dotnet list package --outdated` evaluates through real MSBuild and # NuGet, so the script below drives the bump from that instead. # -# Flow: bump minor/patch versions in the .csproj files -> run the same gates CI -# runs (zero-warning x64 build, both unit suites, format) -> open or update a single -# PR on branch deps/nuget-weekly with the report and the gate results in its body. -# Major bumps are listed in the report but not applied; run the workflow manually -# with include-major to take them. +# Flow: bump minor/patch versions in the .csproj files (skipping versions newer +# than the 7-day cooldown, K22-DEP-20) -> run the same gates CI runs (zero-warning +# x64 build, both unit suites, format) -> open or update a single PR on branch +# build/nuget-weekly with the report and the gate results in its body. Major bumps +# are listed in the report but not applied; run the workflow manually with +# include-major to take them. See docs/adr/0004-nuget-updates-workflow.md. # # Token note: with the default GITHUB_TOKEN, GitHub does not start other workflows # for the PR this job creates, so the PR shows no CI checks of its own (the gates -# were run here, and dev -> main still goes through the required checks). To get -# checks on the PR itself, add a fine-grained PAT with contents + pull-requests -# write as the DEPS_PAT repository secret; the step below prefers it when present. +# were run here). To get checks on the PR itself, add a fine-grained PAT with +# contents + pull-requests write as the DEPS_PAT repository secret; the step below +# prefers it when present. name: NuGet updates on: @@ -49,24 +50,27 @@ jobs: contents: write pull-requests: write steps: - - name: Checkout dev + - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: dev + ref: main + persist-credentials: false - name: Set up .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x + global-json-file: global.json cache: true cache-dependency-path: '**/packages.lock.json' - name: Bump package versions id: bump shell: pwsh + env: + INCLUDE_MAJOR: ${{ inputs.include-major }} run: | - $params = @{ Solution = 'gclo.slnx'; ReportPath = 'nuget-update-report.md' } - if ('${{ inputs.include-major }}' -eq 'true') { $params.IncludeMajor = $true } + $params = @{ Solution = 'gclo.slnx'; ReportPath = 'nuget-update-report.md'; CooldownDays = 7 } + if ($env:INCLUDE_MAJOR -eq 'true') { $params.IncludeMajor = $true } ./.github/scripts/Update-NuGetPackages.ps1 @params # The committed lock files must follow the bump, or CI's locked restore @@ -99,11 +103,17 @@ jobs: if: steps.bump.outputs.changed == 'true' id: format continue-on-error: true - run: dotnet format gclo.slnx --verify-no-changes --severity error + run: dotnet format gclo.slnx --verify-no-changes --severity warn --no-restore - name: Compose PR body if: steps.bump.outputs.changed == 'true' shell: pwsh + env: + BUILD_OUTCOME: ${{ steps.build.outcome }} + ENGINE_TESTS_OUTCOME: ${{ steps.engine-tests.outcome }} + CLI_TESTS_OUTCOME: ${{ steps.cli-tests.outcome }} + FORMAT_OUTCOME: ${{ steps.format.outcome }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | function Mark($outcome) { switch ($outcome) { @@ -115,13 +125,13 @@ jobs: $gates = @( '### Gates (run in this workflow against the bumped tree)', '', - "- Build (x64, warnings as errors): $(Mark '${{ steps.build.outcome }}')", - "- Engine + ViewModels tests: $(Mark '${{ steps.engine-tests.outcome }}')", - "- CLI tests: $(Mark '${{ steps.cli-tests.outcome }}')", - "- Format: $(Mark '${{ steps.format.outcome }}')", + "- Build (x64, warnings as errors): $(Mark $env:BUILD_OUTCOME)", + "- Engine + ViewModels tests: $(Mark $env:ENGINE_TESTS_OUTCOME)", + "- CLI tests: $(Mark $env:CLI_TESTS_OUTCOME)", + "- Format: $(Mark $env:FORMAT_OUTCOME)", '', - 'The UI smoke suite and the 100% coverage gate run when dev is merged to main.', - "Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + 'The UI smoke suite, the 100% coverage gate, and the security scans run on the pull request.', + "Workflow run: $env:RUN_URL", '' ) $report = Get-Content nuget-update-report.md -Raw @@ -133,11 +143,11 @@ jobs: uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.DEPS_PAT || github.token }} - base: dev - branch: deps/nuget-weekly + base: main + branch: build/nuget-weekly delete-branch: true - commit-message: 'Deps: weekly NuGet package updates' - title: 'Deps: weekly NuGet package updates' + commit-message: 'build(deps): weekly NuGet package updates' + title: 'build(deps): weekly NuGet package updates' body-path: pr-body.md labels: | dependencies diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml new file mode 100644 index 0000000..32228bf --- /dev/null +++ b/.github/workflows/pr-gates.yml @@ -0,0 +1,26 @@ +# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off on every +# commit, and review of added dependencies, through the KofTwentyTwo standards' +# reusable workflow. Pinned by full commit SHA like any other action (K22-CI-11); +# the comment names the standards revision. Dependabot's github-actions ecosystem +# updates the SHA and the comment together. +name: pr-gates + +on: + pull_request: + +permissions: {} + +concurrency: + group: pr-gates-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pr: + uses: KofTwentyTwo/standards/.github/workflows/pr.yml@ae174f54959ba4297f2ae174356c4481918fdf44 # main 2026-10-09 (pre-1.0 draft, standards#4) + permissions: + contents: read + pull-requests: read + with: + # These packages declare a license FILE in their nuspec, so the dependency + # graph reports NOASSERTION; see standards EX-0003. + allow-dependencies-licenses: pkg:nuget/LibGit2Sharp, pkg:nuget/Microsoft.WindowsAppSDK, pkg:nuget/Microsoft.Windows.SDK.BuildTools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da1ca0e..0621e06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,10 @@ on: permissions: contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: verify: name: Verify tag @@ -36,6 +40,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Derive version from tag id: version @@ -75,8 +80,9 @@ jobs: exit 1 fi - # The same gates a pull request must pass: zero-warning x64 build, 100% line - # coverage on Engine/ViewModels/Cli, and formatting. If any of those fails, this + # The same gates a pull request must pass (K22-CI-31): zero-warning x64 build, + # 100% line coverage on Engine/ViewModels/Cli, and formatting; the security + # scans and CodeQL run on the tagged commit when it reached main. If any of those fails, this # job fails and release is skipped. The FlaUI UI smoke suite runs too but is # advisory here, exactly as it is not a required check on main: hosted-runner UI # automation is not yet proven stable enough to hold an immutable tag hostage. @@ -101,6 +107,13 @@ jobs: attestations: write env: REPO_URL: https://github.com/KofTwentyTwo/gclo + # The verify job's outputs, as plain environment variables: run: scripts + # read these instead of interpolating ${{ }} expressions (K22-CI-12). + VERSION: ${{ needs.verify.outputs.version }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + CHANNEL: ${{ needs.verify.outputs.channel }} + # SBOM generator (CycloneDX for .NET), pinned like the other release tooling. + CYCLONEDX_VERSION: 6.2.0 # Pinned release tooling. vpk builds the installer and packages users # receive and wingetcreate is a binary fetched at run time; neither may # float. Bump deliberately, together with the Velopack package reference. @@ -113,24 +126,28 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # No restore cache here (K22-CI-15): a release never builds from a cache a + # pull request run could have written. - name: Set up .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 10.0.x - cache: true - cache-dependency-path: '**/packages.lock.json' + global-json-file: global.json # ---------------------------------------------------------------------- # (a) CLI: self-contained single-file publish, zipped as a release asset. # ---------------------------------------------------------------------- - name: Publish CLI (win-x64, single file) - run: dotnet publish gclo.Cli -c Release -r win-x64 --self-contained -p:Version=${{ needs.verify.outputs.version }} -p:PublishSingleFile=true -o publish/cli + run: dotnet publish gclo.Cli -c Release -r win-x64 --self-contained -p:Version=$env:VERSION -p:PublishSingleFile=true -p:RestoreLockedMode=true -o publish/cli + # Released artifacts carry the license (K22-REPO-01). - name: Zip CLI shell: pwsh run: | New-Item -ItemType Directory -Force -Path artifacts | Out-Null + Copy-Item LICENSE publish/cli/LICENSE Compress-Archive -Path publish/cli/* -DestinationPath artifacts/gclo-cli-win-x64.zip # ---------------------------------------------------------------------- @@ -139,7 +156,11 @@ jobs: # and attaches the Setup, portable, and delta assets to it. # ---------------------------------------------------------------------- - name: Publish app for Velopack (unpackaged win-x64) - run: dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained -p:Version=${{ needs.verify.outputs.version }} -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -o publish/app + run: dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained -p:Version=$env:VERSION -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -p:RestoreLockedMode=true -o publish/app + + - name: Add the license to the app payload + shell: pwsh + run: Copy-Item LICENSE publish/app/LICENSE - name: Install Velopack CLI run: dotnet tool install -g vpk --version $env:VPK_VERSION @@ -154,36 +175,71 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | $preFlag = @() - if ('${{ needs.verify.outputs.prerelease }}' -eq 'true') { $preFlag = @('--pre') } - vpk download github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel ${{ needs.verify.outputs.channel }} @preFlag + if ($env:PRERELEASE -eq 'true') { $preFlag = @('--pre') } + vpk download github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel $env:CHANNEL @preFlag - name: Velopack pack - run: vpk pack --packId gclo --packVersion ${{ needs.verify.outputs.version }} --packDir publish/app --mainExe gclo.exe --channel ${{ needs.verify.outputs.channel }} --icon gclo/Assets/Brand/gclo.ico + run: vpk pack --packId gclo --packVersion $env:VERSION --packDir publish/app --mainExe gclo.exe --channel $env:CHANNEL --icon gclo/Assets/Brand/gclo.ico - # SHA256SUMS.txt: one line per asset (' *'), so users and the - # Scoop autoupdate can verify downloads without computing hashes by hand. - - name: Write SHA256SUMS.txt + # ---------------------------------------------------------------------- + # SBOM (K22-REL-06): one CycloneDX document per shipped artifact, from the + # resolved dependency graph of the project that produced it. File names + # carry the version (K22-REL-03). + # ---------------------------------------------------------------------- + - name: Install CycloneDX + run: dotnet tool install -g CycloneDX --version $env:CYCLONEDX_VERSION + + - name: Generate SBOMs shell: pwsh run: | - $files = @(Get-Item artifacts/gclo-cli-win-x64.zip) + @(Get-ChildItem Releases -File | Where-Object { $_.Extension -in '.exe', '.zip', '.nupkg' }) + New-Item -ItemType Directory -Force -Path artifacts/sbom | Out-Null + dotnet CycloneDX gclo.Cli/gclo.Cli.csproj --output artifacts/sbom --filename "gclo-cli-win-x64-$env:VERSION.cdx.json" --output-format Json --spec-version 1.6 --set-name gclo-cli --set-version $env:VERSION --set-type Application --exclude-test-projects --disable-package-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet CycloneDX gclo/gclo.csproj --output artifacts/sbom --filename "gclo-app-$env:VERSION.cdx.json" --output-format Json --spec-version 1.6 --set-name gclo --set-version $env:VERSION --set-type Application --exclude-test-projects --disable-package-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Get-ChildItem artifacts/sbom + + # SHA256SUMS (K22-REL-04): one line per asset (' *'), so users + # and the Scoop autoupdate can verify downloads without computing hashes by + # hand. Fixed-name channel assets (the update feed) are listed too (K22-REL-03). + - name: Write SHA256SUMS + shell: pwsh + run: | + $files = @(Get-Item artifacts/gclo-cli-win-x64.zip) + @(Get-ChildItem artifacts/sbom -File) + @(Get-ChildItem Releases -File | Where-Object { $_.Extension -in '.exe', '.zip', '.nupkg', '.json' }) $lines = $files | ForEach-Object { "{0} *{1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name } - [System.IO.File]::WriteAllText("artifacts/SHA256SUMS.txt", ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false))) - Get-Content artifacts/SHA256SUMS.txt + [System.IO.File]::WriteAllText("artifacts/SHA256SUMS", ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false))) + Get-Content artifacts/SHA256SUMS # Provenance: a signed statement that THIS workflow run built these exact # bytes, verifiable with `gh attestation verify -R KofTwentyTwo/gclo`. - # Not a code signature (see #57); it makes a release attributable to the - # workflow rather than only to whoever holds the account. + # Not a code signature (K22 exception EX-0002, #57); it makes a release + # attributable to the workflow rather than only to whoever holds the account. - name: Attest build provenance uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: subject-path: | artifacts/gclo-cli-win-x64.zip - artifacts/SHA256SUMS.txt + artifacts/SHA256SUMS + artifacts/sbom/*.cdx.json Releases/*.exe Releases/*.zip Releases/*.nupkg + - name: Attest the CLI SBOM + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: artifacts/gclo-cli-win-x64.zip + sbom-path: artifacts/sbom/gclo-cli-win-x64-${{ needs.verify.outputs.version }}.cdx.json + + - name: Attest the app SBOM + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: | + Releases/*.exe + Releases/*.zip + Releases/*.nupkg + sbom-path: artifacts/sbom/gclo-app-${{ needs.verify.outputs.version }}.cdx.json + # This repo has immutable releases: assets freeze the moment a release is # PUBLISHED. So vpk creates a DRAFT (no --publish), every asset and the # changelog land while it is still mutable, and publishing happens last. @@ -193,20 +249,23 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | $preFlag = @() - if ('${{ needs.verify.outputs.prerelease }}' -eq 'true') { $preFlag = @('--pre') } - vpk upload github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel ${{ needs.verify.outputs.channel }} --releaseName "gclo ${{ needs.verify.outputs.version }}" --tag $env:GITHUB_REF_NAME @preFlag + if ($env:PRERELEASE -eq 'true') { $preFlag = @('--pre') } + vpk upload github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel $env:CHANNEL --releaseName "gclo $env:VERSION" --tag $env:GITHUB_REF_NAME @preFlag - - name: Attach CLI zip and checksums to the draft release + - name: Attach the CLI zip, checksums, and SBOMs to the draft release env: GH_TOKEN: ${{ github.token }} - run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip artifacts/SHA256SUMS.txt --clobber + run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip artifacts/SHA256SUMS (Get-ChildItem artifacts/sbom -File).FullName --clobber - # Auto-generated changelog (categorized by labels via .github/release.yml). - - name: Generate release changelog + # Release notes from the Conventional Commit titles merged since the previous + # release, breaking changes and security fixes first (K22-REL-02). + - name: Generate release notes env: GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} run: | - gh api "repos/${{ github.repository }}/releases/generate-notes" -f tag_name=$env:GITHUB_REF_NAME --jq .body | Out-File -FilePath notes.md -Encoding utf8 + ./.github/scripts/New-ReleaseNotes.ps1 -Tag $env:GITHUB_REF_NAME -Repository $env:REPOSITORY -OutputPath notes.md + Get-Content notes.md gh release edit $env:GITHUB_REF_NAME --notes-file notes.md - name: Publish the completed release @@ -222,7 +281,7 @@ jobs: # package is deterministic and SourceLink step-through works; the .snupkg # beside it carries the symbols. - name: Pack gclo.Engine - run: dotnet pack gclo.Engine -c Release -p:Version=${{ needs.verify.outputs.version }} -p:ContinuousIntegrationBuild=true -o artifacts/nuget + run: dotnet pack gclo.Engine -c Release -p:Version=$env:VERSION -p:ContinuousIntegrationBuild=true -p:RestoreLockedMode=true -o artifacts/nuget # The secrets context is not available in a step's `if`, so one step turns # "is it configured?" into outputs; it is the only step besides the two @@ -271,24 +330,22 @@ jobs: } # Velopack names the installer '--Setup.exe'. $setupUrl = "$env:REPO_URL/releases/download/$env:GITHUB_REF_NAME/gclo-stable-Setup.exe" - .\wingetcreate.exe update KofTwentyTwo.gclo --urls $setupUrl --version ${{ needs.verify.outputs.version }} --submit --token $env:WINGET_TOKEN + .\wingetcreate.exe update KofTwentyTwo.gclo --urls $setupUrl --version $env:VERSION --submit --token $env:WINGET_TOKEN # ---------------------------------------------------------------------- # (e) Scoop + Chocolatey (#9), STABLE releases only. The repository is its # own Scoop bucket (bucket/gclo.json) and ships the Chocolatey package # source (packaging/chocolatey). Both are pointed at this release's CLI # zip; the Chocolatey package is pushed when CHOCO_API_KEY is configured, - # and the updated manifests are proposed as a pull request against MAIN: - # Scoop reads bucket/gclo.json from the default branch, so a PR against - # dev would leave `scoop install` on the previous version until the next - # dev->main merge. Merge the PR, then merge main back into dev. (With only - # GITHUB_TOKEN the PR triggers no checks; DEPS_PAT fixes that, see #7.) + # and the updated manifests are proposed as a pull request against main, + # where Scoop reads bucket/gclo.json from. (With only GITHUB_TOKEN the PR + # triggers no checks; DEPS_PAT fixes that, see #7.) # ---------------------------------------------------------------------- - name: Point the Scoop and Chocolatey manifests at this release if: needs.verify.outputs.prerelease == 'false' id: manifests shell: pwsh - run: ./.github/scripts/Update-PackageManifests.ps1 -Version ${{ needs.verify.outputs.version }} -CliZipPath artifacts/gclo-cli-win-x64.zip + run: ./.github/scripts/Update-PackageManifests.ps1 -Version $env:VERSION -CliZipPath artifacts/gclo-cli-win-x64.zip - name: Pack the Chocolatey package if: needs.verify.outputs.prerelease == 'false' @@ -313,8 +370,8 @@ jobs: base: main branch: packaging/v${{ needs.verify.outputs.version }} delete-branch: true - commit-message: 'Packaging: point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}' - title: 'Packaging: Scoop and Chocolatey manifests for v${{ needs.verify.outputs.version }}' + commit-message: 'build(packaging): point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}' + title: 'build(packaging): point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}' body: | Generated by the Release workflow for tag `${{ github.ref_name }}`. @@ -324,7 +381,6 @@ jobs: - `scoop update gclo` picks this up as soon as this PR is merged (Scoop reads the manifest from `main`); the Chocolatey package was ${{ steps.secrets.outputs.choco == 'true' && 'pushed to chocolatey.org' || 'packed but not pushed (CHOCO_API_KEY not configured)' }}. - - After merging, merge `main` back into `dev` so the branches agree. Relates to #9. labels: | diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..ed80dc2 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,54 @@ +# OpenSSF Scorecard (K22-CI-24). Scorecard's publishing API only accepts results +# from a workflow shaped exactly like this one: a single job, read-only top-level +# permissions, no top-level env or defaults, and only the allowed steps. +name: scorecard + +on: + branch_protection_rule: + schedule: + - cron: "17 7 * * 1" + push: + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: false + +jobs: + analysis: + name: analysis + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + actions: read # Scorecard reads workflow run metadata + security-events: write # upload SARIF to code scanning + id-token: write # publish results to the OpenSSF API and badge + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: scorecard.sarif + results_format: sarif + publish_results: true + + - name: Keep the results + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scorecard-sarif + path: scorecard.sarif + retention-days: 30 + + - name: Upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + sarif_file: scorecard.sarif + category: scorecard diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..8277e52 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,27 @@ +# Security scanning (K22-CI-20, -21, -23): gitleaks over the full history, Trivy +# and OSV-Scanner over the lock files, zizmor and actionlint over the workflows, +# through the KofTwentyTwo standards' reusable workflow. Runs on every change and +# weekly, so advisories published against unchanged code still surface. +# Repository-specific configuration: .gitleaks.toml, trivy.yaml, zizmor.yml. +name: security + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "23 6 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + security: + uses: KofTwentyTwo/standards/.github/workflows/security.yml@ae174f54959ba4297f2ae174356c4481918fdf44 # main 2026-10-09 (pre-1.0 draft, standards#4) + permissions: + contents: read + security-events: write diff --git a/.gitignore b/.gitignore index 22e151b..dd6617f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ BenchmarkDotNet.Artifacts/ # .NET project.lock.json +.trivycache/ project.fragment.lock.json artifacts/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..03d88ba --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,18 @@ +# KofTwentyTwo gitleaks configuration (K22-CI-20, K22-REPO-04), from +# KofTwentyTwo/standards configs/security/.gitleaks.toml. +# +# Starts from gitleaks' full default rule set and adds nothing to ignore except +# test fixtures. A real secret is never allowlisted: it is rotated (K22-REPO-04). +# A false positive outside test fixtures is suppressed inline with a +# `gitleaks:allow` comment and a reason, which keeps the decision reviewable. + +title = "KofTwentyTwo gitleaks config" + +[extend] +useDefault = true + +[[allowlists]] +description = "Deliberately fake credentials used as test fixtures" +paths = [ + '''(^|/)(test|tests|__tests__|testdata|fixtures)/.*\.(json|ya?ml|txt|env\.example)$''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a404bad --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,78 @@ +# KofTwentyTwo pre-commit configuration, from KofTwentyTwo/standards +# configs/pre-commit/.pre-commit-config.yaml with the .NET hook enabled. Works +# with pre-commit and prek. Install both hook types once per clone: +# +# pre-commit install --hook-type pre-commit --hook-type commit-msg +# +# Every external hook is pinned to a full commit SHA with the release in a comment +# (`pre-commit autoupdate --freeze` keeps that format). The hooks are the local half +# of the CI gates in the standards' ci-cd.md; CI runs the same tools again. +default_install_hook_types: [pre-commit, commit-msg] +default_stages: [pre-commit] + +repos: + # File hygiene. + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: check-added-large-files + args: [--maxkb=1024] + - id: check-case-conflict + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: check-toml + - id: detect-private-key + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: no-commit-to-branch + args: [--branch, main] + + # Secrets (K22-CI-20): staged changes only, so it stays fast. + - repo: https://github.com/gitleaks/gitleaks + rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1 + hooks: + - id: gitleaks + + # Workflow security (K22-CI-23). + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1 + hooks: + - id: zizmor + args: [--min-severity, medium] + - repo: https://github.com/rhysd/actionlint + rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12 + hooks: + - id: actionlint + + # Commit message: Conventional Commits header (K22-SDLC-11). + - repo: https://github.com/compilerla/conventional-pre-commit + rev: 91ab4bf57e58b32adf1a122681f6ebe164d081c8 # frozen: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [--strict, build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] + + # Commit message: DCO sign-off (`git commit -s`). pygrep with --negate fails when + # no line of the message matches. + - repo: local + hooks: + - id: dco-sign-off + name: DCO sign-off present (git commit -s) + language: pygrep + entry: '^Signed-off-by: .+ <.+@.+>$' + args: [--negate] + stages: [commit-msg] + + # .NET (K22-CODE-CS): the format gate, same command as CI. + - repo: local + hooks: + - id: dotnet-format + name: dotnet format --verify-no-changes + language: system + entry: dotnet format gclo.slnx --verify-no-changes --severity warn --no-restore + pass_filenames: false + files: \.(cs|csproj|props|targets|editorconfig)$ diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..90c4ff9 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,55 @@ +# KofTwentyTwo PowerShell analysis (K22-CODE-SH-05), from KofTwentyTwo/standards +# configs/shell/PSScriptAnalyzerSettings.psd1. CI runs: +# Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit +# and `Invoke-Formatter` with the same settings for layout. PowerShell supports the +# Kingsrook layout fully: next-line (Allman) braces and 3-space indentation. +@{ + Severity = @('Error', 'Warning', 'Information') + IncludeDefaultRules = $true + ExcludeRules = @() + Rules = @{ + PSPlaceOpenBrace = @{ + Enable = $true + OnSameLine = $false + NewLineAfter = $true + IgnoreOneLineBlock = $true + } + PSPlaceCloseBrace = @{ + Enable = $true + NewLineAfter = $true + IgnoreOneLineBlock = $true + NoEmptyLineBefore = $true + } + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 3 + Kind = 'space' + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + } + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + # Kingsrook writes if(...) with no space; the rule cannot require that, so it + # is not checked either way. + CheckOpenParen = $false + CheckOpenBrace = $false + CheckOperator = $true + CheckPipe = $true + CheckSeparator = $true + CheckParameter = $true + } + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $true + } + PSUseCorrectCasing = @{ Enable = $true } + PSAvoidUsingCmdletAliases = @{ Enable = $true } + PSAvoidUsingWriteHost = @{ Enable = $true } + PSProvideCommentHelp = @{ + Enable = $true + ExportedOnly = $false + BlockComment = $true + Placement = 'before' + } + } +} diff --git a/README.md b/README.md index 33a3148..70aa836 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ scoop install gclo choco install gclo ``` -Every release also attaches `SHA256SUMS.txt`, and every asset carries a build-provenance attestation you can check with `gh attestation verify -R KofTwentyTwo/gclo`. `winget` support for the desktop app is tracked in [#7](https://github.com/KofTwentyTwo/gclo/issues/7). +Every release also attaches `SHA256SUMS` and a CycloneDX SBOM per artifact, and every asset carries a build-provenance attestation you can check with `gh attestation verify -R KofTwentyTwo/gclo`. `winget` support for the desktop app is tracked in [#7](https://github.com/KofTwentyTwo/gclo/issues/7). ## Getting a token diff --git a/bucket/gclo.json b/bucket/gclo.json index 13f96b0..2aa16f2 100644 --- a/bucket/gclo.json +++ b/bucket/gclo.json @@ -20,7 +20,7 @@ "64bit": { "url": "https://github.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64.zip", "hash": { - "url": "$baseurl/SHA256SUMS.txt" + "url": "$baseurl/SHA256SUMS" } } } diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4ee18bd..4ac9eac 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -53,7 +53,7 @@ get a release without a delta package, which is normal. | GitHub Release (Setup, portable, full/delta packages, CLI zip) | always | none — the built-in `GITHUB_TOKEN` is enough | | nuget.org (`gclo.Engine` package) | `NUGET_API_KEY` secret is set | `NUGET_API_KEY` — an API key from nuget.org with push rights for `gclo.Engine` | | winget (`KofTwentyTwo.gclo`) | stable releases only, and `WINGET_TOKEN` secret is set | `WINGET_TOKEN` — a GitHub personal access token (classic, `public_repo` scope) used by `wingetcreate` to fork/PR [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) | -| `SHA256SUMS.txt` on the GitHub Release | always | none | +| `SHA256SUMS` and CycloneDX SBOMs on the GitHub Release | always | none | | Scoop (`bucket/gclo.json` in this repository) | stable releases only | none — the workflow opens a PR against `main` with the updated manifest; merge it (then `main` back into `dev`) and `scoop update gclo` sees the release | | Chocolatey (`gclo` on chocolatey.org) | stable releases only, and `CHOCO_API_KEY` secret is set | `CHOCO_API_KEY` — an API key from your chocolatey.org account; the first push creates the package (it then goes through Chocolatey moderation) | @@ -88,7 +88,7 @@ Both install the same asset, `gclo-cli-win-x64.zip`, by URL and SHA-256. - **Scoop** needs no account: this repository is its own bucket. Users run `scoop bucket add gclo https://github.com/KofTwentyTwo/gclo` once, then `scoop install gclo`. The manifest (`bucket/gclo.json`) carries `checkver` and - `autoupdate` entries keyed on `SHA256SUMS.txt`, so `scoop update` works as + `autoupdate` entries keyed on `SHA256SUMS`, so `scoop update` works as soon as the manifest on `main` names the new version. The release workflow therefore opens its "Packaging" pull request **against `main`** (the one exception to the dev-first branch model: three generated files, nothing else). diff --git a/gclo.Cli.Tests/PackagingManifestTests.cs b/gclo.Cli.Tests/PackagingManifestTests.cs index cda946d..ba52a1b 100644 --- a/gclo.Cli.Tests/PackagingManifestTests.cs +++ b/gclo.Cli.Tests/PackagingManifestTests.cs @@ -37,7 +37,7 @@ public sealed class PackagingManifestTests Assert.Equal("github", root.GetProperty("checkver").GetString()); string autoupdateUrl = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("url").GetString()!; Assert.Contains("v$version/gclo-cli-win-x64.zip", autoupdateUrl); - Assert.Contains("SHA256SUMS.txt", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString()); + Assert.EndsWith("/SHA256SUMS", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString()); return (root.GetProperty("version").GetString()!, x64.GetProperty("url").GetString()!, x64.GetProperty("hash").GetString()!); } diff --git a/gclo.Cli/gclo.Cli.csproj b/gclo.Cli/gclo.Cli.csproj index 221347d..4c029e4 100644 --- a/gclo.Cli/gclo.Cli.csproj +++ b/gclo.Cli/gclo.Cli.csproj @@ -3,6 +3,9 @@ Exe net10.0 + + win-x64 enable enable gclo diff --git a/gclo.Cli/packages.lock.json b/gclo.Cli/packages.lock.json index 4ae4404..314288b 100644 --- a/gclo.Cli/packages.lock.json +++ b/gclo.Cli/packages.lock.json @@ -39,6 +39,13 @@ "gclo.Engine": "[1.0.1-dev, )" } } + }, + "net10.0/win-x64": { + "LibGit2Sharp.NativeBinaries": { + "type": "Transitive", + "resolved": "2.0.324", + "contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA==" + } } } } \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..163a6e1 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestFeature" + } +} diff --git a/trivy.yaml b/trivy.yaml new file mode 100644 index 0000000..233e6ea --- /dev/null +++ b/trivy.yaml @@ -0,0 +1,22 @@ +# KofTwentyTwo Trivy configuration for software composition analysis (K22-CI-21), +# from KofTwentyTwo/standards configs/security/trivy.yaml. `trivy fs` loads it from +# the working directory; the reusable security workflow passes severity and output +# flags itself, so this file only holds what is repository-specific. + +scan: + scanners: + - vuln + skip-dirs: + - .trivycache + - node_modules + - "**/bin" + - "**/obj" + +vulnerability: + # Report unfixed vulnerabilities too: an unfixed HIGH still needs a decision + # (replace the dependency, or record a VEX statement and an exception). + ignore-unfixed: false + +# Accepted findings live in .trivyignore (or .trivyignore.yaml), one entry per +# finding, each with the exception ID, the reason, and an expiry date, e.g.: +# CVE-2026-12345 exp:2026-12-31 # EX-0003: not reachable, see VEX statement diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 0000000..377c709 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,11 @@ +# KofTwentyTwo zizmor configuration (K22-CI-23), from KofTwentyTwo/standards +# configs/security/zizmor.yml. +# +# Every audit is enabled. Ignore a finding only for a specific workflow and line, +# with a comment citing the reason or exception ID, for example: +# +# rules: +# template-injection: +# ignore: +# - release.yml:42 # EX-0007: input is a validated semver tag +rules: {}