From df5060f484e06ce006a203257a6fd07f032cca7a Mon Sep 17 00:00:00 2001 From: Skrubby Skrub In A Shrub <87662196+SkrubbySkrubInAShrub@users.noreply.github.com> Date: Sun, 31 May 2026 13:30:25 +0200 Subject: [PATCH 1/3] ci: fix release build and nexus upload pipeline --- .github/workflows/maint-cleanup-releases.yaml | 5 ++++- .github/workflows/nexus-upload.yaml | 18 +++++++++++++----- .github/workflows/release-build.yaml | 18 ++++++++++-------- .github/workflows/release-semantic.yaml | 19 +++++++++++++++++++ .../Shaders/Features/TerrainHelper.ini | 3 ++- tools/feature_version_audit.py | 14 +++++++++++++- 6 files changed, 61 insertions(+), 16 deletions(-) diff --git a/.github/workflows/maint-cleanup-releases.yaml b/.github/workflows/maint-cleanup-releases.yaml index e934cf7085..069b78eced 100644 --- a/.github/workflows/maint-cleanup-releases.yaml +++ b/.github/workflows/maint-cleanup-releases.yaml @@ -57,7 +57,10 @@ jobs: # Get all git tags that look like pre-releases but may not have GitHub releases git fetch --tags - orphaned_tags=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-(pr[0-9]+|rc[0-9]+|alpha[0-9]*|beta[0-9]*|dev[0-9]*)' || true) + # Optional '.' before the number so semantic-release's dotted + # prerelease tags (e.g. v1.6.0-rc.1) are matched, not just + # v1.6.0-rc1. + orphaned_tags=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-(pr|rc|alpha|beta|dev)\.?[0-9]+' || true) # Combine both lists and remove duplicates all_prerelease_tags=$(echo -e "$releases\n$orphaned_tags" | sort | uniq | grep -v '^$' || true) diff --git a/.github/workflows/nexus-upload.yaml b/.github/workflows/nexus-upload.yaml index cfff0dd547..aa27d0da8e 100644 --- a/.github/workflows/nexus-upload.yaml +++ b/.github/workflows/nexus-upload.yaml @@ -497,11 +497,19 @@ jobs: fi echo "file=$FILE" >> $GITHUB_OUTPUT LINK="https://github.com/${REPO}/releases/tag/${RELEASE_TAG}" - if [ -n "$FILE_DESC" ]; then - printf 'description=%s Full changelog: %s\n' "$FILE_DESC" "$LINK" >> $GITHUB_OUTPUT - else - printf 'description=Full changelog: %s\n' "$LINK" >> $GITHUB_OUTPUT - fi + # file_description is multi-line (per-feature compatibility + # bullets), so write it with the heredoc-delimiter form. A plain + # 'description=...' breaks $GITHUB_OUTPUT on the embedded newlines + # ("Error: Invalid format '• HDR 1.0.2'"). + { + echo 'description<> "$GITHUB_OUTPUT" - name: Validate file_group_id if: steps.version-check.outputs.should_upload == 'true' diff --git a/.github/workflows/release-build.yaml b/.github/workflows/release-build.yaml index 72bbcbcf6f..b75b8f81b4 100644 --- a/.github/workflows/release-build.yaml +++ b/.github/workflows/release-build.yaml @@ -35,6 +35,10 @@ concurrency: jobs: build: name: Build + # Build on dispatch / tag push / a non-draft `release: created`, but NOT + # on `release: published`: the artifacts were already built and attached + # when the draft was created, so publishing must not rebuild. + if: github.event.action != 'published' uses: ./.github/workflows/_shared-build.yaml with: ref: ${{ github.ref }} @@ -46,7 +50,7 @@ jobs: feature-audit: name: Feature Version Audit (Release) - if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'release' + if: (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'release') && github.event.action != 'published' runs-on: ubuntu-latest continue-on-error: true steps: @@ -78,6 +82,7 @@ jobs: if: > always() && !cancelled() && needs.build.outputs.cpp-built == 'true' && + github.event.action != 'published' && (github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))) @@ -187,16 +192,13 @@ jobs: allowUpdates: true nexus-dry-run: - # Chained from `release` to avoid the artifact-attachment race a - # parallel `release: published` trigger on a separate workflow had: - # the prior release-nexus-trigger.yaml fired in parallel with this - # build and tried to download artifacts before they existed. + # Runs only on publish, independently of the build jobs (which are + # skipped on publish). Artifacts were already built and attached to the + # release when its draft was created, and nexus-upload downloads them + # from the release assets — so there is nothing to wait on and no rebuild. # Stable tags only (no '-' in the tag name). name: Nexus Upload (dry run) - needs: [release] if: > - always() && !cancelled() && - needs.release.result == 'success' && github.event_name == 'release' && github.event.action == 'published' && !contains(github.event.release.tag_name, '-') diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index 13cf0edc82..9e57272484 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -19,6 +19,9 @@ on: permissions: contents: write + # actions:write lets this job dispatch release-build.yaml after cutting a + # release (the tag push can't trigger it — see the step below). + actions: write concurrency: # Serialize promotions so two concurrent dispatches on main can't race @@ -173,6 +176,22 @@ jobs: # (release-build.yaml); GITHUB_TOKEN pushes do not. GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + - name: Build artifacts for the new release + # The release tag does NOT auto-trigger release-build.yaml: it sits + # on a `chore(release): … [skip ci]` commit (the tag push is skipped) + # and the GitHub release is created as a draft (no `release: created` + # event fires). So dispatch the build explicitly here to populate the + # draft with .7z artifacts. Publishing the draft later re-runs the + # build and fires the Nexus dry-run via `release: published`. + if: steps.semantic.outputs.new_release_published == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.semantic.outputs.new_release_git_tag }} + run: | + gh workflow run release-build.yaml \ + --repo "${{ github.repository }}" \ + --ref "${TAG}" + - 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: diff --git a/features/Terrain Helper/Shaders/Features/TerrainHelper.ini b/features/Terrain Helper/Shaders/Features/TerrainHelper.ini index 9dbee137a6..321113bad1 100644 --- a/features/Terrain Helper/Shaders/Features/TerrainHelper.ini +++ b/features/Terrain Helper/Shaders/Features/TerrainHelper.ini @@ -1,8 +1,9 @@ [Info] Version = 1-0-1 +AuditVersion = false [Nexus] nexusmodid = 143149 nexusfilegroupid = 000000 nexusfilename = Terrain Helper -autoupload = true +autoupload = false diff --git a/tools/feature_version_audit.py b/tools/feature_version_audit.py index e9959a90cb..8e2b6b2924 100644 --- a/tools/feature_version_audit.py +++ b/tools/feature_version_audit.py @@ -186,7 +186,7 @@ def get_feature_ini_metadata(feature_dir_or_ini_path): if not sections: sections = ['Info'] if parser.has_section('Info') else [] - metadata = {'auto_upload': False} + metadata = {'auto_upload': False, 'audit_version': True} for section in sections: if not parser.has_section(section): continue @@ -196,6 +196,12 @@ def get_feature_ini_metadata(feature_dir_or_ini_path): if auto_upload_str is not None: metadata['auto_upload'] = str(auto_upload_str).strip().lower() not in ('false', '0', 'no', 'off', '') + # Activation-only features keep all code in the core mod; their toggle + # .ini opts out of version auditing/bumping with `AuditVersion = false`. + audit_version_str = section_items.get('auditversion') or section_items.get('audit_version') + if audit_version_str is not None: + metadata['audit_version'] = str(audit_version_str).strip().lower() not in ('false', '0', 'no', 'off') + section_metadata = { 'mod_id': section_items.get('nexusmodid') or section_items.get('nexus_mod_id') or section_items.get('mod_id'), 'mod_filename': section_items.get('nexusfilename') or section_items.get('nexus_filename') or section_items.get('nexusmodfilename') or section_items.get('nexus_mod_filename') or section_items.get('mod_filename') or section_items.get('modname') or section_items.get('name'), @@ -630,6 +636,12 @@ def get_feature_key(feature_dir, feature_meta_map): meta = feature_meta_map.get(feature_key) ini_path = get_feature_ini(feature_dir) + # Activation-only features (e.g. Terrain Helper) opt out of version + # auditing via `AuditVersion = false` in their .ini: all their code lives + # in the core mod, so bumping the toggle .ini on every edit is meaningless + # churn. Skip them entirely from bump suggestions and PR-check failures. + if ini_path and not get_feature_ini_metadata(ini_path).get('audit_version', True): + continue # Use last release tag (version_ref) as the baseline for version proposals so that # multiple PRs between releases don't accumulate spurious bumps. prior_ver = get_prior_version(ini_path, version_ref) if ini_path else None From 4bb7ee57bf861594e8e573dcceeb7ee5aca60d68 Mon Sep 17 00:00:00 2001 From: Skrubby Skrub In A Shrub <87662196+SkrubbySkrubInAShrub@users.noreply.github.com> Date: Sun, 31 May 2026 13:38:18 +0200 Subject: [PATCH 2/3] chore: remove RELEASE_PAT reference --- .github/workflows/release-hotfix.yaml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-hotfix.yaml b/.github/workflows/release-hotfix.yaml index 1125bea95c..3add2299f2 100644 --- a/.github/workflows/release-hotfix.yaml +++ b/.github/workflows/release-hotfix.yaml @@ -286,14 +286,9 @@ jobs: - name: Open PR against hotfix branch if: ${{ !inputs.dry_run && steps.cherry.outputs.picked_count != '0' }} env: - # Use GITHUB_TOKEN (not RELEASE_PAT). The workflow declares - # `permissions: pull-requests: write`, which is sufficient - # for creating the candidate PR — no branch-protection - # bypass is required here, so there's no need to spend - # RELEASE_PAT's broader scope (and avoids depending on the - # PAT having pull_requests:write granted, which a previous - # run hit: "Resource not accessible by personal access - # token (createPullRequest)"). + # GITHUB_TOKEN suffices here: the workflow declares + # `permissions: pull-requests: write`, and creating the + # candidate PR requires no branch-protection bypass. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Pass via env (not ${{ }} interpolation) so the literal # backticks inside SOURCE_DESC don't get re-evaluated by From 8ad0692bac4e0be2d78512b70227264f5ee9a015 Mon Sep 17 00:00:00 2001 From: Skrubby Skrub In A Shrub <87662196+SkrubbySkrubInAShrub@users.noreply.github.com> Date: Sun, 31 May 2026 13:47:42 +0200 Subject: [PATCH 3/3] fix: address AI comment --- .github/workflows/release-semantic.yaml | 6 ++++-- tools/feature_version_audit.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index 9e57272484..9911c4159a 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -181,8 +181,10 @@ jobs: # on a `chore(release): … [skip ci]` commit (the tag push is skipped) # and the GitHub release is created as a draft (no `release: created` # event fires). So dispatch the build explicitly here to populate the - # draft with .7z artifacts. Publishing the draft later re-runs the - # build and fires the Nexus dry-run via `release: published`. + # draft with .7z artifacts. Publishing the draft later does NOT + # rebuild — release-build.yaml skips its build job on the + # `release: published` event — it just publishes the already-attached + # artifacts and fires the Nexus dry-run via `release: published`. if: steps.semantic.outputs.new_release_published == 'true' env: GH_TOKEN: ${{ github.token }} diff --git a/tools/feature_version_audit.py b/tools/feature_version_audit.py index 8e2b6b2924..08bfd1e48a 100644 --- a/tools/feature_version_audit.py +++ b/tools/feature_version_audit.py @@ -198,6 +198,9 @@ def get_feature_ini_metadata(feature_dir_or_ini_path): # Activation-only features keep all code in the core mod; their toggle # .ini opts out of version auditing/bumping with `AuditVersion = false`. + # Auditing is on by default, so a blank value must NOT exclude the ini: + # only explicit falsy values opt out (unlike auto_upload, which is opt-in + # and treats blank as off). audit_version_str = section_items.get('auditversion') or section_items.get('audit_version') if audit_version_str is not None: metadata['audit_version'] = str(audit_version_str).strip().lower() not in ('false', '0', 'no', 'off')