From 9beae4ff082a14bba37f9dedbe95d8dd6ee0865c Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Fri, 1 May 2026 17:42:37 -0700 Subject: [PATCH 1/5] ci(nexus): add --repo flag to gh release download prepare-artifacts has no checkout step, so gh has no .git directory to infer the repository from. Without --repo, gh falls back to git which fails with 'fatal: not a git repository'. Pass GITHUB_REPOSITORY explicitly so the download works on a bare runner. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/upload-nexus.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/upload-nexus.yaml b/.github/workflows/upload-nexus.yaml index 06fd4252e3..930ed30ccf 100644 --- a/.github/workflows/upload-nexus.yaml +++ b/.github/workflows/upload-nexus.yaml @@ -211,7 +211,7 @@ jobs: - name: Download GitHub release assets run: | mkdir -p release-assets - gh release download "${{ needs.prepare-nexus-matrix.outputs.tag }}" --pattern "*.7z" --dir release-assets + gh release download "${{ needs.prepare-nexus-matrix.outputs.tag }}" --repo "$GITHUB_REPOSITORY" --pattern "*.7z" --dir release-assets ls -lah release-assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e1a703eea7cdedccd8e8549518c13c232fabda80 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Fri, 1 May 2026 19:05:22 -0700 Subject: [PATCH 2/5] ci(nexus): fix artifact patterns and filter upload matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive artifact_pattern from mod_filename using cmake's space→dot convention (e.g. "Cloud Shadows" → Cloud.Shadows-*.7z) so patterns match actual release assets. Previously used the CamelCase folder name which never matched. Also filter upload_matrix to only include rows whose artifact actually exists in the GitHub release, preventing hard failures in upload-to-nexus when a feature's standalone package was not included in the release. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/upload-nexus.yaml | 23 +++++++++++++++++++++-- tools/feature_version_audit.py | 10 ++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/workflows/upload-nexus.yaml b/.github/workflows/upload-nexus.yaml index 930ed30ccf..33977b7e99 100644 --- a/.github/workflows/upload-nexus.yaml +++ b/.github/workflows/upload-nexus.yaml @@ -170,11 +170,30 @@ jobs: " python -c " - import json + import json, os, fnmatch with open('nexus-matrix-raw.json') as f: data = json.load(f) + # Load asset names from the release so we can skip uploads where + # the standalone artifact was not actually included in the release. + try: + with open('releases.json') as f: + releases = json.load(f) + if not isinstance(releases, list): + releases = [] + except Exception: + releases = [] + tag = os.environ.get('RELEASE_TAG', '') + release = next((r for r in releases if r.get('tag_name') == tag), None) + asset_names = [a['name'] for a in (release.get('assets', []) if release else [])] + def artifact_in_release(row): + if row.get('name') == 'core': + return True + pat = row.get('artifact_pattern', '') + return bool(pat) and any(fnmatch.fnmatch(n, pat) for n in asset_names) # Full matrix for artifact prep; upload matrix excludes auto_upload=false - upload_data = [row for row in data if row.get('auto_upload', True)] + # and features whose standalone artifact is absent from the release. + upload_data = [row for row in data + if row.get('auto_upload', True) and artifact_in_release(row)] has_uploads = bool(upload_data) with open('nexus-matrix.json', 'w') as f: json.dump({'include': data}, f) diff --git a/tools/feature_version_audit.py b/tools/feature_version_audit.py index 2e25aa6ce4..a22b28b079 100644 --- a/tools/feature_version_audit.py +++ b/tools/feature_version_audit.py @@ -934,9 +934,8 @@ def sanitize_name(name): if not mod_id: continue name = info['name'] - artifact_pattern = info.get('artifact_pattern') or f'{name}-*.7z' - # Read INI metadata (version, filename, etc.) + # Read INI metadata first — mod_filename is needed to derive artifact_pattern. ini_path = get_feature_ini(name) # Pass name as string for fuzzy matching ini_metadata = {} mod_version = None @@ -949,6 +948,13 @@ def sanitize_name(name): # Use mod_filename from INI if available, else from feature metadata, else use name mod_filename = ini_metadata.get('mod_filename') or info.get('mod_filename') or name + # artifact_pattern: explicit INI value takes precedence; fallback derives the + # pattern from the display name using the cmake convention of replacing spaces + # with dots — e.g. "Cloud Shadows" → "Cloud.Shadows-*.7z". + artifact_pattern = (ini_metadata.get('artifact_pattern') + or info.get('artifact_pattern') + or f"{mod_filename.replace(' ', '.')}-*.7z") + # Auto-upload is opt-in; missing metadata should not enable uploads. auto_upload = ini_metadata.get('auto_upload', False) From 93fb5a1ead9fe3d0715b824e3393729150f260f5 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Fri, 1 May 2026 20:26:16 -0700 Subject: [PATCH 3/5] ci(nexus): add dry-run version check, check_existing, and better summary Add check-nexus-versions job that runs during dry runs: queries the Nexus API for each planned upload and reports whether the version already exists on Nexus, validating UNEX_APIKEY in the process. Add check_existing: true to upload-to-nexus so re-runs after partial failures automatically skip already-uploaded versions via the Nexus files API. Replace the upload-summary failure branch with a per-feature table listing each mod's Nexus link, planned version, and workflow artifact name for manual recovery. Success branch also lists per-feature links instead of hardcoding the core mod only. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/upload-nexus.yaml | 153 +++++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 15 deletions(-) diff --git a/.github/workflows/upload-nexus.yaml b/.github/workflows/upload-nexus.yaml index 33977b7e99..a104f96ab6 100644 --- a/.github/workflows/upload-nexus.yaml +++ b/.github/workflows/upload-nexus.yaml @@ -281,6 +281,92 @@ jobs: fi fi + check-nexus-versions: + name: Dry Run — Check Nexus Versions + needs: prepare-nexus-matrix + if: > + needs.prepare-nexus-matrix.outputs.dry_run == 'true' && + needs.prepare-nexus-matrix.outputs.has_uploads == 'true' + runs-on: ubuntu-latest + steps: + - name: Query Nexus for existing versions + run: | + python3 << 'PYEOF' + import json, os, urllib.request, urllib.error + + rows = json.loads(os.environ['UPLOAD_MATRIX']).get('include', []) + rows = [r for r in rows if not r.get('skip')] + version = os.environ.get('VERSION', '') + game_id = os.environ.get('NEXUS_GAME_ID', 'skyrimspecialedition') + api_key = os.environ.get('UNEX_APIKEY', '') + summary = open(os.environ['GITHUB_STEP_SUMMARY'], 'a') + + def w(line=''): + print(line) + summary.write(line + '\n') + + w('## Dry Run — Nexus Version Check') + w() + if not api_key: + w('> **Warning:** UNEX_APIKEY is not set — cannot query Nexus.') + w() + w('| Feature | Planned | Status | Versions on Nexus |') + w('|---------|---------|--------|-------------------|') + + api_ok = False + for row in rows: + name = row.get('name', '') + mod_id = str(row.get('nexus_mod_id', '')) + planned = row.get('mod_version') or version + label = row.get('mod_filename', name) + url = f'https://www.nexusmods.com/{game_id}/mods/{mod_id}' + link = f'[{label}]({url})' + + if not api_key or not mod_id: + w(f'| {link} | `{planned}` | ⚠️ skipped | — |') + continue + + api_url = f'https://api.nexusmods.com/v1/games/{game_id}/mods/{mod_id}/files.json' + req = urllib.request.Request(api_url, headers={ + 'apikey': api_key, + 'User-Agent': 'community-shaders-ci/1.0', + 'Accept': 'application/json', + }) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.load(resp) + api_ok = True + files = data.get('files', []) + main = [f for f in files if f.get('category_name') == 'MAIN'] + check = main if main else files + seen = [] + for f in check: + v = f.get('version', '') + if v and v not in seen: + seen.append(v) + versions_str = ', '.join(f'`{v}`' for v in seen[-5:]) or '—' + status = '✅ exists' if planned in seen else '🆕 new' + w(f'| {link} | `{planned}` | {status} | {versions_str} |') + except urllib.error.HTTPError as e: + err = {401: '❌ auth failed', 403: '❌ forbidden'}.get(e.code, f'❌ HTTP {e.code}') + w(f'| {link} | `{planned}` | {err} | — |') + except Exception: + w(f'| {link} | `{planned}` | ❌ error | — |') + + w() + if api_key: + if api_ok: + w('> ✓ UNEX_APIKEY validated.') + else: + w('> ⚠️ UNEX_APIKEY may be invalid — all API calls failed.') + summary.close() + PYEOF + env: + UPLOAD_MATRIX: ${{ needs.prepare-nexus-matrix.outputs.upload_matrix }} + VERSION: ${{ needs.prepare-nexus-matrix.outputs.version }} + NEXUS_GAME_ID: ${{ inputs.nexus_game_id || 'skyrimspecialedition' }} + UNEX_APIKEY: ${{ secrets.UNEX_APIKEY }} + upload-to-nexus: # Reusable workflow call: runs-on is intentionally omitted (runner is defined by the # called workflow). IDE schema validators may flag this as an error — it is valid syntax. @@ -302,6 +388,7 @@ jobs: mod_version: ${{ matrix.mod_version || needs.prepare-nexus-matrix.outputs.version }} mod_filename: ${{ matrix.mod_filename }} changelog: ${{ matrix.changelog || inputs.changelog || '' }} + check_existing: true secrets: UNEX_NEXUSMODS_SESSION_COOKIE: ${{ secrets.UNEX_NEXUSMODS_SESSION_COOKIE }} UNEX_APIKEY: ${{ secrets.UNEX_APIKEY }} @@ -309,7 +396,7 @@ jobs: upload-summary: name: Upload Summary runs-on: ubuntu-latest - needs: [prepare-nexus-matrix, prepare-artifacts, upload-to-nexus] + needs: [prepare-nexus-matrix, prepare-artifacts, upload-to-nexus, check-nexus-versions] if: always() && needs.prepare-nexus-matrix.result == 'success' steps: - name: Check upload status @@ -333,17 +420,53 @@ jobs: - name: Report upload result if: needs.prepare-nexus-matrix.outputs.dry_run != 'true' && needs.prepare-nexus-matrix.outputs.has_uploads == 'true' run: | - RESULT="${{ needs.upload-to-nexus.result }}" - if [ "$RESULT" = "success" ]; then - echo "✓ All Nexus uploads completed successfully" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- [Community Shaders](https://www.nexusmods.com/skyrimspecialedition/mods/86492)" >> $GITHUB_STEP_SUMMARY - else - echo "⚠️ Some uploads failed or were skipped (result: $RESULT)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Recovery steps:**" >> $GITHUB_STEP_SUMMARY - echo "1. Check the upload-to-nexus job logs for details" >> $GITHUB_STEP_SUMMARY - echo "2. Verify credentials: UNEX_NEXUSMODS_SESSION_COOKIE, UNEX_APIKEY" >> $GITHUB_STEP_SUMMARY - echo "3. Download failed artifacts from this workflow run and upload manually" >> $GITHUB_STEP_SUMMARY - echo "4. Or re-run this workflow with the same tag to retry" >> $GITHUB_STEP_SUMMARY - fi + python3 << 'PYEOF' + import json, os + + result = os.environ.get('UPLOAD_RESULT', '') + rows = json.loads(os.environ['UPLOAD_MATRIX']).get('include', []) + rows = [r for r in rows if not r.get('skip')] + version = os.environ.get('VERSION', '') + game_id = os.environ.get('NEXUS_GAME_ID', 'skyrimspecialedition') + summary = open(os.environ['GITHUB_STEP_SUMMARY'], 'a') + + def w(line=''): + summary.write(line + '\n') + + if result == 'success': + w('✅ All Nexus uploads completed successfully') + w() + w('| Feature | Version | Nexus |') + w('|---------|---------|-------|') + for row in rows: + mod_id = str(row.get('nexus_mod_id', '')) + planned = row.get('mod_version') or version + label = row.get('mod_filename', row.get('name', '')) + url = f'https://www.nexusmods.com/{game_id}/mods/{mod_id}' + w(f'| [{label}]({url}) | `{planned}` | [View]({url}) |') + else: + w(f'⚠️ Some uploads may have failed (result: `{result}`)') + w() + w('The following uploads were planned. Re-run this workflow to retry,') + w('or download the artifact and upload manually to Nexus.') + w() + w('| Feature | Version | Nexus | Artifact |') + w('|---------|---------|-------|----------|') + for row in rows: + name = row.get('name', '') + mod_id = str(row.get('nexus_mod_id', '')) + planned = row.get('mod_version') or version + label = row.get('mod_filename', name) + url = f'https://www.nexusmods.com/{game_id}/mods/{mod_id}' + artifact = f'nexus-upload-{name}' + w(f'| [{label}]({url}) | `{planned}` | [View]({url}) | `{artifact}` |') + w() + w('Re-run is safe: already-uploaded versions will be automatically skipped.') + + summary.close() + PYEOF + env: + UPLOAD_RESULT: ${{ needs.upload-to-nexus.result }} + UPLOAD_MATRIX: ${{ needs.prepare-nexus-matrix.outputs.upload_matrix }} + VERSION: ${{ needs.prepare-nexus-matrix.outputs.version }} + NEXUS_GAME_ID: ${{ inputs.nexus_game_id || 'skyrimspecialedition' }} From 3dd8ff2d9927368ba88a3916509e1347fab993bb Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Fri, 1 May 2026 20:54:37 -0700 Subject: [PATCH 4/5] ci(nexus): fetch release by tag and apply asset check to all rows Use /releases/tags/:tag endpoint instead of /releases list to avoid pagination issues when there are 30+ releases. Remove hardcoded True for core in artifact_in_release so all rows including core are validated against actual release assets. Addresses CodeRabbit review comment. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/upload-nexus.yaml | 41 ++++++++++++----------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/.github/workflows/upload-nexus.yaml b/.github/workflows/upload-nexus.yaml index a104f96ab6..777184aba3 100644 --- a/.github/workflows/upload-nexus.yaml +++ b/.github/workflows/upload-nexus.yaml @@ -145,20 +145,17 @@ jobs: fi python tools/feature_version_audit.py "${ARGS[@]}" - # Inject GitHub release body as the core changelog. - # Fetch releases list as raw JSON and let Python search it — avoids - # jq version quirks where array[0] on an empty array produces no output - # instead of null, leaving the file empty and breaking json.load. - gh api "repos/$GITHUB_REPOSITORY/releases" \ - 2>/dev/null > releases.json || echo '[]' > releases.json + # Fetch the specific release by tag — avoids pagination issues with + # the releases list endpoint (default page size is 30). + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \ + 2>/dev/null > release.json || echo '{}' > release.json python -c " import json, os - with open('releases.json') as f: - releases = json.load(f) - if not isinstance(releases, list): - releases = [] - tag = os.environ.get('RELEASE_TAG', '') - body = next((r.get('body') or '' for r in releases if r.get('tag_name') == tag), '') + with open('release.json') as f: + release = json.load(f) + if not isinstance(release, dict): + release = {} + body = release.get('body') or '' with open('nexus-matrix-raw.json') as f: data = json.load(f) for row in data: @@ -173,21 +170,17 @@ jobs: import json, os, fnmatch with open('nexus-matrix-raw.json') as f: data = json.load(f) - # Load asset names from the release so we can skip uploads where - # the standalone artifact was not actually included in the release. + # Load asset names from the release to skip uploads where the + # artifact was not included — applies to all rows including core. try: - with open('releases.json') as f: - releases = json.load(f) - if not isinstance(releases, list): - releases = [] + with open('release.json') as f: + release = json.load(f) + if not isinstance(release, dict): + release = {} except Exception: - releases = [] - tag = os.environ.get('RELEASE_TAG', '') - release = next((r for r in releases if r.get('tag_name') == tag), None) - asset_names = [a['name'] for a in (release.get('assets', []) if release else [])] + release = {} + asset_names = [a['name'] for a in release.get('assets', [])] def artifact_in_release(row): - if row.get('name') == 'core': - return True pat = row.get('artifact_pattern', '') return bool(pat) and any(fnmatch.fnmatch(n, pat) for n in asset_names) # Full matrix for artifact prep; upload matrix excludes auto_upload=false From 068b9bd45ef55c1f4bd381ff8e86dc5692efaa15 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Fri, 1 May 2026 21:17:48 -0700 Subject: [PATCH 5/5] ci: address retroactive CodeRabbit comments from prior PRs build.yaml: run-vs2022 should be false only for tag builds, not all non-dispatch events. Change expression to github.ref_type != 'tag'. upload-nexus.yaml: manual changelog input should override auto-generated feature changelog. Swap precedence to inputs.changelog || matrix.changelog. feature_version_audit.py: feature_dir was constructed as FEATURES_DIR/name (CamelCase) which does not exist for multi-word features like Screen Space GI. Use find_feature_dir() fuzzy resolver with fallback. feature_version_audit.py: --pretty=%s only returns commit subjects, missing BREAKING CHANGE footers in commit bodies. Switch to %s\x1f%b\x1f separator format (ASCII unit-separator; null bytes are rejected by Windows subprocess) so RE_COMMIT_BREAKING can match footers while subjects remain the display text. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yaml | 2 +- .github/workflows/upload-nexus.yaml | 2 +- tools/feature_version_audit.py | 23 ++++++++++++++--------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ae5cc11ec9..205d8fcdc4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -40,7 +40,7 @@ jobs: run-shader-validation: ${{ github.event_name != 'workflow_dispatch' || inputs.validate-shaders == true }} run-shader-tests: ${{ github.event_name != 'workflow_dispatch' || inputs.validate-shaders == true }} cache-key-suffix: ${{ inputs.cache-key-suffix || '' }} - run-vs2022: ${{ github.event_name == 'workflow_dispatch' && github.ref_type != 'tag' }} + run-vs2022: ${{ github.ref_type != 'tag' }} feature-audit: name: Feature Version Audit (Release) diff --git a/.github/workflows/upload-nexus.yaml b/.github/workflows/upload-nexus.yaml index 777184aba3..4c34e5a163 100644 --- a/.github/workflows/upload-nexus.yaml +++ b/.github/workflows/upload-nexus.yaml @@ -380,7 +380,7 @@ jobs: tag_name: ${{ needs.prepare-nexus-matrix.outputs.tag }} mod_version: ${{ matrix.mod_version || needs.prepare-nexus-matrix.outputs.version }} mod_filename: ${{ matrix.mod_filename }} - changelog: ${{ matrix.changelog || inputs.changelog || '' }} + changelog: ${{ inputs.changelog || matrix.changelog || '' }} check_existing: true secrets: UNEX_NEXUSMODS_SESSION_COOKIE: ${{ secrets.UNEX_NEXUSMODS_SESSION_COOKIE }} diff --git a/tools/feature_version_audit.py b/tools/feature_version_audit.py index a22b28b079..b62c4fb9d2 100644 --- a/tools/feature_version_audit.py +++ b/tools/feature_version_audit.py @@ -336,23 +336,28 @@ def get_feature_changelog(feature_dir, feature_info, base_ref): if src_dir.exists() and src_dir.is_dir(): paths.append(str(src_dir).replace("\\", "/")) try: + # Use ASCII unit-separator (0x1f) between subject and body so BREAKING + # CHANGE footers are detected even when the subject lacks the '!' marker. + # Null bytes are not used because Windows subprocess rejects them. raw = subprocess.check_output( - ["git", "log", f"{base_ref}..{HEAD_REF}", "--pretty=%s", "--"] + paths, + ["git", "log", f"{base_ref}..{HEAD_REF}", "--pretty=format:%s\x1f%b\x1f", "--"] + paths, cwd=str(PROJECT_ROOT), stderr=subprocess.DEVNULL, ).decode("utf-8", errors="replace") except Exception: return "" + parts = raw.split("\x1f") seen = set() lines = [] - for msg in raw.splitlines(): - msg = msg.strip() - if not msg or msg in seen: + for subject, body in zip(parts[::2], parts[1::2]): + subject = subject.strip() + if not subject or subject in seen: continue - seen.add(msg) - if (RE_COMMIT_FEAT.match(msg) or RE_COMMIT_FIX.match(msg) or - RE_COMMIT_PERF.match(msg) or RE_COMMIT_BREAKING.search(msg)): - lines.append(f"- {msg}") + seen.add(subject) + full = subject + "\n" + body + if (RE_COMMIT_FEAT.match(subject) or RE_COMMIT_FIX.match(subject) or + RE_COMMIT_PERF.match(subject) or RE_COMMIT_BREAKING.search(full)): + lines.append(f"- {subject}") return "\n".join(lines) def apply_version_bump(ini_path, proposed_ver_str): @@ -969,7 +974,7 @@ def sanitize_name(name): if mod_version: row['mod_version'] = mod_version if base_ref: - feature_dir = FEATURES_DIR / name + feature_dir = find_feature_dir(name) or FEATURES_DIR / name changelog = get_feature_changelog(feature_dir, info, base_ref) if changelog: row['changelog'] = changelog