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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .github/workflows/app-gallery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
name: App Gallery

# Regenerates docs/gallery after CI finishes on main, and commits the images back to main when they
# differ. Nothing is committed when the output is byte-identical, which it is run to run: the
# gallery is a set of headless UI tests (tests/ProjectDirector.UITests/Gallery) rendering on the
# CPU rasterizer over three git repositories seeded offline with pinned names, dates and contents,
# with the settings file and the secret store replaced by in-memory ones and the log panel, whose
# lines carry wall-clock times, cropped out. The only thing that moves a picture is a change to the
# code that draws it.
#
# ci.yml calls this as a job that needs its pipeline, only for a push to main; it can also be run by
# hand on main. It is deliberately not a workflow_run workflow. workflow_run runs with the
# repository's own token and secrets whatever started the run it follows, including a pull request
# from a fork, so building checked-out code there is the pattern githubactions:S7631 exists to catch.
# Here the only things that can start a run are a push to main, which needs write access, and a
# dispatch by someone with write access, so nothing a fork wrote is ever checked out or built with
# this job's permissions.
#
# Why after CI rather than beside it: CI's release job commits VERSION.md and friends back to main
# with a plain `git push` from the commit it was started on. A gallery commit landing on main first
# makes that push non-fast-forward, and the release fails. Needing the pipeline means main already
# carries the release commit, and CI's own cancel-in-progress group means no other main run is part
# way to a release of its own when this one pushes. A failed pipeline skips this job.
#
# There is no paths filter, because the triggering commit is not the only one this run covers: CI
# cancels a superseded run, so a change to a panel can reach main under the CI run of the push after
# it. Regenerating after every successful main run costs a couple of minutes and commits nothing
# when nothing changed.
#
# Why the commit back cannot loop or cut a release:
# - It is pushed with the workflow's own GITHUB_TOKEN, and GitHub starts no workflow run for a
# push made with that token. Neither CI nor, through it, this workflow sees it.
# - It carries `[bot][skip ci]`, the prefix KtsuBuild's own metadata commits use. Should the
# token ever change to one that does trigger runs, GitHub still skips push workflows for it,
# and KtsuBuild leaves it out of the version calculation and the changelog either way.
#
# Why it can push to a protected main: the main ruleset lists github-actions[bot] (the identity
# behind GITHUB_TOKEN) as an always-bypass actor, which is also how KtsuBuild pushes its metadata
# commits. No extra token or ruleset change is needed.
#
# This workflow never runs for a pull request, so it does not belong in dependabot-merge.yml's list
# of pull-request workflows.

on:
workflow_call:
workflow_dispatch:

permissions:
contents: read

env:
DOTNET_VERSION: "10.0"

jobs:
regenerate:
name: Regenerate app gallery
runs-on: ubuntu-latest
timeout-minutes: 20
# Only on main, and never on a fork, where there is nothing of ours to commit to. ci.yml only
# calls this for a push to main, so this is what keeps a manual run off other branches.
if: github.repository == 'ktsu-dev/ProjectDirector' && github.ref == 'refs/heads/main'
# A newer run supersedes an older one: both regenerate from the current main, so an older run
# that is still going has nothing left worth committing. Set on the job rather than the
# workflow, because a called workflow's github.workflow is the caller's name.
concurrency:
group: app-gallery
cancel-in-progress: true
permissions:
contents: write # To push the regenerated images to main

steps:
# LFS content is not fetched: nothing the gallery builds from is in LFS, and every image it
# writes replaces the pointer that was there. The LFS filters are installed below instead,
# which is what makes git compare and commit the images as LFS objects.
- name: Checkout Repository
uses: actions/checkout@v7
with:
# The current main, which by now carries CI's release commit, not the commit CI ran on.
# Never the triggering event's ref or SHA.
ref: main
fetch-depth: 1
lfs: false
persist-credentials: true

- name: Setup .NET SDK ${{ env.DOTNET_VERSION }}
uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ env.DOTNET_VERSION }}.x
cache: true
cache-dependency-path: |
**/*.csproj
**/Directory.Packages.props
**/global.json

- name: Install Git LFS filters
run: git lfs install --local

- name: Build the UI tests
run: dotnet build tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj -c Release

# The old images and index are removed first so that a picture whose entry was renamed or
# removed goes with it, rather than lingering beside its replacement. A stage that fails is a
# failed test, which fails the job before anything is committed.
- name: Regenerate the gallery
env:
PROJECTDIRECTOR_GALLERY_OUT: docs/gallery
run: |
rm -f docs/gallery/*.png docs/gallery/README.md
tests/ProjectDirector.UITests/bin/Release/net10.0/ktsu.ProjectDirector.UITests \
--filter "FullyQualifiedName~ktsu.ProjectDirector.UITests.Gallery"

# Only docs/gallery is staged. The build also rewrites some tracked files (ktsu.Sdk syncs
# .gitignore and friends from its own copy), and none of that belongs in this commit.
- name: Commit and push the images
env:
# A rebase below checks files out, and nothing here reads an image's content, so there
# is no reason to download LFS objects for it. Pointers stay pointers.
GIT_LFS_SKIP_SMUDGE: "1"
run: |
source_sha=$(git rev-parse HEAD)
git add --all docs/gallery

if git diff --cached --quiet; then
echo "The app gallery is up to date."
exit 0
fi

git diff --cached --stat

# A PNG committed as a blob rather than as an LFS pointer would bloat the history for
# good, so refuse to push one.
for path in $(git diff --cached --name-only --diff-filter=AM -- '*.png'); do
header=$(git cat-file blob ":$path" | head -c 42 | tr -d '\0' || true)
if [[ "$header" != "version https://git-lfs.github.com/spec/v1" ]]; then
echo "::error file=$path::Staged as a raw blob rather than an LFS pointer."
exit 1
fi
done

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit --quiet --message "[bot][skip ci] Regenerate the app gallery" \
--message "Rendered by .github/workflows/app-gallery.yml from $source_sha."

# Drop what the build rewrote and was left unstaged, which a rebase refuses to run over.
git reset --hard --quiet HEAD

# Main may still have moved on since checkout, a manual dispatch racing a release being
# the likely way. The commits touch different files, so a rebase is clean.
for attempt in 1 2 3 4 5; do
if git push origin HEAD:main; then
exit 0
fi
echo "Push rejected (attempt $attempt); rebasing onto the current main."
sleep $((attempt * 5))
git fetch --depth=50 origin main
git rebase FETCH_HEAD
done

echo "::error::Could not push the regenerated gallery to main."
exit 1
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,12 @@ jobs:
with:
# `inputs` is empty on push, pull_request and schedule; only a dispatch sets it.
version-bump: ${{ inputs.version-bump || 'auto' }}

# Regenerates docs/gallery once the pipeline has passed on a push to main. It has to run after
# the pipeline, not beside it: see the header of app-gallery.yml.
app-gallery:
needs: ci
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: ./.github/workflows/app-gallery.yml
permissions:
contents: write
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ dotnet publish --configuration Release --output ./staging

Tests live in `ProjectDirector.Test` (MSTest, via `MSTest.Sdk` + `ktsu.Sdk`). The app exposes its internals to the test project through `InternalsVisibleTo` in `ProjectDirector/AssemblyInfo.cs`. `GitCliTests` drives `GitCli` against throwaway repositories under the temp directory; the ImGui layer is not unit-tested.

`tests/ProjectDirector.UITests` drives the real application headlessly through `ktsu.ImGui.App.Testing` to render the app gallery (`Gallery/`), and exists for that purpose. It reaches the app through `ProjectDirector.BuildConfig()` and the internal options constructor. It replaces the settings file and the secret store with in-memory ones, and points the app at three repositories it seeds offline under `/tmp/projectdirector-gallery`. That path is fixed because it appears in the pictures. Every picture is cropped above the log panel, because the log prints wall-clock timestamps. `PROJECTDIRECTOR_GALLERY_OUT` names the output directory; `.github/workflows/app-gallery.yml` regenerates `docs/gallery/` after CI passes on `main`. A new view or dialog worth showing gets an entry in `Gallery/GalleryCatalog.cs`.

That last point is why the repository actions are shaped the way they are: the part of each with a rule in it is pulled out into a plain method so it can be driven without a live ImGui context or a display. `ProjectDirector.DecidePull` decides whether pulling interrupts the user first (`PullDecisionTests`), and `GitCli.ListPendingChanges` plus `ProjectDirector.DescribePendingChanges` decide what a commit will sweep up and how that is shown (`CommitTests`). `DiffTake` builds the file a take arrow in the diff view produces and writes it back with the destination's own line ending and BOM (`DiffTakeTests`). Anything genuinely worth testing that is still tangled up with drawing is usually worth extracting the same way.

```powershell
Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
<PackageVersion Include="ktsu.CredentialCache" Version="1.3.62" />
<PackageVersion Include="ktsu.Extensions" Version="1.9.2" />
<PackageVersion Include="ktsu.ImGui.App" Version="3.64.0" />
<PackageVersion Include="ktsu.ImGui.App.Testing" Version="3.64.0" />
<PackageVersion Include="ktsu.ImGui.Popups" Version="3.64.0" />
<PackageVersion Include="ktsu.ImGui.Probes" Version="3.64.0" />
<PackageVersion Include="ktsu.ImGui.Styler" Version="3.64.0" />
<PackageVersion Include="ktsu.ImGui.Widgets" Version="3.64.0" />
<PackageVersion Include="ktsu.RunCommand" Version="1.9.5" />
Expand All @@ -26,5 +28,6 @@
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="OpenAI" Version="2.14.0" />
<PackageVersion Include="Polyfill" Version="11.4.3" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.3.0" />
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions ProjectDirector.sln
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector", "ProjectD
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.Test", "ProjectDirector.Test\ProjectDirector.Test.csproj", "{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.UITests", "tests\ProjectDirector.UITests\ProjectDirector.UITests.csproj", "{3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -21,6 +23,10 @@ Global
{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.Build.0 = Release|Any CPU
{3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
1 change: 1 addition & 0 deletions ProjectDirector/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.Test")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.UITests")]
Loading
Loading