diff --git a/.github/actions/nuget-pack/action.yaml b/.github/actions/nuget-pack/action.yaml index f7186ca6..b66f5fe9 100644 --- a/.github/actions/nuget-pack/action.yaml +++ b/.github/actions/nuget-pack/action.yaml @@ -1,13 +1,13 @@ name: 'NuGet Pack' -description: 'Packs RocketModFix NuGet packages' +description: 'Packs (and optionally pushes) a RocketModFix NuGet package from a .nuspec' inputs: nuspec_path: description: 'Path to .nuspec' required: true nuget_push: - description: 'Push to Nuget?' + description: 'Push to NuGet?' required: false - default: false + default: 'false' nuget_key: description: 'NuGet deploy key' required: false @@ -15,10 +15,23 @@ runs: using: "composite" steps: - name: Pack - run: nuget pack ${{ inputs.nuspec_path }} shell: bash - - name: Push to NuGet (Release) - run: if ${{ inputs.nuget_push == 'true' }}; then - dotnet nuget push *.nupkg --api-key ${{ inputs.nuget_key }} --source https://api.nuget.org/v3/index.json; - fi + env: + NUSPEC_PATH: ${{ inputs.nuspec_path }} + run: | + set -euo pipefail + nuget pack "$NUSPEC_PATH" + - name: Push to NuGet shell: bash + env: + NUGET_PUSH: ${{ inputs.nuget_push }} + NUGET_KEY: ${{ inputs.nuget_key }} + run: | + set -euo pipefail + if [ "$NUGET_PUSH" != "true" ]; then + echo "nuget_push is '$NUGET_PUSH' (not 'true') — skipping push." + exit 0 + fi + # No --skip-duplicate by design: a duplicate version means something + # upstream went wrong, and the publish should fail loudly. + dotnet nuget push *.nupkg --api-key "$NUGET_KEY" --source https://api.nuget.org/v3/index.json diff --git a/.github/scripts/compare_nuget_version.py b/.github/scripts/compare_nuget_version.py new file mode 100644 index 00000000..5fabf6d8 --- /dev/null +++ b/.github/scripts/compare_nuget_version.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Compare two RocketModFix.Unturned.Redist NuGet versions. + +Prints one of: lt | eq | gt (how NEW compares to OLD). + +Handles the two version shapes this repo produces: + - stable: X.Y.Z.N (e.g. 3.26.3.2) + - preview: X.Y.Z.N-preview (e.g. 3.26.4.100-preview23479280) + +NuGet/SemVer ordering: a stable release outranks any prerelease of the same +core version, and prereleases are ordered by their numeric build id. + +Usage: compare_nuget_version.py +Exit codes: 0 on success, 2 on an unrecognized version string. +""" +import re +import sys + +_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-preview(\d+))?$") + + +def parse(version: str): + match = _VERSION_RE.match(version.strip()) + if not match: + print(f"::error::Unrecognized NuGet version format: {version}", file=sys.stderr) + sys.exit(2) + core = tuple(int(x) for x in match.groups()[:4]) + pre = match.group(5) + # (core, is_release, prerelease_build): release (1) sorts above prerelease (0). + return (core, 1, 0) if pre is None else (core, 0, int(pre)) + + +def main() -> None: + if len(sys.argv) != 3: + print("usage: compare_nuget_version.py ", file=sys.stderr) + sys.exit(2) + new, old = parse(sys.argv[1]), parse(sys.argv[2]) + print("lt" if new < old else ("eq" if new == old else "gt")) + + +if __name__ == "__main__": + main() diff --git a/.github/variants.json b/.github/variants.json new file mode 100644 index 00000000..a2ad1c30 --- /dev/null +++ b/.github/variants.json @@ -0,0 +1,102 @@ +[ + { + "variant": "client", + "appId": "304930", + "depotId": "304931", + "branch": "", + "dir": "redist/redist-client", + "nuspec": "redist/redist-client/RocketModFix.Unturned.Redist.Client.nuspec", + "preview": false, + "publicize": false + }, + { + "variant": "server", + "appId": "1110390", + "depotId": "1110391", + "branch": "", + "dir": "redist/redist-server", + "nuspec": "redist/redist-server/RocketModFix.Unturned.Redist.Server.nuspec", + "preview": false, + "publicize": false + }, + { + "variant": "client-preview", + "appId": "304930", + "depotId": "304931", + "branch": "preview", + "dir": "redist/redist-client-preview", + "nuspec": "redist/redist-client-preview/RocketModFix.Unturned.Redist.Client.nuspec", + "preview": true, + "publicize": false + }, + { + "variant": "server-preview", + "appId": "1110390", + "depotId": "1110391", + "branch": "preview", + "dir": "redist/redist-server-preview", + "nuspec": "redist/redist-server-preview/RocketModFix.Unturned.Redist.Server.nuspec", + "preview": true, + "publicize": false + }, + { + "variant": "client-preview-old", + "appId": "304930", + "depotId": "304931", + "branch": "preview", + "dir": "redist/redist-client-preview-old", + "nuspec": "redist/redist-client-preview-old/RocketModFix.Unturned.Redist.Client.nuspec", + "preview": false, + "publicize": false + }, + { + "variant": "server-preview-old", + "appId": "1110390", + "depotId": "1110391", + "branch": "preview", + "dir": "redist/redist-server-preview-old", + "nuspec": "redist/redist-server-preview-old/RocketModFix.Unturned.Redist.Server.nuspec", + "preview": false, + "publicize": false + }, + { + "variant": "client-publicized", + "appId": "304930", + "depotId": "304931", + "branch": "", + "dir": "redist/redist-client-publicized", + "nuspec": "redist/redist-client-publicized/RocketModFix.Unturned.Redist.Client.nuspec", + "preview": false, + "publicize": true + }, + { + "variant": "server-publicized", + "appId": "1110390", + "depotId": "1110391", + "branch": "", + "dir": "redist/redist-server-publicized", + "nuspec": "redist/redist-server-publicized/RocketModFix.Unturned.Redist.Server.nuspec", + "preview": false, + "publicize": true + }, + { + "variant": "client-preview-publicized", + "appId": "304930", + "depotId": "304931", + "branch": "preview", + "dir": "redist/redist-client-preview-publicized", + "nuspec": "redist/redist-client-preview-publicized/RocketModFix.Unturned.Redist.Client.nuspec", + "preview": false, + "publicize": true + }, + { + "variant": "server-preview-publicized", + "appId": "1110390", + "depotId": "1110391", + "branch": "preview", + "dir": "redist/redist-server-preview-publicized", + "nuspec": "redist/redist-server-preview-publicized/RocketModFix.Unturned.Redist.Server.nuspec", + "preview": false, + "publicize": true + } +] diff --git a/.github/workflows/Cleanup.RedistBranch.yaml b/.github/workflows/Cleanup.RedistBranch.yaml index 66cbae0c..31a58dda 100644 --- a/.github/workflows/Cleanup.RedistBranch.yaml +++ b/.github/workflows/Cleanup.RedistBranch.yaml @@ -17,19 +17,21 @@ jobs: pull-requests: write steps: - name: Lock PR conversation - run: | - PR_NUMBER="${{ github.event.pull_request.number }}" - echo "Locking PR #$PR_NUMBER to prevent further comments" - gh pr lock "$PR_NUMBER" --repo "${{ github.repository }}" --reason resolved env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Delete PR branch run: | - BRANCH_NAME="${{ github.event.pull_request.head.ref }}" - echo "Deleting redist PR branch: $BRANCH_NAME" - gh api \ - -X DELETE \ - "repos/${{ github.repository }}/git/refs/heads/$BRANCH_NAME" + echo "Locking PR #${{ github.event.pull_request.number }}" + gh pr lock "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}" --reason resolved || true + + # Only delete after a successful merge. Never delete the branch of a PR + # that was closed without merging (it may contain work to investigate). + # PRs closed-as-unnecessary by create-pull-request are already cleaned up + # by its delete-branch:true, so this is the merge case. + - name: Delete merged PR branch + if: github.event.pull_request.merged == true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH_NAME="${{ github.event.pull_request.head.ref }}" + echo "Deleting merged redist PR branch: $BRANCH_NAME" + gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/$BRANCH_NAME" || true diff --git a/.github/workflows/RocketModFix.Unturned.Redist.Matrix.yaml b/.github/workflows/RocketModFix.Unturned.Redist.Matrix.yaml index cfd7be2d..64a625f4 100644 --- a/.github/workflows/RocketModFix.Unturned.Redist.Matrix.yaml +++ b/.github/workflows/RocketModFix.Unturned.Redist.Matrix.yaml @@ -8,100 +8,93 @@ on: workflow_dispatch: inputs: variant: - description: 'Which variant to update' + description: 'A variant name from .github/variants.json, or "all"' required: true - type: choice - options: - - all - - client - - client-preview - - client-preview-old - - client-preview-publicized - - client-publicized - - server - - server-preview - - server-preview-old - - server-preview-publicized - - server-publicized + default: 'all' + type: string jobs: + # Single source of truth: variant definitions live in .github/variants.json. + load-variants: + name: "Load Variants" + runs-on: ubuntu-latest + outputs: + variants: ${{ steps.load.outputs.variants }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Load variant matrix + id: load + env: + INPUT_VARIANT: ${{ github.event.inputs.variant }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "${INPUT_VARIANT:-}" ] && [ "$INPUT_VARIANT" != "all" ]; then + if ! jq -e --arg v "$INPUT_VARIANT" 'any(.[]; .variant == $v)' .github/variants.json >/dev/null; then + echo "::error::Unknown variant '$INPUT_VARIANT'. Valid variants:" + jq -r '.[].variant' .github/variants.json + exit 1 + fi + variants=$(jq -c --arg v "$INPUT_VARIANT" '[.[] | select(.variant == $v)]' .github/variants.json) + else + variants=$(jq -c '.' .github/variants.json) + fi + echo "variants=$variants" >> "$GITHUB_OUTPUT" + build: name: "Build ${{ matrix.variant }}" runs-on: ubuntu-22.04 - continue-on-error: true - + needs: load-variants + # No job-level continue-on-error: a failed pack/push must surface red. + # fail-fast:false keeps the other (independent) variants building. strategy: - matrix: - include: - # Client variants - - variant: "client" - nuspec_path: "redist/redist-client/RocketModFix.Unturned.Redist.Client.nuspec" - trigger_path: "redist/redist-client/**" - - variant: "client-preview" - nuspec_path: "redist/redist-client-preview/RocketModFix.Unturned.Redist.Client.nuspec" - trigger_path: "redist/redist-client-preview/**" - - variant: "client-preview-old" - nuspec_path: "redist/redist-client-preview-old/RocketModFix.Unturned.Redist.Client.nuspec" - trigger_path: "redist/redist-client-preview-old/**" - - variant: "client-preview-publicized" - nuspec_path: "redist/redist-client-preview-publicized/RocketModFix.Unturned.Redist.Client.nuspec" - trigger_path: "redist/redist-client-preview-publicized/**" - - variant: "client-publicized" - nuspec_path: "redist/redist-client-publicized/RocketModFix.Unturned.Redist.Client.nuspec" - trigger_path: "redist/redist-client-publicized/**" - - # Server variants - - variant: "server" - nuspec_path: "redist/redist-server/RocketModFix.Unturned.Redist.Server.nuspec" - trigger_path: "redist/redist-server/**" - - variant: "server-preview" - nuspec_path: "redist/redist-server-preview/RocketModFix.Unturned.Redist.Server.nuspec" - trigger_path: "redist/redist-server-preview/**" - - variant: "server-preview-old" - nuspec_path: "redist/redist-server-preview-old/RocketModFix.Unturned.Redist.Server.nuspec" - trigger_path: "redist/redist-server-preview-old/**" - - variant: "server-preview-publicized" - nuspec_path: "redist/redist-server-preview-publicized/RocketModFix.Unturned.Redist.Server.nuspec" - trigger_path: "redist/redist-server-preview-publicized/**" - - variant: "server-publicized" - nuspec_path: "redist/redist-server-publicized/RocketModFix.Unturned.Redist.Server.nuspec" - trigger_path: "redist/redist-server-publicized/**" fail-fast: false + matrix: + include: ${{ fromJson(needs.load-variants.outputs.variants) }} steps: - name: Checkout code uses: actions/checkout@v6 with: - # Fetches all history for the tj-actions/changed-files to work correctly on push + # Full history so we can diff the push range for per-variant change detection. fetch-depth: 0 - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v47 - with: - files: | - ${{ matrix.trigger_path }} - - name: Determine if this variant should run id: check + env: + EVENT_NAME: ${{ github.event_name }} + DIR: ${{ matrix.dir }} + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.sha }} run: | - SHOULD_RUN=false - # For manual triggers, check the input - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - if [[ "${{ github.event.inputs.variant }}" == "all" || "${{ github.event.inputs.variant }}" == "${{ matrix.variant }}" ]]; then - SHOULD_RUN=true + set -euo pipefail + should_run=false + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + # load-variants already filtered the matrix to the requested variant(s). + should_run=true + else + # Native git diff replaces tj-actions/changed-files (one less third-party + # action). The Update pipeline squash-merges one variant per commit, so + # github.event.before is the prior master tip; fall back to HEAD~1. + before="$BEFORE_SHA" + if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then + before="HEAD~1" fi - # For push triggers, use the changed-files output - elif [[ "${{ github.event_name }}" == "push" ]]; then - if [[ "${{ steps.changed-files.outputs.any_changed }}" == "true" ]]; then - SHOULD_RUN=true + if ! git rev-parse --verify "$before" >/dev/null 2>&1; then + # No usable baseline (e.g. first push to a branch) -> build to be safe. + should_run=true + elif git diff --name-only "$before" "$AFTER_SHA" -- "$DIR/" | grep -q .; then + should_run=true fi fi - echo "should_run=$SHOULD_RUN" >> $GITHUB_OUTPUT + echo "Variant ${{ matrix.variant }} should_run=$should_run" + echo "should_run=$should_run" >> "$GITHUB_OUTPUT" - name: Setup NuGet if: steps.check.outputs.should_run == 'true' - uses: nuget/setup-nuget@v4 + uses: nuget/setup-nuget@fd55a6f3b34392fa83fde1454582407d8c714123 # v4 with: nuget-api-key: ${{ secrets.NUGET_DEPLOY_KEY }} @@ -110,6 +103,6 @@ jobs: uses: ./.github/actions/nuget-pack id: nuget-pack with: - nuspec_path: ${{ matrix.nuspec_path }} + nuspec_path: ${{ matrix.nuspec }} nuget_key: ${{ secrets.NUGET_DEPLOY_KEY }} - nuget_push: true \ No newline at end of file + nuget_push: true diff --git a/.github/workflows/Update.Unturned.Redist.yaml b/.github/workflows/Update.Unturned.Redist.yaml index 6af96329..91752d2d 100644 --- a/.github/workflows/Update.Unturned.Redist.yaml +++ b/.github/workflows/Update.Unturned.Redist.yaml @@ -6,104 +6,69 @@ on: workflow_dispatch: inputs: variant: - description: 'Which variant to update' + description: 'A variant name from .github/variants.json, or "all"' required: true default: 'all' - type: choice - options: - - all - - client - - server - - server-preview - - client-preview - - server-preview-old - - client-preview-old - - client-publicized - - server-publicized - - client-preview-publicized - - server-preview-publicized + type: string permissions: contents: write - packages: write pull-requests: write + issues: write concurrency: group: unturned-redist-update-${{ github.ref }} cancel-in-progress: false jobs: - determine-variants: - name: "Determine Variants to Run" + # Single source of truth: the variant matrix lives in .github/variants.json. + # This job loads it (optionally filtered by the workflow_dispatch input) and + # exposes it as a matrix for update_redist. To add/remove a variant, edit + # .github/variants.json only. + load-variants: + name: "Load Variants" runs-on: ubuntu-latest outputs: - variants: ${{ steps.set_variants.outputs.variants }} + variants: ${{ steps.load.outputs.variants }} steps: - - name: Set variants - id: set_variants + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Load variant matrix + id: load + env: + INPUT_VARIANT: ${{ github.event.inputs.variant }} + EVENT_NAME: ${{ github.event_name }} run: | - if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - if [ "$INPUT_VARIANT" = "all" ]; then - all_variants=("client" "server" "server-preview" "client-preview" "server-preview-old" "client-preview-old" "client-publicized" "server-publicized" "client-preview-publicized" "server-preview-publicized") - variants_json=$(printf '%s\n' "${all_variants[@]}" | jq -R . | jq -s -c .) - echo "variants=$variants_json" >> $GITHUB_OUTPUT - else - echo "variants=[\"${INPUT_VARIANT}\"]" >> $GITHUB_OUTPUT + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "${INPUT_VARIANT:-}" ] && [ "$INPUT_VARIANT" != "all" ]; then + if ! jq -e --arg v "$INPUT_VARIANT" 'any(.[]; .variant == $v)' .github/variants.json >/dev/null; then + echo "::error::Unknown variant '$INPUT_VARIANT'. Valid variants:" + jq -r '.[].variant' .github/variants.json + exit 1 fi + variants=$(jq -c --arg v "$INPUT_VARIANT" '[.[] | select(.variant == $v)]' .github/variants.json) else - all_variants=("client" "server" "server-preview" "client-preview" "server-preview-old" "client-preview-old" "client-publicized" "server-publicized" "client-preview-publicized" "server-preview-publicized") - variants_json=$(printf '%s\n' "${all_variants[@]}" | jq -R . | jq -s -c .) - echo "variants=$variants_json" >> $GITHUB_OUTPUT + variants=$(jq -c '.' .github/variants.json) fi - env: - INPUT_VARIANT: ${{ github.event.inputs.variant }} - GITHUB_EVENT_NAME: ${{ github.event_name }} + echo "variants=$variants" >> "$GITHUB_OUTPUT" update_redist: - name: "Update Redist" + name: "Update ${{ matrix.variant }}" runs-on: ubuntu-latest - needs: determine-variants - continue-on-error: true + needs: load-variants + # fail-fast:false keeps the other variants running if one fails; we no + # longer swallow failures with continue-on-error so a broken variant turns + # the run red (and notify-failure files an issue). strategy: - matrix: - variant: ${{ fromJson(needs.determine-variants.outputs.variants) }} fail-fast: false + matrix: + include: ${{ fromJson(needs.load-variants.outputs.variants) }} steps: - - name: Determine APP_ID, APP_BRANCH_NAME, APP_DEPOT_ID, and REDIST_DIR - id: vars - continue-on-error: true - run: | - if [[ "${{ matrix.variant }}" == client || "${{ matrix.variant }}" == client-preview || "${{ matrix.variant }}" == client-preview-publicized || "${{ matrix.variant }}" == client-preview-old ]]; then - echo "APP_ID=304930" >> $GITHUB_ENV - echo "APP_DEPOT_ID=304931" >> $GITHUB_ENV - else - echo "APP_ID=1110390" >> $GITHUB_ENV - echo "APP_DEPOT_ID=1110391" >> $GITHUB_ENV - fi - - if [[ "${{ matrix.variant }}" == client-preview || "${{ matrix.variant }}" == server-preview || "${{ matrix.variant }}" == client-preview-publicized || "${{ matrix.variant }}" == server-preview-publicized || "${{ matrix.variant }}" == client-preview-old || "${{ matrix.variant }}" == server-preview-old ]]; then - echo "APP_BRANCH_NAME=preview" >> $GITHUB_ENV - fi + - name: Generate branch name + run: echo "BRANCH_NAME=redist-update/${{ matrix.variant }}" >> "$GITHUB_ENV" - case "${{ matrix.variant }}" in - client-preview) echo "REDIST_DIR=redist/redist-client-preview" >> $GITHUB_ENV ;; - server-preview) echo "REDIST_DIR=redist/redist-server-preview" >> $GITHUB_ENV ;; - client-preview-old) echo "REDIST_DIR=redist/redist-client-preview-old" >> $GITHUB_ENV ;; - server-preview-old) echo "REDIST_DIR=redist/redist-server-preview-old" >> $GITHUB_ENV ;; - client) echo "REDIST_DIR=redist/redist-client" >> $GITHUB_ENV ;; - server) echo "REDIST_DIR=redist/redist-server" >> $GITHUB_ENV ;; - client-publicized) echo "REDIST_DIR=redist/redist-client-publicized" >> $GITHUB_ENV ;; - server-publicized) echo "REDIST_DIR=redist/redist-server-publicized" >> $GITHUB_ENV ;; - client-preview-publicized) echo "REDIST_DIR=redist/redist-client-preview-publicized" >> $GITHUB_ENV ;; - server-preview-publicized) echo "REDIST_DIR=redist/redist-server-preview-publicized" >> $GITHUB_ENV ;; - *) echo "REDIST_DIR=redist/redist-unknown" >> $GITHUB_ENV ;; - esac - - # Generate unique branch name - timestamp=$(date +%Y%m%d-%H%M%S) - echo "BRANCH_NAME=redist-update/${{ matrix.variant }}-${timestamp}" >> $GITHUB_ENV - - name: Checkout repository uses: actions/checkout@v6 with: @@ -117,61 +82,81 @@ jobs: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true with: - dotnet-version: | - 9.x - 6.0.x + # UnturnedRedistUpdateTool targets net9; DepotDownloader needs net8 + # (its self-contained release runs on 9.x). .NET 6 ran neither. + dotnet-version: 9.x - name: Download depot downloader - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1 with: repository: SteamRE/DepotDownloader latest: true fileName: DepotDownloader-linux-x64.zip out-file-path: depot_downloader - tag: "DepotDownloader_3.4.0" # Pin it's version to avoid breaking changes. + tag: "DepotDownloader_3.4.0" # Pin its version to avoid breaking changes. extract: true - name: Compare current vs saved manifest id: compare_manifest - continue-on-error: true + env: + STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} + STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }} + APP_ID: ${{ matrix.appId }} + APP_DEPOT_ID: ${{ matrix.depotId }} + APP_BRANCH_NAME: ${{ matrix.branch }} + VARIANT: ${{ matrix.variant }} run: | - MANIFEST_FILE="redist/redist-manifests/.manifest.redist-${{ matrix.variant }}.txt" - + set -euo pipefail + MANIFEST_FILE="redist/redist-manifests/.manifest.redist-${VARIANT}.txt" mkdir -p redist/temp_depots - chmod +x depot_downloader/DepotDownloader + + beta_args=() + if [ -n "$APP_BRANCH_NAME" ]; then + beta_args=(-beta "$APP_BRANCH_NAME") + fi + + # '|| true': DepotDownloader can exit non-zero even on a successful + # manifest fetch. We don't trust its exit code — the 19-digit + # validation below is the real gate (a genuine auth/network failure + # yields no manifest id and still fails the step loudly). manifest_output=$(depot_downloader/DepotDownloader \ - -app $APP_ID \ - -depot $APP_DEPOT_ID \ - -username ${{ secrets.STEAM_USERNAME }} \ - -password ${{ secrets.STEAM_PASSWORD }} \ + -app "$APP_ID" \ + -depot "$APP_DEPOT_ID" \ + -username "$STEAM_USERNAME" \ + -password "$STEAM_PASSWORD" \ -manifest-only \ - -beta $APP_BRANCH_NAME \ - -dir redist/temp_depots) - - current_manifest=$(echo "$manifest_output" | grep -oE '[0-9]{19}' | head -n 1) - echo "Current manifest: $current_manifest" - - if [ -f "$MANIFEST_FILE" ]; then - previous_manifest=$(cat "$MANIFEST_FILE") - else - previous_manifest="" + "${beta_args[@]}" \ + -dir redist/temp_depots || true) + + current_manifest=$(printf '%s\n' "$manifest_output" | grep -oE '[0-9]{19}' | head -n 1 || true) + + # Fail loudly instead of silently treating an extraction failure as + # "no change" (which would leave the variant stale-but-green forever). + if ! [[ "$current_manifest" =~ ^[0-9]{19}$ ]]; then + echo "::error::Could not extract a 19-digit manifest id for ${VARIANT} (Steam auth/network issue or output-format change?)." + echo "----- DepotDownloader output (first 50 lines) -----" + printf '%s\n' "$manifest_output" | head -n 50 + exit 1 fi - - echo "Previous manifest: $previous_manifest" - + echo "Current manifest: $current_manifest" + + previous_manifest="" + [ -f "$MANIFEST_FILE" ] && previous_manifest=$(cat "$MANIFEST_FILE") + echo "Previous manifest: ${previous_manifest:-}" + if [ "$current_manifest" != "$previous_manifest" ]; then echo "Manifest changed" echo "$current_manifest" > "$MANIFEST_FILE" - echo "manifest_changed=true" >> $GITHUB_OUTPUT + echo "manifest_changed=true" >> "$GITHUB_OUTPUT" else echo "Manifest unchanged" - echo "manifest_changed=false" >> $GITHUB_OUTPUT + echo "manifest_changed=false" >> "$GITHUB_OUTPUT" fi - name: Download update tool if: steps.compare_manifest.outputs.manifest_changed == 'true' - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1 with: repository: RocketModFix/UnturnedRedistUpdateTool latest: true @@ -181,123 +166,130 @@ jobs: - name: Setup SteamCMD if: steps.compare_manifest.outputs.manifest_changed == 'true' - uses: CyberAndrii/setup-steamcmd@v1 + uses: CyberAndrii/setup-steamcmd@afc45f145b95c175c3a3862d3ca84756537d48e8 # v1 - name: Update game files if: steps.compare_manifest.outputs.manifest_changed == 'true' + env: + STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} + STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }} + APP_ID: ${{ matrix.appId }} + APP_BRANCH_NAME: ${{ matrix.branch }} run: | - if [[ "$APP_BRANCH_NAME" == "" ]]; then - steamcmd +force_install_dir $GITHUB_WORKSPACE +login ${{ secrets.STEAM_USERNAME }} ${{ secrets.STEAM_PASSWORD }} +app_update $APP_ID -validate +quit + set -uo pipefail + # SteamCMD frequently exits non-zero even on a successful download, so + # its exit code is not a reliable failure signal. We log it but do not + # fail here — the redist tool below is the real gate (it errors if the + # game's Managed files aren't present), so a genuinely failed download + # still turns the run red. + rc=0 + if [ -z "$APP_BRANCH_NAME" ]; then + steamcmd +force_install_dir "$GITHUB_WORKSPACE" +login "$STEAM_USERNAME" "$STEAM_PASSWORD" +app_update "$APP_ID" -validate +quit || rc=$? else - steamcmd +force_install_dir $GITHUB_WORKSPACE +login ${{ secrets.STEAM_USERNAME }} ${{ secrets.STEAM_PASSWORD }} +app_update $APP_ID -beta $APP_BRANCH_NAME -validate +quit + steamcmd +force_install_dir "$GITHUB_WORKSPACE" +login "$STEAM_USERNAME" "$STEAM_PASSWORD" +app_update "$APP_ID" -beta "$APP_BRANCH_NAME" -validate +quit || rc=$? + fi + if [ "$rc" -ne 0 ]; then + echo "::warning::steamcmd exited with code $rc (often non-fatal); the redist tool will fail if the game files are actually missing." fi - name: Run redist updater if: steps.compare_manifest.outputs.manifest_changed == 'true' - continue-on-error: true + env: + IS_PREVIEW: ${{ matrix.preview }} + DO_PUBLICIZE: ${{ matrix.publicize }} + APP_ID: ${{ matrix.appId }} + REDIST_DIR: ${{ matrix.dir }} + EVENT_NAME: ${{ github.event_name }} + VARIANT: ${{ matrix.variant }} run: | + set -euo pipefail flags="" - - # Add --force flag for manual dispatch - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # Manual dispatch forces a rebuild even if the tool thinks nothing changed. + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then flags="$flags --force" fi - - # Add --preview flag for preview variants - case "${{ matrix.variant }}" in - client-preview|server-preview) - flags="$flags --preview" - ;; - esac - - # Add -update-files with all DLL files + # Only client-preview / server-preview emit the -preview prerelease. + if [ "$IS_PREVIEW" = "true" ]; then + flags="$flags --preview" + fi flags="$flags -update-files Assembly-CSharp.dll,Assembly-CSharp.xml,SDG.NetPak.Runtime.xml,UnturnedDat.dll,UnityEx.dll,SystemEx.dll,SDG.NetTransport.dll,SDG.NetPak.Runtime.dll,SDG.HostBans.Runtime.dll,SDG.Glazier.Runtime.dll,com.rlabrecque.steamworks.net.dll" - - if [[ "${{ matrix.variant }}" == client-publicized || "${{ matrix.variant }}" == server-publicized || "${{ matrix.variant }}" == client-preview-publicized || "${{ matrix.variant }}" == server-preview-publicized ]]; then + if [ "$DO_PUBLICIZE" = "true" ]; then flags="$flags -publicize Assembly-CSharp.dll" fi - - echo "Event name: ${{ github.event_name }}" - echo "Matrix variant: ${{ matrix.variant }}" - echo "Final flags: '$flags'" - - dotnet redist_tool/UnturnedRedistUpdateTool.dll "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE/$REDIST_DIR" "$APP_ID" $flags - - - name: Generate Commit Message + echo "Variant: $VARIANT | Event: $EVENT_NAME | Flags: '$flags'" + # Capture output so a failure (or a success that doesn't write .commit) + # leaves diagnostics in the log, mirroring the manifest step. + if ! tool_output=$(dotnet redist_tool/UnturnedRedistUpdateTool.dll "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE/$REDIST_DIR" "$APP_ID" $flags 2>&1); then + echo "::error::Redist updater failed for ${VARIANT}." + echo "----- Tool output (first 80 lines) -----" + printf '%s\n' "$tool_output" | head -n 80 + exit 1 + fi + printf '%s\n' "$tool_output" + + - name: Generate commit message if: steps.compare_manifest.outputs.manifest_changed == 'true' - run: | - msg=$( cat .commit ) - echo "message=$msg" >> "$GITHUB_OUTPUT" id: generate_commit_message + run: | + set -euo pipefail + if [ ! -s .commit ]; then + echo "::error::Redist updater did not produce a non-empty .commit file for ${{ matrix.variant }}." + exit 1 + fi + { + echo "message<> "$GITHUB_OUTPUT" - name: Check for git changes if: steps.compare_manifest.outputs.manifest_changed == 'true' id: check_git_changes run: | if git diff --quiet; then - echo "No changes detected after all processing for ${{ matrix.variant }}." - echo "has_git_changes=false" >> $GITHUB_OUTPUT - else - echo "Changes detected after all processing for ${{ matrix.variant }}" - echo "has_git_changes=true" >> $GITHUB_OUTPUT - fi - - - name: Check for existing PRs - if: steps.compare_manifest.outputs.manifest_changed == 'true' && steps.check_git_changes.outputs.has_git_changes == 'true' - id: check_existing_prs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Check for existing open PRs for this variant created by rocketmodfixadmin - existing_prs=$(gh pr list --state open --search "Auto-update ${{ matrix.variant }} redist files" --json number,title,url,author) - - # Filter to only include PRs created by rocketmodfixadmin - filtered_prs=$(echo "$existing_prs" | jq '[.[] | select(.author.login == "rocketmodfixadmin")]') - - if [ "$(echo "$filtered_prs" | jq length)" -gt 0 ]; then - echo "Found existing PRs for ${{ matrix.variant }} created by rocketmodfixadmin:" - echo "$filtered_prs" | jq -r '.[] | "- #\(.number): \(.title) by \(.author.login) (\(.url))"' - echo "has_existing_pr=true" >> $GITHUB_OUTPUT - echo "existing_pr_count=$(echo "$filtered_prs" | jq length)" >> $GITHUB_OUTPUT + echo "No changes detected after processing for ${{ matrix.variant }}." + echo "has_git_changes=false" >> "$GITHUB_OUTPUT" else - echo "No existing PRs found for ${{ matrix.variant }} created by rocketmodfixadmin" - echo "has_existing_pr=false" >> $GITHUB_OUTPUT - echo "existing_pr_count=0" >> $GITHUB_OUTPUT + echo "Changes detected after processing for ${{ matrix.variant }}." + echo "has_git_changes=true" >> "$GITHUB_OUTPUT" fi + # peter-evans/create-pull-request commits to a fixed-name branch and keeps + # the open PR continually updated until it is merged/closed; delete-branch + # cleans it up afterwards. No timestamped branches or existing-PR lookups. - name: Create Pull Request - if: steps.compare_manifest.outputs.manifest_changed == 'true' && steps.check_git_changes.outputs.has_git_changes == 'true' && steps.check_existing_prs.outputs.has_existing_pr == 'false' + if: steps.compare_manifest.outputs.manifest_changed == 'true' && steps.check_git_changes.outputs.has_git_changes == 'true' id: create_pr - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 with: add-paths: | redist/* token: ${{ secrets.PAT }} branch: ${{ env.BRANCH_NAME }} + delete-branch: true base: master - commit-message: "${{ steps.generate_commit_message.outputs.message }}" + commit-message: ${{ steps.generate_commit_message.outputs.message }} title: "🤖 Auto-update ${{ matrix.variant }} redist files" committer: rocketmodfixadmin author: rocketmodfixadmin body: | ## Automated Redist Update - ${{ matrix.variant }} - + This PR contains automatically updated redist files for the **${{ matrix.variant }}** variant. - + ### Changes ${{ steps.generate_commit_message.outputs.message }} - + ### Validation - 🔄 Validation will run automatically when this PR is created. - + 🔄 Validation (file presence, SHA-256 hashes, version monotonicity) runs automatically on this PR. + --- - + **Triggered by**: ${{ github.event_name == 'workflow_dispatch' && 'Manual dispatch' || 'Scheduled run' }} **Variant**: ${{ matrix.variant }} **Branch**: `${{ env.BRANCH_NAME }}` - - > This PR was automatically created by the Unturned Redist update workflow. - > Validation checks will run automatically. Review the changes carefully before merging. + + > Automatically created by the Unturned Redist update workflow. draft: false labels: | automated @@ -305,25 +297,49 @@ jobs: ${{ matrix.variant }} - name: Summary + if: always() run: | - echo "## Update Summary for ${{ matrix.variant }}" >> $GITHUB_STEP_SUMMARY - echo "- **Branch**: \`${{ env.BRANCH_NAME }}\`" >> $GITHUB_STEP_SUMMARY - echo "- **Manifest Changed**: ${{ steps.compare_manifest.outputs.manifest_changed }}" >> $GITHUB_STEP_SUMMARY - if [[ "${{ steps.compare_manifest.outputs.manifest_changed }}" == "true" ]]; then - echo "- **Git Changes**: ${{ steps.check_git_changes.outputs.has_git_changes }}" >> $GITHUB_STEP_SUMMARY - if [[ "${{ steps.check_git_changes.outputs.has_git_changes }}" == "true" ]]; then - echo "- **Existing PRs**: ${{ steps.check_existing_prs.outputs.existing_pr_count }}" >> $GITHUB_STEP_SUMMARY - if [[ "${{ steps.check_existing_prs.outputs.has_existing_pr }}" == "true" ]]; then - echo "- **Status**: Skipped (existing PR already pending)" >> $GITHUB_STEP_SUMMARY - else - echo "- **PR Created**: #${{ steps.create_pr.outputs.pull-request-number }}" >> $GITHUB_STEP_SUMMARY - echo "- **PR URL**: ${{ steps.create_pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY + { + echo "## Update Summary for ${{ matrix.variant }}" + echo "- **Branch**: \`${{ env.BRANCH_NAME }}\`" + echo "- **Manifest Changed**: ${{ steps.compare_manifest.outputs.manifest_changed }}" + if [ "${{ steps.compare_manifest.outputs.manifest_changed }}" = "true" ]; then + echo "- **Git Changes**: ${{ steps.check_git_changes.outputs.has_git_changes }}" + if [ -n "${{ steps.create_pr.outputs.pull-request-number }}" ]; then + echo "- **PR**: #${{ steps.create_pr.outputs.pull-request-number }} (${{ steps.create_pr.outputs.pull-request-url }})" fi else - echo "- **Status**: No git changes detected after processing" >> $GITHUB_STEP_SUMMARY + echo "- **Status**: Skipped (no manifest changes)" fi + } >> "$GITHUB_STEP_SUMMARY" + + # Make silent breakage visible: if any scheduled update fails, open (or + # comment on) a tracking issue instead of relying on someone watching the + # Actions tab. + notify-failure: + name: "Notify on failure" + needs: [load-variants, update_redist] + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Open or update tracking issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + marker="Redist auto-update workflow failed" + body=$(printf '⚠️ The scheduled redist update workflow failed.\n\nRun: %s\n\nA matrix variant errored (Steam auth/network, DepotDownloader, the redist tool, or PR creation). Check the run for the failing variant.' "$RUN_URL") + gh label create update-failure --color B60205 --description "Automated redist update failure" --force >/dev/null 2>&1 || true + existing=$(gh issue list --repo "$REPO" --state open --search "in:title \"$marker\"" --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$REPO" --body "$body" else - echo "- **Status**: Skipped (no manifest changes)" >> $GITHUB_STEP_SUMMARY + gh issue create --repo "$REPO" --title "⚠️ $marker" --body "$body" --label update-failure fi workflow-keepalive: @@ -332,4 +348,4 @@ jobs: permissions: actions: write steps: - - uses: liskin/gh-workflow-keepalive@v1 \ No newline at end of file + - uses: liskin/gh-workflow-keepalive@f72ff1a1336129f29bf0166c0fd0ca6cf1bcb38c # v1 diff --git a/.github/workflows/Verify.Redist.Update.yaml b/.github/workflows/Verify.Redist.Update.yaml index 4115d9c1..cf08cee3 100644 --- a/.github/workflows/Verify.Redist.Update.yaml +++ b/.github/workflows/Verify.Redist.Update.yaml @@ -1,5 +1,10 @@ name: "Verify.Redist.Update" +# Uses `pull_request` (NOT `pull_request_target`): the validation steps below run +# only PR-supplied data (file checks), never untrusted PR code, so they have no +# access to repo secrets from a fork — which is what we want. The auto-merge / +# auto-approve steps are additionally gated on the PR author being the trusted +# bot (`rocketmodfixadmin`), a value GitHub sets and a fork cannot spoof. on: pull_request: branches: @@ -25,45 +30,44 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Validate redist update + - name: Resolve variant and redist directory run: | - echo "🔍 Verifying redist update..." - - VARIANT=$(echo "${GITHUB_HEAD_REF}" | cut -d'/' -f2 | sed -E 's/-[0-9]{8}-[0-9]{6}$//') + set -euo pipefail + # Automated redist PRs use the branch name redist-update/. + if [[ "${GITHUB_HEAD_REF}" != redist-update/* ]]; then + echo "::error::This validator only handles automated 'redist-update/' branches (got '${GITHUB_HEAD_REF}')." + exit 1 + fi + VARIANT="${GITHUB_HEAD_REF##*/}" echo "Detected variant: $VARIANT" - case "$VARIANT" in - client-preview) REDIST_DIR="redist/redist-client-preview" ;; - server-preview) REDIST_DIR="redist/redist-server-preview" ;; - client-preview-old) REDIST_DIR="redist/redist-client-preview-old" ;; - server-preview-old) REDIST_DIR="redist/redist-server-preview-old" ;; - client) REDIST_DIR="redist/redist-client" ;; - server) REDIST_DIR="redist/redist-server" ;; - client-publicized) REDIST_DIR="redist/redist-client-publicized" ;; - server-publicized) REDIST_DIR="redist/redist-server-publicized" ;; - client-preview-publicized) REDIST_DIR="redist/redist-client-preview-publicized" ;; - server-preview-publicized) REDIST_DIR="redist/redist-server-preview-publicized" ;; - *) - echo "Unknown variant: $VARIANT" - exit 1 - ;; - esac - - echo "📁 Validating redist directory: $REDIST_DIR" - + # Single source of truth: map variant -> dir via .github/variants.json. + REDIST_DIR=$(jq -r --arg v "$VARIANT" '.[] | select(.variant == $v) | .dir' .github/variants.json) + IS_PREVIEW=$(jq -r --arg v "$VARIANT" '.[] | select(.variant == $v) | .preview' .github/variants.json) + if [ -z "$REDIST_DIR" ] || [ "$REDIST_DIR" = "null" ]; then + echo "::error::Unknown variant '$VARIANT' (not found in .github/variants.json)." + exit 1 + fi if [ ! -d "$REDIST_DIR" ]; then - echo "❌ Redist directory does not exist: $REDIST_DIR" + echo "::error::Redist directory does not exist: $REDIST_DIR" exit 1 fi + echo "✅ Variant=$VARIANT Dir=$REDIST_DIR Preview=$IS_PREVIEW" - echo "✅ Redist directory exists: $REDIST_DIR" - - echo "REDIST_DIR=$REDIST_DIR" >> $GITHUB_ENV + echo "VARIANT=$VARIANT" >> "$GITHUB_ENV" + echo "REDIST_DIR=$REDIST_DIR" >> "$GITHUB_ENV" + # Preview variants track version.preview.json; everyone else version.json. + if [ "$IS_PREVIEW" = "true" ]; then + echo "VERSION_FILE=$REDIST_DIR/version.preview.json" >> "$GITHUB_ENV" + else + echo "VERSION_FILE=$REDIST_DIR/version.json" >> "$GITHUB_ENV" + fi - - name: Validate DLL and XML files + - name: Validate required DLL and XML files run: | - echo "📋 Validating DLL and XML documentation files..." - + set -euo pipefail + echo "📋 Validating required DLL and XML documentation files in $REDIST_DIR..." + REQUIRED_DLLS=( "Assembly-CSharp.dll" "SDG.NetPak.Runtime.dll" @@ -75,73 +79,106 @@ jobs: "UnityEx.dll" "UnturnedDat.dll" ) - - declare -A DLL_XML_MAPPING=( - ["Assembly-CSharp.dll"]="Assembly-CSharp.xml" - ["SDG.NetPak.Runtime.dll"]="SDG.NetPak.Runtime.xml" - ) - - MISSING_DLLS=() - MISSING_XML_FILES=() - - echo "🔍 Checking required DLL files..." - for dll_file in "${REQUIRED_DLLS[@]}"; do - if [ -f "$REDIST_DIR/$dll_file" ]; then - echo "✅ Found $dll_file" - else - echo "❌ Missing $dll_file" - MISSING_DLLS+=("$dll_file") - fi + # DLLs that must ship with a matching XML doc. + XML_FOR=("Assembly-CSharp.dll" "SDG.NetPak.Runtime.dll") + + missing=() + for dll in "${REQUIRED_DLLS[@]}"; do + if [ -f "$REDIST_DIR/$dll" ]; then echo "✅ $dll"; else echo "❌ $dll"; missing+=("$dll"); fi done - - echo "🔍 Checking XML documentation files..." - for dll_file in "${!DLL_XML_MAPPING[@]}"; do - xml_file="${DLL_XML_MAPPING[$dll_file]}" - - if [ -f "$REDIST_DIR/$dll_file" ]; then - if [ -f "$REDIST_DIR/$xml_file" ]; then - echo "✅ Found $xml_file (for $dll_file)" - else - echo "❌ Missing $xml_file (for $dll_file)" - MISSING_XML_FILES+=("$xml_file") - fi - fi + for dll in "${XML_FOR[@]}"; do + xml="${dll%.dll}.xml" + if [ -f "$REDIST_DIR/$xml" ]; then echo "✅ $xml"; else echo "❌ $xml"; missing+=("$xml"); fi done - - if [ ${#MISSING_DLLS[@]} -eq 0 ] && [ ${#MISSING_XML_FILES[@]} -eq 0 ]; then - echo "✅ All required DLL and XML documentation files are present" - else - if [ ${#MISSING_DLLS[@]} -gt 0 ]; then - echo "" - echo "❌ Missing required DLL files:" - for dll_file in "${MISSING_DLLS[@]}"; do - echo " - $dll_file" - done - fi - if [ ${#MISSING_XML_FILES[@]} -gt 0 ]; then - echo "" - echo "❌ Missing XML documentation files:" - for xml_file in "${MISSING_XML_FILES[@]}"; do - echo " - $xml_file" - done - fi - echo "" - echo "All required files must be present for the redistributable." + + if [ ${#missing[@]} -ne 0 ]; then + echo "::error::Missing required files: ${missing[*]}" exit 1 fi + echo "✅ All required files present." - - name: Enable PR automerge + - name: Validate file hashes against manifest.sha256.json + run: | + set -euo pipefail + HASH_FILE="$REDIST_DIR/manifest.sha256.json" + if [ ! -f "$HASH_FILE" ]; then + echo "::error::Missing $HASH_FILE — cannot verify file integrity." + exit 1 + fi + echo "🔒 Verifying SHA-256 of each tracked file against manifest.sha256.json..." + # manifest.sha256.json: { "": "" }. sha256sum -c wants + # " "; run it from inside the redist dir so names resolve. + ( + cd "$REDIST_DIR" + jq -r 'to_entries[] | "\(.value) \(.key)"' manifest.sha256.json | sha256sum -c - + ) + echo "✅ All file hashes match the manifest." + + - name: Validate this is a newer upstream build + run: | + set -euo pipefail + if [ ! -f "$VERSION_FILE" ]; then + echo "::error::Missing $VERSION_FILE." + exit 1 + fi + NEW_VER=$(jq -r '.NuGetVersion' "$VERSION_FILE") + NEW_BUILD=$(jq -r '.BuildId' "$VERSION_FILE") + echo "PR: version=$NEW_VER buildId=$NEW_BUILD ($VERSION_FILE)" + + # Compare against the same file on the base branch (self-contained; no + # network dependency on nuget.org). + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" >/dev/null 2>&1 || true + BASE_JSON=$(git show "origin/${{ github.event.pull_request.base.ref }}:$VERSION_FILE" 2>/dev/null || echo "") + if [ -z "$BASE_JSON" ]; then + echo "No baseline on '${{ github.event.pull_request.base.ref }}' (new file) — skipping the check." + exit 0 + fi + OLD_VER=$(printf '%s' "$BASE_JSON" | jq -r '.NuGetVersion') + OLD_BUILD=$(printf '%s' "$BASE_JSON" | jq -r '.BuildId') + echo "Base: version=$OLD_VER buildId=$OLD_BUILD" + + # Authoritative gate: the Steam build id. Build ids are monotonic per + # app+branch and increase even when a release reverts game content (a + # rollback is a NEW, higher build). A LOWER build id therefore means we + # processed an OLDER upstream build — a real regression — so fail. + # The game VERSION itself can legitimately dip on a preview rollback + # while the build id still rises, so we do NOT hard-fail on version. + # See ARCHITECTURE.md ("Why the build id, not the version"). + if [[ "$NEW_BUILD" =~ ^[0-9]+$ ]] && [[ "$OLD_BUILD" =~ ^[0-9]+$ ]]; then + if [ "$NEW_BUILD" -lt "$OLD_BUILD" ]; then + echo "::error::Build id went backwards ($OLD_BUILD -> $NEW_BUILD): this is an older upstream build than what is already published." + exit 1 + fi + else + echo "::warning::Non-numeric build id ('$OLD_BUILD' -> '$NEW_BUILD'); skipping build-id gate." + fi + + # Informational only: surface a NuGet-version dip (e.g. an upstream + # rollback) without blocking — the build id already confirmed it is the + # newer build. + result=$(python3 .github/scripts/compare_nuget_version.py "$NEW_VER" "$OLD_VER" 2>/dev/null || echo "unknown") + case "$result" in + lt) echo "::warning::NuGet version decreased ($OLD_VER -> $NEW_VER) but the build id advanced — likely an upstream rollback. Allowing (newer build)." ;; + eq) echo "::warning::NuGet version unchanged ($NEW_VER); the publish step will fail on a duplicate version by design." ;; + gt) echo "✅ Newer build and higher version: $OLD_VER ($OLD_BUILD) -> $NEW_VER ($NEW_BUILD)." ;; + *) echo "Build id advanced; version comparison skipped." ;; + esac + + # --- Auto-merge: only for the trusted bot's PRs, when opted in via the + # repo variable. GITHUB_TOKEN approves (so the review shows as the bot + # reviewing); the PAT enables auto-merge (it can merge to a protected base). + - name: Auto-approve PR if: success() && env.ALLOW_AUTO_MERGE_REDIST_PR == 'true' && github.event.pull_request.user.login == 'rocketmodfixadmin' - uses: peter-evans/enable-pull-request-automerge@v3 + uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 # v4 with: - token: ${{ secrets.PAT }} + github-token: ${{ secrets.GITHUB_TOKEN }} pull-request-number: ${{ github.event.pull_request.number }} - merge-method: squash + review-message: "All checks passed (files, hashes, version). Auto-approved automated PR." - - name: Auto-approve PR + - name: Enable PR automerge if: success() && env.ALLOW_AUTO_MERGE_REDIST_PR == 'true' && github.event.pull_request.user.login == 'rocketmodfixadmin' - uses: hmarr/auto-approve-action@v4 + uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.PAT }} pull-request-number: ${{ github.event.pull_request.number }} - review-message: "All checks passed. Auto approved automated PR." + merge-method: squash diff --git a/.gitignore b/.gitignore index 0520a58c..d67e0932 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,9 @@ !/.gitignore !/README.md !/LICENSE -!architecture.jpg \ No newline at end of file +!/ARCHITECTURE.md +!architecture.jpg +!architecture.svg + +# Never commit build artifacts that may land in redist/ during packing +redist/**/*.nupkg \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..bb4a3fab --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,109 @@ +# Architecture + +How the auto-updating redistribution works. Everything runs on GitHub Actions — there are no external servers. + +> The image in the README (`architecture.jpg`, Jul 2025) is a high-level sketch and may lag behind the workflows. **This document is the source of truth.** + +## TL;DR + +A scheduled job polls Steam for new Unturned builds. When a build changes, it downloads the game, copies the managed DLLs into a per-variant folder under `redist/`, and opens a pull request. The PR is validated (files present, hashes match, version not a downgrade) and — if enabled — auto-approved and squash-merged. Merging to `master` triggers a pack-and-push of the affected NuGet package. + +![Pipeline diagram](architecture.svg) + +
+Same flow as a Mermaid diagram (click to expand) + +```mermaid +flowchart TD + cron["schedule: every 15 min
Update.Unturned.Redist.yaml"] --> probe{"DepotDownloader
manifest changed?"} + probe -- no --> done([exit quietly]) + probe -- yes --> dl["SteamCMD downloads game
UnturnedRedistUpdateTool copies/publicizes DLLs
writes version.json + manifest.sha256.json + .commit"] + dl --> pr["create-pull-request
branch: redist-update/<variant>"] + pr --> verify["Verify.Redist.Update.yaml
files + SHA-256 + version monotonicity"] + verify -- pass + opted in + bot author --> merge["auto-approve + squash auto-merge"] + merge --> push["push to master →
RocketModFix.Unturned.Redist.Matrix.yaml
nuget pack + push (changed variant only)"] + merge --> cleanup["Cleanup.RedistBranch.yaml
lock PR + delete merged branch"] +``` + +
+ +## Workflows + +| Workflow | Trigger | Responsibility | +| --- | --- | --- | +| `Update.Unturned.Redist.yaml` | `schedule` (*/15) + `workflow_dispatch` | Poll Steam manifest per variant; on change, download + run the redist tool; open/update one PR per variant. Files an issue if a scheduled run fails. | +| `Verify.Redist.Update.yaml` | `pull_request` to `master` touching `redist/**` | Validate the PR: required files present, SHA-256 hashes match `manifest.sha256.json`, version is not a downgrade. Auto-approve + enable squash auto-merge for the bot's PRs when `ALLOW_AUTO_MERGE_REDIST_PR` is `true`. | +| `RocketModFix.Unturned.Redist.Matrix.yaml` | `push` to `master` touching `redist/redist-*/**` + `workflow_dispatch` | For each variant whose directory changed in the push, `nuget pack` the `.nuspec` and push to nuget.org. | +| `Cleanup.RedistBranch.yaml` | `pull_request: closed` | Lock the PR conversation; delete the branch **only if merged**. | + +External tool: [`RocketModFix/UnturnedRedistUpdateTool`](https://github.com/RocketModFix/UnturnedRedistUpdateTool) (net9). CLI: `dotnet UnturnedRedistUpdateTool.dll [--force] [--preview] [-publicize ] -update-files `. It copies (and optionally publicizes) the requested DLLs into ``, and writes `version.json` (or `version.preview.json` with `--preview`), `manifest.sha256.json`, the `.nuspec` ``, and a one-line `.commit` message. + +## Single source of truth: `.github/variants.json` + +Every variant is defined **once**, in `.github/variants.json`. All three matrix workflows load it via `jq` + `fromJson`. Each entry: + +```json +{ + "variant": "client-preview", + "appId": "304930", "depotId": "304931", + "branch": "preview", + "dir": "redist/redist-client-preview", + "nuspec": "redist/redist-client-preview/RocketModFix.Unturned.Redist.Client.nuspec", + "preview": true, + "publicize": false +} +``` + +- `branch`: `""` = Steam default branch, `"preview"` = Steam `preview` beta branch. +- `preview`: `true` only for the two variants that publish an `-preview` **prerelease** (passes `--preview` to the tool → writes `version.preview.json`). +- `publicize`: `true` for the publicized variants (passes `-publicize Assembly-CSharp.dll`). + +## The 4 sources → 10 directories → 6 packages + +The 10 redist directories pull from only **4 distinct Steam sources** (2 apps × 2 branches) and publish to **6 NuGet package ids**: + +| Variant directory | Steam source (app / branch) | NuGet package id | Version style | +| --- | --- | --- | --- | +| `redist/redist-client` | 304930 / default | `…Redist.Client` | stable `X.Y.Z.N` | +| `redist/redist-client-preview` | 304930 / preview | `…Redist.Client` | prerelease `X.Y.Z.N-preview` | +| `redist/redist-client-preview-old` | 304930 / preview | `…Redist.Client-Preview` *(legacy)* | stable-style `X.Y.Z.N` | +| `redist/redist-client-publicized` | 304930 / default | `…Redist.Client.Publicized` | stable `X.Y.Z.N` | +| `redist/redist-client-preview-publicized` | 304930 / preview | `…Redist.Client.Publicized` | stable-style `X.Y.Z.N` | +| `redist/redist-server` | 1110390 / default | `…Redist.Server` | stable `X.Y.Z.N` | +| `redist/redist-server-preview` | 1110390 / preview | `…Redist.Server` | prerelease `X.Y.Z.N-preview` | +| `redist/redist-server-preview-old` | 1110390 / preview | `…Redist.Server-Preview` *(legacy)* | stable-style `X.Y.Z.N` | +| `redist/redist-server-publicized` | 1110390 / default | `…Redist.Server.Publicized` | stable `X.Y.Z.N` | +| `redist/redist-server-preview-publicized` | 1110390 / preview | `…Redist.Server.Publicized` | stable-style `X.Y.Z.N` | + +Notes: +- The main **`Client` / `Server`** packages receive both a *stable* version (default branch) and a *prerelease* version (preview branch). Consumers who opt into prereleases of `…Redist.Client` get the preview build under the same package id. +- The **`*.Publicized`** packages similarly receive both default-branch and preview-branch builds (the preview one as a higher stable-style version). +- The **`*-Preview`** package ids are *legacy*, fed only by the `*-preview-old` variants. They are kept for **backward compatibility** with consumers that referenced the old standalone preview packages, from before preview builds were folded into the main package ids as prereleases. + +### Legacy artifacts (kept on purpose) + +- **`version.preview.json` inside the stable `redist-client` / `redist-server` directories** is an orphaned tracker left over from an earlier "preview embedded in stable" scheme. It is no longer updated (frozen at an old version) and is intentionally retained; the active preview metadata lives in the `redist-*-preview` directories. +- The **`*-preview-old`** variants and the legacy `*-Preview` package ids are retained for backward compatibility, not removed. + +### Why the build id, not the version (rollback safety) + +`Verify` checks that an update is a **newer upstream build before auto-merging**, gating on the Steam **`BuildId`** (from `version.json`), *not* the game version string. Reason: SDG occasionally rolls back a release — on the stable branch they ship the revert as a higher patch number, but on the **preview branch the game version can dip** while the content is reverted. Steam build ids, however, are monotonic per app+branch and **increase even on a rollback** (a rollback is a new, higher-numbered build). So: + +- **`BuildId` decreased** → we somehow processed an *older* build than what's published → hard fail (a real regression). +- **`BuildId` increased but the NuGet version dipped** → a legitimate upstream rollback → allowed, emitted as a warning (not a block). + +This lets the redist faithfully follow upstream rollbacks instead of getting stuck. + +## How to add a new variant + +1. Create the redist directory and its `.nuspec` under `redist/` (matching the existing layout). +2. Add **one object** to `.github/variants.json` with all fields (`variant`, `appId`, `depotId`, `branch`, `dir`, `nuspec`, `preview`, `publicize`). + +That's it — `Update`, `Matrix`, and `Verify` all derive their matrices from that file. (The `workflow_dispatch` `variant` input is a free-form string validated against `variants.json`, so no dropdown to update.) + +## Conventions + +- **Third-party actions are pinned to full commit SHAs** (with a `# vX` comment) to prevent tag-retargeting supply-chain attacks; Dependabot keeps them current. First-party `actions/*` may use major tags. +- **Secrets** (`STEAM_USERNAME`, `STEAM_PASSWORD`, `NUGET_DEPLOY_KEY`) are passed via step `env:`, never as inline command-line arguments. +- **Failures are not swallowed.** A failed variant turns the run red; scheduled failures open/update a GitHub issue labelled `update-failure`. +- Publishing **fails on a duplicate version by design** (no `--skip-duplicate`) — a repeated version signals an upstream problem. diff --git a/README.md b/README.md index cd145e4e..9d665b15 100755 --- a/README.md +++ b/README.md @@ -62,7 +62,11 @@ dotnet add package RocketModFix.Unturned.Redist.Server All updates and automation are handled entirely through GitHub Actions. We don't run any of this on external servers — everything happens directly on GitHub. -![Architecture](https://raw.githubusercontent.com/RocketModFix/RocketModFix.Unturned.Redist/master/architecture.jpg) +📖 **See [ARCHITECTURE.md](ARCHITECTURE.md)** for the full picture: the workflows, the variant matrix (`.github/variants.json`), how the 10 redist directories map to 6 NuGet packages, and how to add a variant. + +![Architecture](architecture.svg) + +> Diagram source: [`architecture.svg`](architecture.svg) (crisp, version-controlled). `ARCHITECTURE.md` has the details and a Mermaid version. --- diff --git a/architecture.svg b/architecture.svg new file mode 100644 index 00000000..05fbec19 --- /dev/null +++ b/architecture.svg @@ -0,0 +1,122 @@ + + + + + + + + + + + + RocketModFix.Unturned.Redist — automated update pipeline + Everything runs on GitHub Actions. No external servers. To add a variant, edit one file: .github/variants.json + + + + .github/variants.json + Single source of truth + 10 variants → 6 packages + appId · depotId · branch + dir · preview · publicize + consumed via jq + fromJson + + + + + matrix + + + + STEAM · Valve + Unturned (304930) · Dedicated Server (1110390) — default + preview branches + + + poll manifest every 15 min + + + + ① Update.Unturned.Redist.yaml · schedule */15 + dispatch + + + Probe Steam manifest with DepotDownloader (per variant) + + + manifest changed? + + + SteamCMD downloads game → UnturnedRedistUpdateTool + copies/publicizes DLLs · writes version + manifest + .commit + + + create-pull-request → fixed branch + redist-update/<variant> (one rolling PR per variant) + + + + + no change → exit + + + PR opened / updated + + + + ② Verify.Redist.Update.yaml · on pull_request + + Validate: files ✓ · SHA-256 hashes ✓ · version not a downgrade ✓ + + Auto-approve + squash auto-merge (bot PRs, when enabled) + + + + + + merge → master + + + + + + ③ Matrix.yaml (pack & push) · on push to master + + Detect changed variant (native git diff) + nuget pack + push (only the changed package) + + + + + + nuget.org + 6 packages · Client / Server × stable · prerelease · publicized + + + + ④ Cleanup.RedistBranch + on PR closed: + lock PR conversation + delete merged branch + + on close + + + + on failure + open / update tracking issue + + + + Blue = GitHub Actions workflow · Green = success / source of truth · Amber = decision · Red = failure · Dashed = feeds / side-effect +