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
39 changes: 39 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,45 @@ Follow conventional commit format for consistency:
- `fix(imgui): resolve orphaned TableNextColumn calls`
- `refactor(constants): centralize UI constants in ThemeManager`

Conventional commits drive semantic-release. `feat:` triggers a minor bump, `fix:` triggers a patch bump, `feat!:` or `BREAKING CHANGE:` triggers a major bump. `chore:`, `docs:`, `style:`, `test:`, `refactor:` produce no release on their own. Pick the type with the version impact in mind — a refactor mislabeled `feat:` will force a minor bump on the next release.

### Release Branch Model

| Branch | Role | Releases produced |
| -------------- | ------------------------------- | ------------------------------------------------------------------------------- |
| `main` | Stable release channel | `vX.Y.Z` |
| `dev` | Integration / RC | `vX.Y.Z-rc.N` prereleases |
| `hotfix/X.Y.x` | Maintenance for **older** lines | `vX.Y.Z` on the `X.Y` channel (also reused as staging for current-line patches) |

**Default branch for PRs is `dev`.** Feature work, fixes, and refactors all land there via normal PRs. `main` is updated only through the release workflows — never PR a feature branch directly into `main`.

**Branch lineage invariant:** `main` is always an ancestor of `dev`, and `dev` is always an ancestor of `main` after a release reconciles. The `Release: Semantic Version` workflow's `ff_target` promotion mode and the auto dev-FF-reconcile keep this invariant — do not break it by force-pushing or merging shared branches manually.

**Patch flow (current line _or_ older line, same staging mechanism):**

1. Land the fix on `dev` via normal PR (if applicable).
2. Dispatch **Actions → Release: Hotfix Candidate** — auto-creates/reuses `hotfix/X.Y.x` from the latest stable tag, cherry-picks eligible `fix:`/`perf:` commits, opens a PR.
3. PR checks build a `vX.Y.Z-prNNNN` prerelease for verification.
4. Merge the candidate PR.
5. Cut the release:
- **Current line** (`main` is on `X.Y`): dispatch **Release: Semantic Version** on `main` with `ff_target = <hotfix/X.Y.x tip SHA>`.
- **Older line** (`main` has shipped a newer minor/major): dispatch **Release: Semantic Version** on `hotfix/X.Y.x` with `ff_target` empty.

**Minor/major release flow:**

1. Cut RCs from `dev`: dispatch **Release: Semantic Version** on `dev`, `ff_target` empty → `vX.Y.Z-rc.N`.
2. When ready, dispatch **Release: Semantic Version** on `main` with `ff_target = <dev SHA>` (typically the latest RC's SHA). The workflow FFs `main`, runs semantic-release to cut stable, then FFs `dev` to absorb the `chore(release):` commit.

**Things agents should not do without explicit user direction:**

- Force-push or rebase `main`, `dev`, or any `hotfix/*` branch.
- Manually create tags matching `v*` (semantic-release owns these).
- Bump `CMakeLists.txt`'s `VERSION` field outside the release workflow.
- PR a feature branch directly into `main`.
- Run `Release: Semantic Version` on `hotfix/X.Y.x` for the current line — it will fail with `cannot be published as it is out of range` because the maintenance contract requires the hotfix line to be strictly older than `main`. Use `ff_target` into `main` instead.

Full details: [Developers wiki — Patch Release Process](https://github.com/community-shaders/skyrim-community-shaders/wiki/Developers#patch-release-process-any-line).

### Code Organization and Refactoring Patterns

- **Extract Large Functions**: Functions over ~200 lines should be broken into focused helper methods (see `FeatureListRenderer::DrawMenuVisitor` refactoring)
Expand Down
29 changes: 21 additions & 8 deletions .github/workflows/pr-checks.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
name: "PR: Checks"

on:
# `paths` is restricted to files that pr-checks can actually validate on
# this PR. Workflow definitions (.github/workflows/**) and composite
# actions (.github/actions/**) resolve from the BASE branch under
# pull_request_target, so they can never be tested by their own PR's
# run — keeping them in `paths` would only fire the unconditional
# feature-audit job for changes that have no validatable impact.
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
paths:
Expand All @@ -22,9 +28,7 @@ on:
- "src/Features/**"
- "package/Shaders/**"
- "tests/shaders/**"
- ".github/workflows/**"
- ".github/configs/**"
- ".github/actions/**"

# Least-privilege defaults; individual jobs override where needed.
permissions:
Expand All @@ -40,9 +44,16 @@ jobs:
name: Check for changes in PRs
runs-on: ubuntu-latest
outputs:
should-build: ${{ steps.changed-files.outputs.build_any_changed == 'true' || steps.changed-files.outputs.cpp_any_changed == 'true' || steps.changed-files.outputs.ci_any_changed == 'true' || steps.changed-files.outcome == 'failure' }}
hlsl-should-build: ${{ steps.changed-files.outputs.hlsl_any_changed == 'true' || steps.changed-files.outputs.cmake_any_changed == 'true' || steps.changed-files.outputs.ci_any_changed == 'true' || steps.changed-files.outcome == 'failure' }}
shader-tests-should-build: ${{ steps.changed-files.outputs.shader_tests_any_changed == 'true' || steps.changed-files.outputs.hlsl_any_changed == 'true' || steps.changed-files.outputs.cmake_any_changed == 'true' || steps.changed-files.outputs.ci_any_changed == 'true' || steps.changed-files.outcome == 'failure' }}
# `build_ci` is CI changes that can actually be validated on a PR.
# Under pull_request_target, workflow definitions and composite
# actions resolve from the BASE branch ref — so changes to
# pr-checks.yaml, _shared-build.yaml, or .github/actions/** are not
# exercised by their own PR's runs (they only take effect on the
# NEXT PR after merge). Only files read from the PR's checkout
# (shader-validation configs) genuinely benefit from a rebuild.
should-build: ${{ steps.changed-files.outputs.build_any_changed == 'true' || steps.changed-files.outputs.cpp_any_changed == 'true' || steps.changed-files.outputs.build_ci_any_changed == 'true' || steps.changed-files.outcome == 'failure' }}
hlsl-should-build: ${{ steps.changed-files.outputs.hlsl_any_changed == 'true' || steps.changed-files.outputs.cmake_any_changed == 'true' || steps.changed-files.outputs.build_ci_any_changed == 'true' || steps.changed-files.outcome == 'failure' }}
shader-tests-should-build: ${{ steps.changed-files.outputs.shader_tests_any_changed == 'true' || steps.changed-files.outputs.hlsl_any_changed == 'true' || steps.changed-files.outputs.cmake_any_changed == 'true' || steps.changed-files.outputs.build_ci_any_changed == 'true' || steps.changed-files.outcome == 'failure' }}
steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -80,10 +91,12 @@ jobs:
- 'CMakeLists.txt'
- 'CMakePresets.json'
- 'cmake/**'
ci:
- '.github/workflows/**'
# Only files read from the PR's checkout during the build
# belong here. Workflow definitions and composite actions
# are resolved from the base branch under pull_request_target,
# so changes to them cannot be validated on the PR itself.
build_ci:
- '.github/configs/**'
- '.github/actions/**'
hlsl:
- '**.hlsl'
- '**.hlsli'
Expand Down
147 changes: 137 additions & 10 deletions .github/workflows/release-semantic.yaml
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
name: "Release: Semantic Version"

# Dispatch on:
# - dev → cuts vX.Y.Z-rc.N (RC channel)
# - main → cuts vX.Y.Z stable (current line)
# - hotfix/X.Y.x → cuts vX.Y.Z+1 on the X.Y maintenance channel
# (only valid when main has shipped a higher minor/major)
#
# Promotion mode (dev → main):
# Dispatch on main with `ff_target` set to a dev SHA. The workflow
# fast-forwards main to that SHA, runs semantic-release to cut stable,
# then fast-forwards dev to absorb the chore(release) commit. One click,
# no PR, no merge commits, no force pushes.

on:
workflow_dispatch:
inputs:
release_type:
description: "Release type"
required: true
type: choice
default: "rc"
options:
- rc
- stable
ff_target:
description: "Promotion mode: dev SHA to fast-forward main to before releasing. Leave empty for a normal release on the dispatched branch."
required: false
default: ""

permissions:
contents: write

concurrency:
# Serialize promotions so two concurrent dispatches on main can't race
# against each other's FF pushes.
group: release-semantic-${{ github.ref_name }}
cancel-in-progress: false

jobs:
release:
runs-on: ubuntu-latest
Expand All @@ -24,12 +38,87 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
# Token used for the working checkout. The actual pushes
# (FF main, semantic-release commit/tag, FF dev) all go
# through RELEASE_PAT below so they can bypass branch
# protection and fire downstream workflows.
token: ${{ secrets.RELEASE_PAT }}

- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Validate ff_target (promotion mode)
id: validate
if: ${{ inputs.ff_target != '' }}
env:
FF_TARGET: ${{ inputs.ff_target }}
run: |
set -euo pipefail
if [[ '${{ github.ref_name }}' != 'main' ]]; then
echo "::error::ff_target is only valid when this workflow is dispatched on 'main' (got '${{ github.ref_name }}')."
exit 1
fi
# Fetch dev + every hotfix branch so the ancestor checks can
# find ff_target regardless of which staging branch it's on.
git fetch origin main dev '+refs/heads/hotfix/*:refs/remotes/origin/hotfix/*'
if ! git rev-parse --verify "${FF_TARGET}^{commit}" >/dev/null 2>&1; then
echo "::error::ff_target SHA ${FF_TARGET} does not exist."
exit 1
fi
if ! git merge-base --is-ancestor origin/main "${FF_TARGET}"; then
echo "::error::main is not an ancestor of ff_target ${FF_TARGET} — fast-forward is not possible."
exit 1
fi
# Identify the source branch for the promotion. dev → reconcile
# dev afterward; hotfix/* → patch-id dedup handles it at the
# next dev→main promotion, no reconcile.
SOURCE=""
if git merge-base --is-ancestor "${FF_TARGET}" origin/dev; then
SOURCE="dev"
else
while read -r REF; do
[[ -z "${REF}" ]] && continue
if git merge-base --is-ancestor "${FF_TARGET}" "${REF}"; then
SOURCE="${REF#origin/}"
break
fi
done < <(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/hotfix/*')
fi
if [[ -z "${SOURCE}" ]]; then
echo "::error::ff_target ${FF_TARGET} is not an ancestor of dev or any origin/hotfix/* branch — refusing to promote an unreviewed SHA."
exit 1
fi
echo "source=${SOURCE}" >> "$GITHUB_OUTPUT"
{
echo "## Promotion plan"
echo "- Source branch: \`${SOURCE}\`"
echo "- Target SHA: \`$(git rev-parse --short=9 "${FF_TARGET}")\`"
PRERELEASE_TAG=$(git tag --points-at "${FF_TARGET}" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-(rc|pr)[.0-9]+$' | head -n1 || true)
echo "- Prerelease tag at target: \`${PRERELEASE_TAG:-<none>}\`"
echo "- Commits being promoted ($(git rev-list --count "origin/main..${FF_TARGET}")):"
echo ""
# 4-space indent renders the commits as a code block under the preceding list item in GitHub-flavored markdown.
git log --oneline "origin/main..${FF_TARGET}" | sed 's/^/ /'
} >> "$GITHUB_STEP_SUMMARY"

- name: Fast-forward main to ff_target
if: ${{ inputs.ff_target != '' }}
env:
TOKEN: ${{ secrets.RELEASE_PAT }}
FF_TARGET: ${{ inputs.ff_target }}
run: |
set -euo pipefail
REMOTE="https://x-access-token:${TOKEN}@github.com/${{ github.repository }}.git"
# FF-only push: --force-with-lease guards against concurrent
# changes; if main moved we abort rather than overwrite.
CURRENT_MAIN=$(git rev-parse origin/main)
git push --force-with-lease="main:${CURRENT_MAIN}" "${REMOTE}" "${FF_TARGET}:refs/heads/main"
# Re-point our local working tree at the new main tip so
# semantic-release runs against the promoted history.
git checkout -B main "${FF_TARGET}"

- name: Apply feature version bumps
run: |
python tools/feature_version_audit.py \
Expand All @@ -48,8 +137,46 @@ jobs:
env:
# RELEASE_PAT required for two reasons:
# 1. Allows pushing the version commit and tag to the protected
# dev branch (GITHUB_TOKEN cannot bypass branch protection).
# target branch (GITHUB_TOKEN cannot bypass branch protection).
# 2. A PAT-sourced tag push triggers downstream workflows
# (release-build.yaml); GITHUB_TOKEN pushes do not.
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
RELEASE_TYPE: ${{ github.event.inputs.release_type }}

- name: Reconcile dev with release commit (promotion mode, dev source only)
if: ${{ inputs.ff_target != '' && steps.semantic.outputs.new_release_published == 'true' && steps.validate.outputs.source == 'dev' }}
env:
TOKEN: ${{ secrets.RELEASE_PAT }}
run: |
set -euo pipefail
git fetch origin main dev
# Verify dev is still an ancestor of main — if someone pushed
# to dev during the release, exit with a warning instead of
# rewriting their work.
if ! git merge-base --is-ancestor origin/dev origin/main; then
echo "::warning::dev moved during promotion; FF-reconcile skipped."
{
echo ""
echo "## Dev reconcile skipped"
echo "Dev was pushed to during the release. To reconcile manually:"
echo ""
echo '```bash'
echo "git fetch && git checkout dev && git merge --ff-only origin/main && git push"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
NEW_MAIN=$(git rev-parse origin/main)
REMOTE="https://x-access-token:${TOKEN}@github.com/${{ github.repository }}.git"
CURRENT_DEV=$(git rev-parse origin/dev)
git push --force-with-lease="dev:${CURRENT_DEV}" "${REMOTE}" "${NEW_MAIN}:refs/heads/dev"
echo "::notice::Reconciled dev to main ($NEW_MAIN)."

- name: Note dev reconcile skipped (hotfix-staging source)
if: ${{ inputs.ff_target != '' && steps.semantic.outputs.new_release_published == 'true' && steps.validate.outputs.source != 'dev' && steps.validate.outputs.source != '' }}
run: |
{
echo ""
echo "## Dev reconcile not applicable"
echo "Promotion source was \`${{ steps.validate.outputs.source }}\` (hotfix staging branch). The cherry-picked commits on main have different SHAs than their originals on dev — semantic-release's patch-id-dedup will collapse the duplicates at the next dev→main promotion, so no FF-reconcile is needed (and would not be possible)."
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::Hotfix-staging promotion — dev reconcile not applicable."
37 changes: 19 additions & 18 deletions .releaserc.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
const isRC = (process.env.RELEASE_TYPE || 'rc') === 'rc';

module.exports = {
// RC: 'main' is the non-prerelease anchor required by semantic-release v25.
// 'dev' produces rc pre-releases.
// Stable: 'dev' is the primary release branch.
// 'hotfix/N.N.x' maintenance branches allow patch releases from a
// tagged stable baseline without carrying unreleased dev work.
// Branch naming: hotfix/1.5.x (the x.y maintenance line, not a specific patch).
branches: isRC
? ['main', { name: 'dev', prerelease: 'rc' }]
: [
'dev',
{
name: 'hotfix/+([0-9])?(.{+([0-9]),x}).x',
range: '${name.split("/")[1]}',
channel: '${name.split("/")[1]}',
},
],
// 'main' is the stable release channel. Patches to the current line land
// here directly (cherry-picked from dev) and produce vX.Y.Z. Minors/majors
// land here via the dev → main promotion flow in release-semantic.yaml.
//
// 'dev' is the integration channel and produces vX.Y.Z-rc.N prereleases.
//
// 'hotfix/X.Y.x' is the maintenance channel for OLDER release lines.
// semantic-release validates it as a maintenance branch, which means it is
// only valid once 'main' has shipped a release on a newer minor/major than
// X.Y. Patches to the current line do NOT use this — use a fix PR to main.
branches: [
'main',
{ name: 'dev', prerelease: 'rc' },
{
name: 'hotfix/+([0-9])?(.{+([0-9]),x}).x',
range: '${name.split("/")[1]}',
channel: '${name.split("/")[1]}',
},
],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
Expand Down
Loading