diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 01c81dc908..4212954429 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 = `. + - **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 = ` (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) diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index 5e038ac8cc..27f3509377 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -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: @@ -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: @@ -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: @@ -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' diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index 73a86ff457..ccc18240e6 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -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 @@ -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:-}\`" + 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 \ @@ -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." diff --git a/.releaserc.js b/.releaserc.js index 3ee92238db..e6fe010f96 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -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',