diff --git a/.github/workflows/backfill-release.yml b/.github/workflows/backfill-release.yml index 2448ec8..9220adb 100644 --- a/.github/workflows/backfill-release.yml +++ b/.github/workflows/backfill-release.yml @@ -153,6 +153,21 @@ jobs: exit 1 fi + - name: Render CurseForge changelog + if: ${{ inputs.publish_curseforge }} + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + jq -Rs '{ text: ., mode: "gfm" }' release-notes.md \ + | gh api --method POST markdown --input - \ + > curseforge-release-notes.html + + if [[ ! -s curseforge-release-notes.html ]]; then + echo "Rendered CurseForge changelog is empty" >&2 + exit 1 + fi + - name: Publish existing release to Modrinth if: ${{ inputs.publish_modrinth }} uses: Kira-NT/mc-publish@v3.3.1 @@ -176,6 +191,7 @@ jobs: - name: Publish existing release to CurseForge if: ${{ inputs.publish_curseforge }} + id: publish_curseforge uses: Kira-NT/mc-publish@v3.3.1 with: curseforge-id: ${{ vars.CURSEFORGE_PROJECT_ID }} @@ -194,3 +210,34 @@ jobs: retry-attempts: 3 retry-delay: 15000 fail-mode: fail + + - name: Apply formatted CurseForge changelog + if: ${{ inputs.publish_curseforge }} + env: + CURSEFORGE_PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + CURSEFORGE_FILE_ID: ${{ steps.publish_curseforge.outputs.curseforge-version }} + shell: bash + run: | + if [[ ! "$CURSEFORGE_FILE_ID" =~ ^[0-9]+$ ]]; then + echo "Publisher did not return a valid CurseForge file ID: $CURSEFORGE_FILE_ID" >&2 + exit 1 + fi + + jq -cn \ + --argjson fileID "$CURSEFORGE_FILE_ID" \ + --rawfile changelog curseforge-release-notes.html \ + '{ fileID: $fileID, changelog: $changelog, changelogType: "html" }' \ + > curseforge-update.json + + response="$(curl --fail-with-body --silent --show-error \ + --request POST \ + --header "X-Api-Token: $CURSEFORGE_TOKEN" \ + --form "metadata=&2 + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4028623..c841a89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,6 +141,20 @@ jobs: exit 1 fi + - name: Render CurseForge changelog + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + jq -Rs '{ text: ., mode: "gfm" }' release-notes.md \ + | gh api --method POST markdown --input - \ + > curseforge-release-notes.html + + if [[ ! -s curseforge-release-notes.html ]]; then + echo "Rendered CurseForge changelog is empty" >&2 + exit 1 + fi + - name: Prepare release artifact id: artifact env: @@ -158,6 +172,7 @@ jobs: echo "checksum=$artifact.sha256" >> "$GITHUB_OUTPUT" - name: Publish to GitHub, Modrinth, and CurseForge + id: publish uses: Kira-NT/mc-publish@v3.3.1 with: modrinth-id: ${{ vars.MODRINTH_PROJECT_ID }} @@ -192,3 +207,33 @@ jobs: retry-attempts: 3 retry-delay: 15000 fail-mode: fail + + - name: Apply formatted CurseForge changelog + env: + CURSEFORGE_PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + CURSEFORGE_FILE_ID: ${{ steps.publish.outputs.curseforge-version }} + shell: bash + run: | + if [[ ! "$CURSEFORGE_FILE_ID" =~ ^[0-9]+$ ]]; then + echo "Publisher did not return a valid CurseForge file ID: $CURSEFORGE_FILE_ID" >&2 + exit 1 + fi + + jq -cn \ + --argjson fileID "$CURSEFORGE_FILE_ID" \ + --rawfile changelog curseforge-release-notes.html \ + '{ fileID: $fileID, changelog: $changelog, changelogType: "html" }' \ + > curseforge-update.json + + response="$(curl --fail-with-body --silent --show-error \ + --request POST \ + --header "X-Api-Token: $CURSEFORGE_TOKEN" \ + --form "metadata=&2 + exit 1 + fi diff --git a/.github/workflows/repair-curseforge-changelog.yml b/.github/workflows/repair-curseforge-changelog.yml new file mode 100644 index 0000000..982f57b --- /dev/null +++ b/.github/workflows/repair-curseforge-changelog.yml @@ -0,0 +1,117 @@ +name: Repair CurseForge Changelog + +on: + workflow_dispatch: + inputs: + version: + description: 'Version whose changelog should be repaired, without the v prefix' + required: true + type: string + curseforge_file_id: + description: 'Numeric CurseForge file ID from the file page URL' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: repair-curseforge-changelog-${{ inputs.curseforge_file_id }} + cancel-in-progress: false + +jobs: + repair: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Validate request + id: version + env: + VERSION: ${{ inputs.version }} + CURSEFORGE_FILE_ID: ${{ inputs.curseforge_file_id }} + CURSEFORGE_PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + shell: bash + run: | + if [[ -z "$VERSION" ]]; then + echo "A version is required" >&2 + exit 1 + fi + + if [[ ! "$CURSEFORGE_FILE_ID" =~ ^[0-9]+$ ]]; then + echo "CurseForge file ID must be numeric" >&2 + exit 1 + fi + + [[ -n "$CURSEFORGE_PROJECT_ID" ]] || { echo "CURSEFORGE_PROJECT_ID is missing" >&2; exit 1; } + [[ -n "$CURSEFORGE_TOKEN" ]] || { echo "CURSEFORGE_TOKEN is missing" >&2; exit 1; } + + tag="v$VERSION" + if ! git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then + echo "Git tag $tag does not exist" >&2 + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Extract release notes from the tagged source + env: + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ inputs.version }} + shell: bash + run: | + git show "$TAG:CHANGELOG.md" > tagged-changelog.md + + awk -v version="$VERSION" ' + index($0, "## " version " - ") == 1 { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' tagged-changelog.md > release-notes.md + + if [[ ! -s release-notes.md ]]; then + echo "Release notes for $VERSION are missing or empty in $TAG" >&2 + exit 1 + fi + + - name: Render changelog as HTML + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + jq -Rs '{ text: ., mode: "gfm" }' release-notes.md \ + | gh api --method POST markdown --input - \ + > curseforge-release-notes.html + + if [[ ! -s curseforge-release-notes.html ]]; then + echo "Rendered CurseForge changelog is empty" >&2 + exit 1 + fi + + - name: Update CurseForge changelog + env: + CURSEFORGE_PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + CURSEFORGE_FILE_ID: ${{ inputs.curseforge_file_id }} + shell: bash + run: | + jq -cn \ + --argjson fileID "$CURSEFORGE_FILE_ID" \ + --rawfile changelog curseforge-release-notes.html \ + '{ fileID: $fileID, changelog: $changelog, changelogType: "html" }' \ + > curseforge-update.json + + response="$(curl --fail-with-body --silent --show-error \ + --request POST \ + --header "X-Api-Token: $CURSEFORGE_TOKEN" \ + --form "metadata=&2 + exit 1 + fi diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f7d2670..800f7df 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -44,6 +44,7 @@ The backfill workflow: - verifies the GitHub checksum when a checksum asset exists - reads release notes from the tagged version of `CHANGELOG.md` - publishes independently to Modrinth and CurseForge +- renders the CurseForge changelog as HTML after upload so headings, lists, and inline code remain intact - never creates or modifies a GitHub release If one destination succeeds and the other fails, rerun the workflow with only the failed destination enabled. This avoids attempting to create a duplicate version on the successful platform. @@ -89,6 +90,7 @@ When all checks pass, the workflow publishes: - `retold-.jar` to Modrinth and CurseForge - the same JAR plus its `.sha256` checksum to GitHub Releases - the matching `CHANGELOG.md` section as the changelog on every platform +- an HTML-rendered copy of those notes on CurseForge to preserve headings, lists, and inline code - NeoForge, Minecraft 26.2, Java 25, and client-and-server metadata Versions containing `-alpha` or `-snapshot` publish as alpha. Versions containing `-beta` or `-rc` publish as beta. Other versions publish as releases. @@ -98,3 +100,17 @@ Versions containing `-alpha` or `-snapshot` publish as alpha. Versions containin The workflow can also be started from **Actions → Release → Run workflow**. Enter the version without the `v` prefix. The entered version must still match `mod_version`, and every normal validation and publishing step still runs. Use manual publishing only when the release tag workflow did not start. If a run partially publishes, first inspect GitHub, Modrinth, and CurseForge. Either upload the missing platform manually, or delete every version and GitHub release created by the failed run before retrying. The existing Git tag can remain in place. + +## Repair A CurseForge Changelog + +Use **Actions → Repair CurseForge Changelog → Run workflow** when a CurseForge file already exists but its changelog was flattened or otherwise formatted incorrectly. + +1. Open the affected file in the CurseForge author dashboard. +2. Copy the numeric file ID from the file page URL. +3. Open **Actions → Repair CurseForge Changelog → Run workflow** in GitHub. +4. Enter the version without the `v` prefix and the numeric CurseForge file ID. +5. Run the workflow. + +The repair workflow reads the matching release section from the tagged `CHANGELOG.md`, renders it to HTML with GitHub's Markdown renderer, and updates only that existing CurseForge file. It does not upload another JAR or modify GitHub or Modrinth. + +New normal and backfilled releases perform this HTML update automatically after the CurseForge upload.