├── .gitattributes
├── settings.gradle.kts
├── docs
├── pages
│ ├── _meta.json
│ ├── shared_options.mdx
│ ├── github_actions.mdx
│ ├── options.mdx
│ ├── getting_started.mdx
│ ├── multi_platform.mdx
│ ├── index.mdx
│ └── platforms
│ │ ├── github.mdx
│ │ ├── curseforge.mdx
│ │ ├── gitea.mdx
│ │ ├── forgejo.mdx
│ │ ├── modrinth.mdx
│ │ └── discord.mdx
├── public
│ └── images
│ │ ├── discord_button.png
│ │ ├── discord_modern.png
│ │ ├── discord_example.png
│ │ └── discord_modern_button_thumbnail.png
├── next-env.d.ts
├── .gitignore
├── next.config.js
├── package.json
├── tsconfig.json
└── theme.config.tsx
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── .gitignore
├── src
├── main
│ └── kotlin
│ │ └── me
│ │ └── modmuss50
│ │ └── mpp
│ │ ├── Validators.kt
│ │ ├── ReleaseType.kt
│ │ ├── MinecraftApi.kt
│ │ ├── platforms
│ │ ├── curseforge
│ │ │ ├── CurseforgeVersions.kt
│ │ │ ├── CurseforgeApi.kt
│ │ │ └── Curseforge.kt
│ │ ├── gitea
│ │ │ ├── GiteaApi.kt
│ │ │ └── Gitea.kt
│ │ ├── discord
│ │ │ └── DiscordAPI.kt
│ │ ├── modrinth
│ │ │ └── ModrinthApi.kt
│ │ └── github
│ │ │ └── Github.kt
│ │ ├── MppPlugin.kt
│ │ ├── PublishOptions.kt
│ │ ├── Platform.kt
│ │ ├── PlatformInternal.kt
│ │ ├── HttpUtils.kt
│ │ ├── PublishModTask.kt
│ │ └── ModPublishExtension.kt
└── test
│ ├── kotlin
│ └── me
│ │ └── modmuss50
│ │ └── mpp
│ │ └── test
│ │ ├── MockWebServer.kt
│ │ ├── misc
│ │ ├── MockMinecraftApi.kt
│ │ ├── PublishResultTest.kt
│ │ ├── MinecraftApiTest.kt
│ │ ├── OptionsTest.kt
│ │ └── MultiPlatformTest.kt
│ │ ├── discord
│ │ ├── MockDiscordApi.kt
│ │ ├── BotTokenGenerator.kt
│ │ └── DiscordIntegrationTest.kt
│ │ ├── curseforge
│ │ ├── CurseforgeVersionsTest.kt
│ │ └── MockCurseforgeApi.kt
│ │ ├── gitea
│ │ ├── MockGiteaApi.kt
│ │ └── GiteaTest.kt
│ │ ├── github
│ │ ├── MockGithubApi.kt
│ │ └── GithubTest.kt
│ │ ├── ProductionTest.kt
│ │ ├── modrinth
│ │ └── MockModrinthApi.kt
│ │ └── IntegrationTest.kt
│ └── resources
│ ├── curseforge_version_types.json
│ └── version_manifest_v2.json
├── .github
└── workflows
│ ├── publish.yml
│ ├── docs.yml
│ └── test.yml
├── LICENSE
├── README.md
├── gradlew.bat
└── gradlew
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | *.bat text eol=crlf
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "mod-publish-plugin"
--------------------------------------------------------------------------------
/docs/pages/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "index": "Home",
3 | "getting_started": "Getting Started",
4 | "platforms": "Platforms"
5 | }
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/docs/public/images/discord_button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/HEAD/docs/public/images/discord_button.png
--------------------------------------------------------------------------------
/docs/public/images/discord_modern.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/HEAD/docs/public/images/discord_modern.png
--------------------------------------------------------------------------------
/docs/public/images/discord_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/HEAD/docs/public/images/discord_example.png
--------------------------------------------------------------------------------
/docs/public/images/discord_modern_button_thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/HEAD/docs/public/images/discord_modern_button_thumbnail.png
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.parallel=true
2 | kotlin.stdlib.default.dependency = false
3 | org.gradle.kotlin.dsl.skipMetadataVersionCheck=false
4 | org.gradle.configuration-cache=true
--------------------------------------------------------------------------------
/docs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore everything
2 | /*
3 |
4 | # Folders
5 | !/pages
6 | !/public
7 |
8 | # Files
9 | !/.gitignore
10 | !/next.config.js
11 | !/next-env.d.ts
12 | !/package.json
13 | !/package-lock.json
14 | !/theme.config.tsx
15 | !/tsconfig.json
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore everything
2 | /*
3 |
4 | # Folders
5 | !/gradle
6 | !/src
7 | !/.github
8 | !/docs
9 |
10 | # Files
11 | !/.gitattributes
12 | !/.gitignore
13 | !/build.gradle.kts
14 | !/gradle.properties
15 | !/gradlew
16 | !/gradlew.bat
17 | !/settings.gradle.kts
18 | !/README.md
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/Validators.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import org.gradle.api.provider.ListProperty
4 |
5 | object Validators {
6 | fun validateUnique(name: String, listProp: ListProperty) {
7 | val duplicates = listProp.get().groupingBy { it }.eachCount().filter { it.value > 1 }.keys
8 | if (duplicates.isNotEmpty()) {
9 | throw IllegalArgumentException("$name contains duplicate values: $duplicates")
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/docs/next.config.js:
--------------------------------------------------------------------------------
1 | const withNextra = require("nextra")({
2 | theme: "nextra-theme-docs",
3 | themeConfig: "./theme.config.tsx",
4 | })
5 |
6 | /**
7 | * @type {import('next').NextConfig}
8 | */
9 | const nextConfig = {
10 | output: "export",
11 | distDir: "build",
12 | trailingSlash: true,
13 | rewrites: undefined,
14 | images: {
15 | unoptimized: true,
16 | },
17 | basePath: "/mod-publish-plugin"
18 | }
19 |
20 | module.exports = {
21 | ...withNextra(),
22 | ...nextConfig,
23 | }
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mpp-docs",
3 | "version": "0.0.1",
4 | "description": "Mod Publish Plugin Docs",
5 | "private": true,
6 | "scripts": {
7 | "dev": "next dev",
8 | "build": "next build"
9 | },
10 | "dependencies": {
11 | "next": "13.4.12",
12 | "nextra": "2.10.0",
13 | "nextra-theme-docs": "2.10.0",
14 | "react": "18.2.0",
15 | "react-dom": "18.2.0"
16 | },
17 | "devDependencies": {
18 | "@types/node": "20.4.5",
19 | "typescript": "5.1.6"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on: [workflow_dispatch] # Manual trigger
3 |
4 | jobs:
5 | publish:
6 | name: Publish
7 | runs-on: ubuntu-24.04
8 | environment: Production
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: actions/setup-java@v4
12 | with:
13 | distribution: temurin
14 | java-version: 21
15 | - name: Publish
16 | env:
17 | GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
18 | GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}
19 | run: ./gradlew publishPlugins
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "incremental": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve"
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/ReleaseType.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import java.lang.IllegalArgumentException
4 |
5 | enum class ReleaseType {
6 | STABLE,
7 | BETA,
8 | ALPHA, ;
9 |
10 | companion object {
11 | @JvmStatic
12 | fun of(value: String): ReleaseType {
13 | val upper = value.uppercase()
14 | try {
15 | return ReleaseType.valueOf(upper)
16 | } catch (e: IllegalArgumentException) {
17 | throw IllegalArgumentException("Invalid release type: $upper. Must be one of: STABLE, BETA, ALPHA")
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docs/theme.config.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { DocsThemeConfig } from 'nextra-theme-docs'
3 |
4 | const config: DocsThemeConfig = {
5 | logo: Mod Publish Plugin,
6 | project: {
7 | link: 'https://github.com/modmuss50/mod-publish-plugin',
8 | },
9 | chat: {
10 | link: 'https://discord.gg/teamreborn',
11 | },
12 | docsRepositoryBase: 'https://github.com/modmuss50/mod-publish-plugin/tree/main/docs',
13 | footer: {
14 | text: (
15 |
16 | Built with Nextra
17 |
18 | ),
19 | },
20 | useNextSeoProps() {
21 | return {
22 | titleTemplate: '%s – Mod Publish Plugin'
23 | }
24 | },
25 | navigation: {
26 | prev: true,
27 | next: true,
28 | }
29 | }
30 |
31 | export default config
32 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/MockWebServer.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test
2 |
3 | import io.javalin.Javalin
4 | import io.javalin.apibuilder.EndpointGroup
5 |
6 | class MockWebServer(val api: T) : AutoCloseable {
7 | private val server: Javalin = Javalin.create { config ->
8 | config.router.apiBuilder(api.routes())
9 | }.start(9082)
10 |
11 | val endpoint: String
12 | get() = "http://localhost:9082"
13 |
14 | override fun close() {
15 | server.stop()
16 | }
17 |
18 | interface MockApi {
19 | fun routes(): EndpointGroup
20 | }
21 |
22 | class CombinedApi(val apis: List) : MockApi {
23 | override fun routes(): EndpointGroup {
24 | return EndpointGroup {
25 | for (api in apis) {
26 | api.routes().addEndpoints()
27 | }
28 | }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Deploy docs Pages
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | workflow_dispatch: # Manual run
7 |
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 |
13 | concurrency:
14 | group: "pages"
15 | cancel-in-progress: false
16 |
17 | jobs:
18 | deploy:
19 | environment:
20 | name: github-pages
21 | url: ${{ steps.deployment.outputs.page_url }}
22 | runs-on: ubuntu-24.04
23 | steps:
24 | - name: Checkout
25 | uses: actions/checkout@v4
26 | with:
27 | fetch-depth: 0
28 | - run: npm i
29 | working-directory: ./docs
30 | - run: npm run build
31 | working-directory: ./docs
32 | - name: Setup Pages
33 | uses: actions/configure-pages@v5
34 | - name: Upload artifact
35 | uses: actions/upload-pages-artifact@v3
36 | with:
37 | path: './docs/build'
38 | - name: Deploy to GitHub Pages
39 | id: deployment
40 | uses: actions/deploy-pages@v4
41 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | test:
6 | strategy:
7 | fail-fast: false
8 | matrix:
9 | os: [ ubuntu-24.04, macos-14, windows-2022 ]
10 | java: [ 17, 21 ]
11 | gradle: [ "8.10", nightly ]
12 | runs-on: ${{ matrix.os }}
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: gradle/actions/wrapper-validation@v4
16 | - uses: actions/setup-java@v4
17 | with:
18 | distribution: temurin
19 | java-version: ${{ matrix.java }}
20 | - uses: gradle/actions/setup-gradle@v3
21 | with:
22 | cache-disabled: true
23 | gradle-version: ${{ matrix.gradle }}
24 | - name: Build
25 | run: gradle build --stacktrace --warning-mode fail
26 |
27 | docs:
28 | runs-on: ubuntu-24.04
29 | steps:
30 | - uses: actions/checkout@v4
31 | with:
32 | fetch-depth: 0
33 | - run: npm i
34 | working-directory: ./docs
35 | - run: npm run build
36 | working-directory: ./docs
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/misc/MockMinecraftApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.misc
2 |
3 | import io.javalin.apibuilder.ApiBuilder.get
4 | import io.javalin.apibuilder.ApiBuilder.path
5 | import io.javalin.apibuilder.EndpointGroup
6 | import io.javalin.http.Context
7 | import me.modmuss50.mpp.test.MockWebServer
8 | import java.io.BufferedReader
9 |
10 | class MockMinecraftApi : MockWebServer.MockApi {
11 | override fun routes(): EndpointGroup {
12 | return EndpointGroup {
13 | path("mc/game/version_manifest_v2.json") {
14 | get(this::versions)
15 | }
16 | }
17 | }
18 |
19 | private fun versions(context: Context) {
20 | val versions = readResource("version_manifest_v2.json")
21 | context.result(versions)
22 | }
23 |
24 | private fun readResource(path: String): String {
25 | this::class.java.classLoader!!.getResourceAsStream(path).use { inputStream ->
26 | BufferedReader(inputStream!!.reader()).use { reader ->
27 | return reader.readText()
28 | }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 modmuss
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docs/pages/shared_options.mdx:
--------------------------------------------------------------------------------
1 | If you wish to create multiple releases to the same platform you can create shared options. The following example shows how to use `curseforgeOptions` for a forge and fabric release.
2 |
3 | ```groovy
4 | publishMods {
5 | changelog = file("changelog.md").text
6 |
7 | def options = curseforgeOptions {
8 | accessToken = providers.environmentVariable("CURSEFORGE_TOKEN")
9 | minecraftVersions.add("1.20.1")
10 | }
11 |
12 | curseforge("curseforgeFabric") {
13 | from options
14 | projectId = "123456"
15 | type = STABLE
16 | file = fabricJar.archiveFile
17 | }
18 |
19 | curseforge("curseforgeForge") {
20 | from options
21 | projectId = "7890123"
22 | type = BETA
23 | file = forgeJar.archiveFile
24 | }
25 | }
26 | ```
27 |
28 | The following example shows how the inheritance works, any option can be overridden without affecting the parent.
29 |
30 | ```groovy
31 | publishMods {
32 | changelog = "Changelog 1"
33 |
34 | def options = curseforgeOptions {
35 | // Automatically inherits properties from the parent block
36 |
37 | changelog = "Changelog 2" // Overrides changelog 1
38 | }
39 |
40 | curseforge {
41 | from options // Inherit properties from `options`
42 |
43 | changelog = "Changelog 3" // Overrides changelog 2
44 | }
45 | }
46 | ```
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/discord/MockDiscordApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.discord
2 |
3 | import io.javalin.apibuilder.ApiBuilder.get
4 | import io.javalin.apibuilder.ApiBuilder.path
5 | import io.javalin.apibuilder.ApiBuilder.post
6 | import io.javalin.apibuilder.EndpointGroup
7 | import io.javalin.http.Context
8 | import kotlinx.serialization.json.Json
9 | import me.modmuss50.mpp.platforms.discord.DiscordAPI
10 | import me.modmuss50.mpp.test.MockWebServer
11 |
12 | class MockDiscordApi : MockWebServer.MockApi {
13 | private val json = Json { classDiscriminator = "class"; encodeDefaults = true }
14 | var requests = arrayListOf()
15 | var requestedKeys = arrayListOf()
16 |
17 | override fun routes(): EndpointGroup {
18 | return EndpointGroup {
19 | path("api/webhooks/{key}/{token}") {
20 | post(this::postWebhook)
21 | get(this::getWebhook)
22 | }
23 | }
24 | }
25 |
26 | private fun postWebhook(context: Context) {
27 | requests.add(json.decodeFromString(context.body()))
28 | requestedKeys.add(context.pathParam("key"))
29 | context.result("") // Just returns an empty string
30 | }
31 |
32 | private fun getWebhook(context: Context) {
33 | // Just returns a simple response so the component check passes
34 | context.result("{\"application_id\": \"0\"}")
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/test/resources/curseforge_version_types.json:
--------------------------------------------------------------------------------
1 | [{"id":2,"name":"Java","slug":"java"},{"id":4,"name":"Minecraft 1.8","slug":"minecraft-1-8"},{"id":5,"name":"Minecraft 1.7","slug":"minecraft-1-7"},{"id":6,"name":"Minecraft 1.6","slug":"minecraft-1-6"},{"id":11,"name":"Minecraft 1.5","slug":"minecraft-1-5"},{"id":12,"name":"Minecraft 1.4","slug":"minecraft-1-4"},{"id":13,"name":"Minecraft 1.3","slug":"minecraft-1-3"},{"id":14,"name":"Minecraft 1.2","slug":"minecraft-1-2"},{"id":15,"name":"Minecraft 1.1","slug":"minecraft-1-1"},{"id":16,"name":"Minecraft 1.0","slug":"minecraft-1-0"},{"id":17,"name":"Minecraft Beta","slug":"minecraft-beta"},{"id":552,"name":"Minecraft 1.9","slug":"minecraft-1-9"},{"id":572,"name":"Minecraft 1.10","slug":"minecraft-1-10"},{"id":599,"name":"Minecraft 1.11","slug":"minecraft-1-11"},{"id":615,"name":"Addons","slug":"addons"},{"id":628,"name":"Minecraft 1.12","slug":"minecraft-1-12"},{"id":55023,"name":"Minecraft 1.13","slug":"minecraft-1-13"},{"id":64806,"name":"Minecraft 1.14","slug":"minecraft-1-14"},{"id":68441,"name":"Modloader","slug":"modloader"},{"id":68722,"name":"Minecraft 1.15","slug":"minecraft-1-15"},{"id":70886,"name":"Minecraft 1.16","slug":"minecraft-1-16"},{"id":73242,"name":"Minecraft 1.17","slug":"minecraft-1-17"},{"id":73250,"name":"Minecraft 1.18","slug":"minecraft-1-18"},{"id":73407,"name":"Minecraft 1.19","slug":"minecraft-1-19"},{"id":75125,"name":"Minecraft 1.20","slug":"minecraft-1-20"},{"id":75208,"name":"Environment","slug":"environment"}]
--------------------------------------------------------------------------------
/docs/pages/github_actions.mdx:
--------------------------------------------------------------------------------
1 | You can automate the building and publishing of your mod using GitHub Actions.
2 | This is a free service provided by GitHub that allows you to run scripts in response to events like pushing to a repository.
3 |
4 | To use GitHub Actions, you need to create a `.github/workflows/release.yml` file in your repository.
5 | ```yml
6 | name: Release
7 | on: [workflow_dispatch] # Manual trigger
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-22.04
15 | container:
16 | image: mcr.microsoft.com/openjdk/jdk:21-ubuntu
17 | options: --user root
18 | steps:
19 | - uses: actions/checkout@v4
20 | - run: ./gradlew build publishMods
21 | env:
22 | CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24 | MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }}
25 | ```
26 | The above example is a simple workflow that will build and publish your mod when manually triggered via the GitHub web interface.
27 | You must set the following secrets in your repository settings:
28 | - `CURSEFORGE_API_KEY`: Your CurseForge API key. You can get this from your CurseForge account settings [here](https://legacy.curseforge.com/account/api-tokens).
29 | - `MODRINTH_TOKEN`: Your Modrinth API key. You can get this from your Modrinth account settings [here](https://modrinth.com/settings/pats).
30 |
31 | The `GITHUB_TOKEN` is automatically created by GitHub actions. If you arent publishing to any of these platforms you can omit the corresponding environment variable.
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/MinecraftApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | class MinecraftApi(private val baseUrl: String = "https://piston-meta.mojang.com/") {
6 | private val httpUtils = HttpUtils()
7 |
8 | @Serializable
9 | data class Version(
10 | val id: String,
11 | val type: String,
12 | val url: String,
13 | val time: String,
14 | val releaseTime: String,
15 | )
16 |
17 | @Serializable
18 | data class LauncherMeta(
19 | val versions: List,
20 | )
21 |
22 | private val headers: Map
23 | get() = mapOf()
24 |
25 | fun getVersions(): List {
26 | return httpUtils.get("$baseUrl/mc/game/version_manifest_v2.json", headers).versions
27 | }
28 |
29 | fun getVersionsInRange(startId: String, endId: String, includeSnapshots: Boolean = false): List {
30 | val versions = getVersions()
31 | .filter { it.type == "release" || includeSnapshots }
32 | .map { it.id }
33 | .reversed()
34 |
35 | val startIndex = versions.indexOf(startId)
36 | val endIndex = if (endId == "latest") versions.size - 1 else versions.indexOf(endId)
37 |
38 | if (startIndex == -1) throw IllegalArgumentException("Invalid start version $startId")
39 | if (endIndex == -1) throw IllegalArgumentException("Invalid end version $endId")
40 | if (startIndex > endIndex) throw IllegalArgumentException("Start version $startId must be before end version $endId")
41 |
42 | return versions.subList(startIndex, endIndex + 1)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/discord/BotTokenGenerator.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.discord
2 |
3 | import kotlinx.serialization.Serializable
4 | import kotlinx.serialization.encodeToString
5 | import kotlinx.serialization.json.Json
6 | import me.modmuss50.mpp.HttpUtils
7 | import okhttp3.RequestBody.Companion.toRequestBody
8 | import java.io.File
9 |
10 | val json = Json { ignoreUnknownKeys = true }
11 | val httpUtils = HttpUtils()
12 |
13 | /*
14 | Use this to generate a bot created webhook URL for testing the discord support
15 | Get a bot token from https://discord.com/developers/applications and set it in options.json
16 | Run this with the channel id as the first argument
17 | */
18 | fun main(args: Array) {
19 | if (args.size != 1) {
20 | println("Usage: BotTokenGenerator ")
21 | return
22 | }
23 |
24 | val options = json.decodeFromString(File("options.json").readText())
25 |
26 | val channelId = args[0]
27 | val headers: Map = mapOf(
28 | "Content-Type" to "application/json",
29 | "Authorization" to "Bot ${options.discordBotToken}",
30 | )
31 |
32 | val request = CreateWebhookRequest("test")
33 | val body = json.encodeToString(request).toRequestBody()
34 | val response = httpUtils.post("https://discord.com/api/v9/channels/$channelId/webhooks", body, headers)
35 |
36 | println(response)
37 | }
38 |
39 | @Serializable
40 | data class CreateWebhookRequest(
41 | val name: String,
42 | )
43 |
44 | @Serializable
45 | data class CreateWebhookResponse(
46 | val url: String,
47 | )
48 |
49 | @Serializable
50 | data class Options(
51 | val discordBotToken: String,
52 | )
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mod Publish Plugin
2 | A modern Gradle plugin to publish mods to a range of destinations.
3 |
4 | Please make sure to report all issues, and any suggestions on this Github repo!
5 |
6 | ## Basic usage
7 | Visit the [docs site](https://modmuss50.github.io/mod-publish-plugin/) for more detailed instructions.
8 |
9 | Add to your gradle plugins block:
10 |
11 | ```gradle
12 | plugins {
13 | id "me.modmuss50.mod-publish-plugin" version "1.1.0"
14 | }
15 | ```
16 |
17 | It is recommended to specify an exact version number to prevent unwanted breakages to your build script.
18 |
19 | Basic example to publish a jar to CurseForge, Modrinth and Github from a Fabric project:
20 | ```gradle
21 | publishMods {
22 | file = remapJar.archiveFile
23 | changelog = "Hello!"
24 | type = STABLE
25 | modLoaders.add("fabric")
26 |
27 | curseforge {
28 | projectId = "123456"
29 | projectSlug = "example-project" // Required for discord webhook
30 | accessToken = providers.environmentVariable("CURSEFORGE_TOKEN")
31 | minecraftVersions.add("1.20.1")
32 | requires("fabric-api")
33 | }
34 | modrinth {
35 | projectId = "abcdef"
36 | accessToken = providers.environmentVariable("MODRINTH_TOKEN")
37 | minecraftVersions.add("1.20.1")
38 | }
39 | github {
40 | repository = "test/example"
41 | accessToken = providers.environmentVariable("GITHUB_TOKEN")
42 | commitish = "main"
43 | }
44 | discord {
45 | webhookUrl = providers.environmentVariable("DISCORD_WEBHOOK")
46 | }
47 | }
48 | ```
49 |
50 | Run the `publishMods` task to publish to all configured destinations.
51 |
52 | Visit the [docs site](https://modmuss50.github.io/mod-publish-plugin/) for more detailed instructions.
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/platforms/curseforge/CurseforgeVersions.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.platforms.curseforge
2 |
3 | import org.gradle.api.JavaVersion
4 |
5 | class CurseforgeVersions(
6 | private val versionTypes: List,
7 | private val versions: List,
8 | ) {
9 |
10 | private fun getGameVersionTypes(name: String): List {
11 | val versions = if (name == "minecraft") {
12 | versionTypes.filter { it.slug.startsWith("minecraft") }
13 | } else {
14 | versionTypes.filter { it.slug == name }
15 | }.map { it.id }
16 |
17 | if (versions.isEmpty()) {
18 | throw IllegalStateException("Failed to find version type: $name")
19 | }
20 |
21 | return versions
22 | }
23 |
24 | private fun getVersion(name: String, type: String): Int {
25 | val versionTypes = getGameVersionTypes(type)
26 | val version = versions.find { versionTypes.contains(it.gameVersionTypeID) && it.name.equals(name, ignoreCase = true) } ?: throw IllegalStateException("Failed to find version: $name")
27 | return version.id
28 | }
29 |
30 | fun getMinecraftVersion(name: String): Int {
31 | return getVersion(name, "minecraft")
32 | }
33 |
34 | fun getModLoaderVersion(name: String): Int {
35 | return getVersion(name, "modloader")
36 | }
37 |
38 | fun getClientVersion(): Int {
39 | return getVersion("client", "environment")
40 | }
41 |
42 | fun getServerVersion(): Int {
43 | return getVersion("server", "environment")
44 | }
45 |
46 | fun getJavaVersion(version: JavaVersion): Int {
47 | return getVersion("Java ${version.ordinal + 1}", "java")
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/MppPlugin.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import me.modmuss50.mpp.platforms.curseforge.Curseforge
4 | import me.modmuss50.mpp.platforms.gitea.Gitea
5 | import me.modmuss50.mpp.platforms.github.Github
6 | import me.modmuss50.mpp.platforms.modrinth.Modrinth
7 | import org.gradle.api.Plugin
8 | import org.gradle.api.Project
9 | import org.gradle.api.reflect.TypeOf
10 | import org.gradle.api.tasks.TaskProvider
11 |
12 | @Suppress("unused")
13 | class MppPlugin : Plugin {
14 | override fun apply(project: Project) {
15 | val extension = project.extensions.create(TypeOf.typeOf(ModPublishExtension::class.java), "publishMods", ModPublishExtension::class.java, project)
16 |
17 | extension.platforms.registerFactory(Curseforge::class.java) {
18 | project.objects.newInstance(Curseforge::class.java, it)
19 | }
20 | extension.platforms.registerFactory(Github::class.java) {
21 | project.objects.newInstance(Github::class.java, it)
22 | }
23 | extension.platforms.registerFactory(Modrinth::class.java) {
24 | project.objects.newInstance(Modrinth::class.java, it)
25 | }
26 | extension.platforms.registerFactory(Gitea::class.java) {
27 | project.objects.newInstance(Gitea::class.java, it)
28 | }
29 |
30 | val publishModsTask = project.tasks.register("publishMods") {
31 | it.group = "publishing"
32 | }
33 |
34 | extension.platforms.whenObjectAdded { platform ->
35 | val publishPlatformTask = configureTask(project, platform)
36 |
37 | publishModsTask.configure { task ->
38 | task.dependsOn(publishPlatformTask)
39 | }
40 | }
41 | }
42 |
43 | private fun configureTask(project: Project, platform: Platform): TaskProvider {
44 | return project.tasks.register(platform.taskName, PublishModTask::class.java, platform)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/docs/pages/options.mdx:
--------------------------------------------------------------------------------
1 | The mod publish plugin has a number of common options that are shared between all platforms.
2 |
3 | ```groovy
4 | publishMods {
5 | // Set the main file to be uploaded
6 | file = jar.archiveFile
7 |
8 | // Or set the main file to be uploaded from another sub project
9 | file project(":subproject")
10 |
11 | // Markdown changelog
12 | changelog = "Example changelog"
13 |
14 | // Defaults to the Gradle project version
15 | version = "1.0.0"
16 |
17 | // Specify the release type
18 | type = STABLE
19 | type = BETA
20 | type = ALPHA
21 |
22 | // The display name/release title, this defaults to the Gradle project name + version
23 | displayName = "My Mod"
24 |
25 | // Set the display name to match the file name
26 | displayName = file.map { it.asFile.name }
27 |
28 | // A list of mod loaders the release supports
29 | modLoaders.add("fabric")
30 |
31 | // A ConfigurableFileCollection of addional files that are uploaded alongside the main file
32 | additionalFiles.from(jar.archiveFile)
33 |
34 | // The max number of times to retry on server error, defaults to 3.
35 | maxRetries = 5
36 |
37 | // When dry run is enabled the release assets will be saved to the build directory for testing.
38 | dryRun = true
39 | // You can always enable it when one of your API keys is not present.
40 | dryRun = providers.environmentVariable("API_KEY").getOrNull() == null
41 | }
42 | ```
43 |
44 | Each platform inherits the above common options, and can all be individually configured. For example if you want to use a different file just for Github you could do the following:
45 |
46 | ```groovy
47 | publishMods {
48 | // Default file
49 | file = jar.archiveFile
50 |
51 | github {
52 | // The githubJar is only used by the github publish task.
53 | file = githubJar.archiveFile
54 | }
55 |
56 | curseforge {
57 | // Default file used
58 | }
59 |
60 | modrinth {
61 | // Default file used
62 | }
63 | }
64 | ```
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/curseforge/CurseforgeVersionsTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.curseforge
2 |
3 | import kotlinx.serialization.json.Json
4 | import me.modmuss50.mpp.platforms.curseforge.CurseforgeVersions
5 | import org.gradle.api.JavaVersion
6 | import org.junit.jupiter.api.Test
7 | import java.io.BufferedReader
8 | import kotlin.test.assertEquals
9 |
10 | class CurseforgeVersionsTest {
11 | val json = Json { ignoreUnknownKeys = true }
12 |
13 | @Test
14 | fun minecraftVersions() {
15 | val versions = createVersions()
16 | assertEquals(9990, versions.getMinecraftVersion("1.20.1"))
17 | }
18 |
19 | @Test
20 | fun modLoader() {
21 | val versions = createVersions()
22 | assertEquals(7499, versions.getModLoaderVersion("fabric"))
23 | }
24 |
25 | @Test
26 | fun client() {
27 | val versions = createVersions()
28 | assertEquals(9638, versions.getClientVersion())
29 | }
30 |
31 | @Test
32 | fun server() {
33 | val versions = createVersions()
34 | assertEquals(9639, versions.getServerVersion())
35 | }
36 |
37 | @Test
38 | fun javaVersions() {
39 | val versions = createVersions()
40 | assertEquals(4458, versions.getJavaVersion(JavaVersion.VERSION_1_8))
41 | assertEquals(8320, versions.getJavaVersion(JavaVersion.VERSION_11))
42 | assertEquals(8326, versions.getJavaVersion(JavaVersion.VERSION_17))
43 | }
44 |
45 | private fun createVersions(): CurseforgeVersions {
46 | val versionTypes = readResource("curseforge_version_types.json")
47 | val versions = readResource("curseforge_versions.json")
48 | return CurseforgeVersions(json.decodeFromString(versionTypes), json.decodeFromString(versions))
49 | }
50 |
51 | private fun readResource(path: String): String {
52 | this::class.java.classLoader!!.getResourceAsStream(path).use { inputStream ->
53 | BufferedReader(inputStream!!.reader()).use { reader ->
54 | return reader.readText()
55 | }
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/docs/pages/getting_started.mdx:
--------------------------------------------------------------------------------
1 | import { Tabs, Tab } from 'nextra/components'
2 |
3 | # Installing the plugin
4 |
5 | The plugin is hosted on the Gradle plugin portal, so can be easily added to your project by adding the following snippet to the top of your build.gradle file.
6 |
7 |
8 |
9 | ```groovy
10 | plugins {
11 | id "me.modmuss50.mod-publish-plugin" version "1.1.0"
12 | }
13 | ```
14 |
15 |
16 | ```kotlin
17 | plugins {
18 | id("me.modmuss50.mod-publish-plugin") version "1.1.0"
19 | }
20 | ```
21 |
22 |
23 |
24 | # Configuring the plugin
25 |
26 | import { Callout } from 'nextra/components'
27 |
28 |
29 | If you just want to see a working example, select a specific platform on the left.
30 |
31 |
32 | By default, the plugin does nothing. First you must configure the basic platform-agnostic options within the `publishMods` extension block.
33 |
34 | ```groovy
35 | publishMods {
36 | changelog = "# Markdown changelog content"
37 | type = STABLE
38 | }
39 | ```
40 |
41 | ## Input file
42 |
43 | Next you need to specify the input file and supported modloaders, this changes based on the mod loader you are using.
44 |
45 | - On Fabric uses the `remapJar` task
46 | - On Forge uses the `jar` task
47 |
48 | ### Fabric
49 |
50 | ```groovy
51 | publishMods {
52 | file = remapJar.archiveFile
53 | modLoaders.add("fabric")
54 | }
55 | ```
56 |
57 | ### Forge
58 |
59 | ```groovy
60 | publishMods {
61 | file = jar.archiveFile
62 | modLoaders.add("forge")
63 | }
64 | ```
65 |
66 | ## Adding a platform
67 |
68 | Next you need to configure each platform that you wish to publish your mod to. The following shows a simple example for publishing a mod to curseforge.
69 |
70 | ```groovy
71 | publishMods {
72 | curseforge {
73 | projectId = "123456"
74 | accessToken = providers.environmentVariable("CURSEFORGE_TOKEN")
75 | minecraftVersions.add("1.20.1")
76 | }
77 | }
78 | ```
79 | ## Publishing
80 |
81 | Run the `publishMods` task to publish the mod to all the configured destinations.
--------------------------------------------------------------------------------
/docs/pages/multi_platform.mdx:
--------------------------------------------------------------------------------
1 | When creating a cross platform mod is it common to have a subproject for each platform. This can lead to a lot of repetition in the buildscript.
2 | To help with this the plugin supports sharing options between multiple destinations, see the [Shared Options](/shared_options) page for more information.
3 | The simplest way to configure the publishing of multiple platforms is to configure them all in the root project, this keeps the configuration in one place and allows for easy sharing of options.
4 |
5 | See the following fully working example that publishes both the Fabric and Forge versions of a mod to both CurseForge and Modrinth:
6 |
7 | ```groovy
8 | publishMods {
9 | changelog = "Changelog goes here"
10 | type = STABLE
11 |
12 | // CurseForge options used by both Fabric and Forge
13 | def cfOptions = curseforgeOptions {
14 | accessToken = providers.environmentVariable("CURSEFORGE_TOKEN")
15 | projectId = "123456"
16 | minecraftVersions.add("1.20.1")
17 | }
18 |
19 | // Modrinth options used by both Fabric and Forge
20 | def mrOptions = modrinthOptions {
21 | accessToken = providers.environmentVariable("MODRINTH_TOKEN")
22 | projectId = "12345678"
23 | minecraftVersions.add("1.20.1")
24 | }
25 |
26 | // Fabric specific options for CurseForge
27 | curseforge("curseforgeFabric") {
28 | from cfOptions
29 | file project(":fabric")
30 | modLoaders.add("fabric")
31 | requires {
32 | slug = "fabric-api"
33 | }
34 | }
35 |
36 | // Forge specific options for CurseForge
37 | curseforge("curseforgeForge") {
38 | from cfOptions
39 | file project(":forge")
40 | modLoaders.add("forge")
41 | }
42 |
43 | // Fabric specific options for Modrinth
44 | modrinth("modrinthFabric") {
45 | from mrOptions
46 | file project(":fabric")
47 | modLoaders.add("fabric")
48 | requires {
49 | slug = "fabric-api"
50 | }
51 | }
52 |
53 | // Forge specific options for Modrinth
54 | modrinth("modrinthForge") {
55 | from mrOptions
56 | file project(":forge")
57 | modLoaders.add("forge")
58 | }
59 | }
60 | ```
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/misc/PublishResultTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.misc
2 |
3 | import me.modmuss50.mpp.CurseForgePublishResult
4 | import me.modmuss50.mpp.GithubPublishResult
5 | import me.modmuss50.mpp.ModrinthPublishResult
6 | import me.modmuss50.mpp.PublishResult
7 | import kotlin.test.Test
8 | import kotlin.test.assertEquals
9 |
10 | class PublishResultTest {
11 | @Test
12 | fun decodeGithubJson() {
13 | val result = PublishResult.fromJson(
14 | """
15 | {
16 | "type": "github",
17 | "repository": "test/test",
18 | "releaseId": 123,
19 | "url": "https://github.com",
20 | "title": "test"
21 | }
22 | """.trimIndent(),
23 | )
24 |
25 | val github = result as GithubPublishResult
26 | assertEquals("test/test", github.repository)
27 | assertEquals(123, github.releaseId)
28 | assertEquals("https://github.com", github.url)
29 | assertEquals("test", github.title)
30 | }
31 |
32 | @Test
33 | fun decodeCurseforgeJson() {
34 | val result = PublishResult.fromJson(
35 | """
36 | {
37 | "type": "curseforge",
38 | "projectId": "abc",
39 | "fileId": 123,
40 | "projectSlug": "example",
41 | "title": "test"
42 | }
43 | """.trimIndent(),
44 | )
45 |
46 | val curseforge = result as CurseForgePublishResult
47 | assertEquals("abc", curseforge.projectId)
48 | assertEquals(123, curseforge.fileId)
49 | assertEquals("test", curseforge.title)
50 | }
51 |
52 | @Test
53 | fun decodeModrinthJson() {
54 | val result = PublishResult.fromJson(
55 | """
56 | {
57 | "type": "modrinth",
58 | "id": "test",
59 | "projectId": "123",
60 | "title": "test"
61 | }
62 | """.trimIndent(),
63 | )
64 |
65 | val modrinth = result as ModrinthPublishResult
66 | assertEquals("test", modrinth.id)
67 | assertEquals("123", modrinth.projectId)
68 | assertEquals("test", modrinth.title)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/docs/pages/index.mdx:
--------------------------------------------------------------------------------
1 | # Mod Publish Plugin
2 |
3 | A modern Gradle plugin to publish Minecraft mods to a range of destinations. Start with the [Getting Started](/getting_started) page.
4 |
5 | ### Features
6 | - Supports CurseForge, Modrinth, Github, Gitea, Forgejo and Discord
7 | - Typesafe DSL to easily publish to multiple locations with minimal repetition
8 | - Retry on failure
9 | - Dry run mode to try and increase confidence in your buildscript before releases
10 | - Built with modern Gradle features
11 | - Mod loader independent
12 | - Stable API, backwards compatibility is a high priority.
13 | - Breaking changes indicated by a major version bump.
14 |
15 | ### FAQ
16 | - Why use Kotlin?
17 | - I wanted to explore using Kotlin for a larger project.
18 | - Gradle includes Kotlin so there is no additional dependency for the end user.
19 | - Groovy buildscripts are fully supported.
20 | - Why do I need to specify the minecraft versions for both CurseForge and Modrinth?
21 | - Curseforge and Modrinth use different versioning for snapshots, thus they must be defined for each platform.
22 | - Feature x or platform y is not supported
23 | - Please open an issue on this repo!
24 | - I have a question and need some help
25 | - Please use the mod publish plugin discord channel on the [Team Reborn Discord](https://discord.gg/teamreborn)
26 |
27 | ### Changelog
28 | #### 1.1.0
29 | - Gitea/Forgejo support
30 | - Update Github library to fix incompatibility with newer jackson.
31 | #### 1.0.0
32 | - Support GitHub immutable releases
33 | - Retry on all network failures including timeouts.
34 | #### 0.8.4
35 | - Fix project files attempting to include transitive dependencies
36 | #### 0.8.3
37 | - Add helper to depend on files from another subproject
38 | - Add documentation and tests for the common use case of multi platform setups
39 | #### 0.8.2
40 | - Allow providing the version name instead of version id for a modrinth dependency
41 | #### 0.8.1
42 | - Fix to accommodate a change in Modrinth's API for updating project descriptions
43 | #### 0.8.0
44 | - Add modern Discord message styles and link button and inline link support
45 | - Support setting CurseForge additional file display name.
46 | - Fail on duplicate Github file names.
47 | - Use `convention` instead of `set` in `from` functions, removes the need for `from` to be first.
48 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/PublishOptions.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import org.gradle.api.Project
4 | import org.gradle.api.file.ConfigurableFileCollection
5 | import org.gradle.api.file.RegularFileProperty
6 | import org.gradle.api.provider.ListProperty
7 | import org.gradle.api.provider.Property
8 | import org.gradle.api.tasks.Input
9 | import org.gradle.api.tasks.InputFile
10 | import org.gradle.api.tasks.InputFiles
11 | import org.jetbrains.annotations.ApiStatus.Internal
12 | import javax.inject.Inject
13 |
14 | // Contains options shared by each platform and the extension
15 | interface PublishOptions {
16 | @get:InputFile
17 | val file: RegularFileProperty
18 |
19 | @get:Input
20 | val version: Property // Should this use a TextResource?
21 |
22 | @get:Input
23 | val changelog: Property
24 |
25 | @get:Input
26 | val type: Property
27 |
28 | @get:Input
29 | val displayName: Property
30 |
31 | @get:Input
32 | val modLoaders: ListProperty
33 |
34 | @get:InputFiles
35 | val additionalFiles: ConfigurableFileCollection
36 |
37 | @get:Input
38 | val maxRetries: Property
39 |
40 | @get:Inject
41 | @get:Internal
42 | val _thisProject: Project
43 |
44 | fun from(other: PublishOptions) {
45 | file.convention(other.file)
46 | version.convention(other.version)
47 | changelog.convention(other.changelog)
48 | type.convention(other.type)
49 | displayName.convention(other.displayName)
50 | modLoaders.convention(other.modLoaders)
51 | additionalFiles.convention(other.additionalFiles)
52 | maxRetries.convention(other.maxRetries)
53 | }
54 |
55 | /**
56 | * A helper function to add a file from the output of another project
57 | */
58 | fun file(project: Project) {
59 | var configuration = _thisProject.configurations.detachedConfiguration(
60 | _thisProject.dependencyFactory.create(project).setTransitive(false),
61 | )
62 | file.fileProvider(
63 | configuration.elements.map { it.single().asFile },
64 | )
65 | }
66 |
67 | /**
68 | * A helper function to add an additional file from the output of another project
69 | */
70 | fun additionalFile(project: Project) {
71 | var configuration = _thisProject.configurations.detachedConfiguration(
72 | _thisProject.dependencyFactory.create(project).setTransitive(false),
73 | )
74 | additionalFiles.from(configuration)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/misc/MinecraftApiTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.misc
2 |
3 | import me.modmuss50.mpp.MinecraftApi
4 | import me.modmuss50.mpp.test.MockWebServer
5 | import kotlin.test.Test
6 | import kotlin.test.assertContains
7 | import kotlin.test.assertFalse
8 |
9 | class MinecraftApiTest {
10 | @Test
11 | fun getVersions() {
12 | val server = MockWebServer(MockMinecraftApi())
13 | val api = MinecraftApi(server.endpoint)
14 |
15 | val versions = api.getVersionsInRange("1.19.4", "1.20.1")
16 | assertContains(versions, "1.19.4")
17 | assertContains(versions, "1.20")
18 | assertContains(versions, "1.20.1")
19 | assertFalse(versions.contains("1.20-rc1"))
20 |
21 | server.close()
22 | }
23 |
24 | @Test
25 | fun getVersionsSnapshots() {
26 | val server = MockWebServer(MockMinecraftApi())
27 | val api = MinecraftApi(server.endpoint)
28 |
29 | val versions = api.getVersionsInRange("1.19.4", "1.20.1", true)
30 | assertContains(versions, "1.19.4")
31 | assertContains(versions, "1.20")
32 | assertContains(versions, "1.20.1")
33 | assertContains(versions, "1.20-rc1")
34 |
35 | server.close()
36 | }
37 |
38 | @Test
39 | fun getVersionsLatest() {
40 | val server = MockWebServer(MockMinecraftApi())
41 | val api = MinecraftApi(server.endpoint)
42 |
43 | val versions = api.getVersionsInRange("1.19.4", "latest")
44 | assertContains(versions, "1.19.4")
45 | assertContains(versions, "1.20")
46 | assertContains(versions, "1.20.2")
47 | assertFalse(versions.contains("23w44a"))
48 |
49 | server.close()
50 | }
51 |
52 | @Test
53 | fun getVersionsLatestSnapshot() {
54 | val server = MockWebServer(MockMinecraftApi())
55 | val api = MinecraftApi(server.endpoint)
56 |
57 | val versions = api.getVersionsInRange("1.19.4", "latest", true)
58 | assertContains(versions, "1.19.4")
59 | assertContains(versions, "1.20")
60 | assertContains(versions, "1.20.2")
61 | assertContains(versions, "23w44a")
62 |
63 | server.close()
64 | }
65 |
66 | @Test
67 | fun getSingleVersionFromRange() {
68 | val server = MockWebServer(MockMinecraftApi())
69 | val api = MinecraftApi(server.endpoint)
70 |
71 | val versions = api.getVersionsInRange("1.19.4", "1.19.4", true)
72 | assert(versions.size == 1)
73 | assertContains(versions, "1.19.4")
74 |
75 | server.close()
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/gitea/MockGiteaApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.gitea
2 |
3 | import io.javalin.apibuilder.ApiBuilder.get
4 | import io.javalin.apibuilder.ApiBuilder.patch
5 | import io.javalin.apibuilder.ApiBuilder.path
6 | import io.javalin.apibuilder.ApiBuilder.post
7 | import io.javalin.apibuilder.EndpointGroup
8 | import io.javalin.http.Context
9 | import me.modmuss50.mpp.test.MockWebServer
10 |
11 | class MockGiteaApi : MockWebServer.MockApi {
12 | override fun routes(): EndpointGroup {
13 | return EndpointGroup {
14 | path("api/v1/repos") {
15 | path("{owner}/{name}") {
16 | path("releases") {
17 | post(this::createRelease)
18 | path("{id}/assets") {
19 | post(this::uploadAsset)
20 | }
21 | get("{id}", this::getRelease)
22 | patch("{id}", this::updateRelease)
23 | }
24 | }
25 | }
26 | }
27 | }
28 |
29 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoCreateRelease
30 | private fun createRelease(context: Context) {
31 | context.result(
32 | """
33 | {
34 | "id": 1,
35 | "upload_url": "http://localhost:${context.port()}/api/v1/repos/${context.pathParam("owner")}/${context.pathParam("name")}/releases/1/assets",
36 | "html_url": "https://codeberg.org"
37 | }
38 | """.trimIndent(),
39 | )
40 | }
41 |
42 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoCreateReleaseAttachment
43 | private fun uploadAsset(context: Context) {
44 | context.result(
45 | """
46 | {
47 | }
48 | """.trimIndent(),
49 | )
50 | }
51 |
52 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoGetRelease
53 | private fun getRelease(context: Context) {
54 | val id = context.pathParam("id")
55 | context.result(
56 | """
57 | {
58 | "id": $id,
59 | "upload_url": "http://localhost:${context.port()}/api/v1/repos/${context.pathParam("owner")}/${context.pathParam("name")}/releases/$id/assets",
60 | "html_url": "https://codeberg.org"
61 | }
62 | """.trimIndent(),
63 | )
64 | }
65 |
66 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoEditRelease
67 | private fun updateRelease(context: Context) {
68 | context.result(
69 | """
70 | {
71 | }
72 | """.trimIndent(),
73 | )
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/test/resources/version_manifest_v2.json:
--------------------------------------------------------------------------------
1 | {
2 | "latest": {
3 | "release": "1.20.2",
4 | "snapshot": "23w44a"
5 | },
6 | "versions": [
7 | {
8 | "id": "23w44a",
9 | "type": "snapshot",
10 | "url": "https://piston-meta.mojang.com/v1/packages/c18d3537d1c262a5527126b317dcf6056f7d8e4d/23w44a.json",
11 | "time": "2023-11-01T12:39:19+00:00",
12 | "releaseTime": "2023-11-01T12:30:52+00:00",
13 | "sha1": "c18d3537d1c262a5527126b317dcf6056f7d8e4d",
14 | "complianceLevel": 1
15 | },
16 | {
17 | "id": "1.20.2",
18 | "type": "release",
19 | "url": "https://piston-meta.mojang.com/v1/packages/9e1a5201ca011f539b8c713d01e02805fbdb6b59/1.20.2.json",
20 | "time": "2023-11-01T11:19:11+00:00",
21 | "releaseTime": "2023-09-20T09:02:57+00:00",
22 | "sha1": "9e1a5201ca011f539b8c713d01e02805fbdb6b59",
23 | "complianceLevel": 1
24 | },
25 | {
26 | "id": "1.20.2-rc2",
27 | "type": "snapshot",
28 | "url": "https://piston-meta.mojang.com/v1/packages/3f2a700470a0b3af144f2a0934e8b1f118d596da/1.20.2-rc2.json",
29 | "time": "2023-11-01T11:19:11+00:00",
30 | "releaseTime": "2023-09-18T12:34:57+00:00",
31 | "sha1": "3f2a700470a0b3af144f2a0934e8b1f118d596da",
32 | "complianceLevel": 1
33 | },
34 | {
35 | "id": "1.20.1",
36 | "type": "release",
37 | "url": "https://piston-meta.mojang.com/v1/packages/e5e16c872ce05032dcf702a6a95d9f70f4876b13/1.20.1.json",
38 | "time": "2023-11-01T11:17:42+00:00",
39 | "releaseTime": "2023-06-12T13:25:51+00:00",
40 | "sha1": "e5e16c872ce05032dcf702a6a95d9f70f4876b13",
41 | "complianceLevel": 1
42 | },
43 | {
44 | "id": "1.20",
45 | "type": "release",
46 | "url": "https://piston-meta.mojang.com/v1/packages/b66b89b8595e12d29bee6390b3098e630f527c69/1.20.json",
47 | "time": "2023-11-01T11:17:42+00:00",
48 | "releaseTime": "2023-06-02T08:36:17+00:00",
49 | "sha1": "b66b89b8595e12d29bee6390b3098e630f527c69",
50 | "complianceLevel": 1
51 | },
52 | {
53 | "id": "1.20-rc1",
54 | "type": "snapshot",
55 | "url": "https://piston-meta.mojang.com/v1/packages/7f0e821864d81d0a5a29dc379cf372212c55e8e2/1.20-rc1.json",
56 | "time": "2023-11-01T11:17:42+00:00",
57 | "releaseTime": "2023-05-31T12:33:33+00:00",
58 | "sha1": "7f0e821864d81d0a5a29dc379cf372212c55e8e2",
59 | "complianceLevel": 1
60 | },
61 | {
62 | "id": "1.19.4",
63 | "type": "release",
64 | "url": "https://piston-meta.mojang.com/v1/packages/7177b926557a2cbbbdeaaad46a86ff3deadc26b4/1.19.4.json",
65 | "time": "2023-11-01T11:16:42+00:00",
66 | "releaseTime": "2023-03-14T12:56:18+00:00",
67 | "sha1": "7177b926557a2cbbbdeaaad46a86ff3deadc26b4",
68 | "complianceLevel": 1
69 | }
70 | ]
71 | }
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/github/MockGithubApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.github
2 |
3 | import io.javalin.apibuilder.ApiBuilder.get
4 | import io.javalin.apibuilder.ApiBuilder.patch
5 | import io.javalin.apibuilder.ApiBuilder.path
6 | import io.javalin.apibuilder.ApiBuilder.post
7 | import io.javalin.apibuilder.EndpointGroup
8 | import io.javalin.http.Context
9 | import me.modmuss50.mpp.test.MockWebServer
10 |
11 | // The very bare minimum to mock out the GitHub API.
12 | class MockGithubApi : MockWebServer.MockApi {
13 | override fun routes(): EndpointGroup {
14 | return EndpointGroup {
15 | path("repos") {
16 | path("{owner}/{name}") {
17 | get(this::getRepo)
18 | path("releases") {
19 | post(this::createRelease)
20 | path("{id}/assets") {
21 | post(this::uploadAsset)
22 | }
23 | get("{id}", this::getRelease)
24 | patch("{id}", this::updateRelease)
25 | }
26 | }
27 | }
28 | }
29 | }
30 |
31 | // https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository
32 | private fun getRepo(context: Context) {
33 | context.result(
34 | """
35 | {
36 | "full_name": "${context.pathParam("owner")}/${context.pathParam("name")}"
37 | }
38 | """.trimIndent(),
39 | )
40 | }
41 |
42 | // https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release
43 | private fun createRelease(context: Context) {
44 | context.result(
45 | """
46 | {
47 | "upload_url": "http://localhost:${context.port()}/repos/${context.pathParam("owner")}/${context.pathParam("name")}/releases/1/assets{?name,label}",
48 | "html_url": "https://github.com"
49 | }
50 | """.trimIndent(),
51 | )
52 | }
53 |
54 | // https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset
55 | private fun uploadAsset(context: Context) {
56 | context.result(
57 | """
58 | {
59 | }
60 | """.trimIndent(),
61 | )
62 | }
63 |
64 | // https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-a-release
65 | private fun getRelease(context: Context) {
66 | val id = context.pathParam("id")
67 | context.result(
68 | """
69 | {
70 | "id": $id,
71 | "upload_url": "http://localhost:${context.port()}/repos/${context.pathParam("owner")}/${context.pathParam("name")}/releases/1/assets{?name,label}",
72 | "html_url": "https://github.com"
73 | }
74 | """.trimIndent(),
75 | )
76 | }
77 |
78 | private fun updateRelease(context: Context) {
79 | context.result(
80 | """
81 | {
82 | }
83 | """.trimIndent(),
84 | )
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/misc/OptionsTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.misc
2 |
3 | import me.modmuss50.mpp.test.IntegrationTest
4 | import me.modmuss50.mpp.test.MockWebServer
5 | import me.modmuss50.mpp.test.curseforge.MockCurseforgeApi
6 | import me.modmuss50.mpp.test.modrinth.MockModrinthApi
7 | import org.gradle.testkit.runner.TaskOutcome
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 |
11 | class OptionsTest : IntegrationTest {
12 | @Test
13 | fun uploadWithPublishOptions() {
14 | val server = MockWebServer(MockWebServer.CombinedApi(listOf(MockModrinthApi(), MockCurseforgeApi())))
15 |
16 | val result = gradleTest()
17 | .buildScript(
18 | """
19 | publishMods {
20 | file = tasks.jar.flatMap { it.archiveFile }
21 | changelog = "Hello!"
22 | version = "1.0.0"
23 | type = BETA
24 |
25 | val fabricOptions = publishOptions {
26 | displayName = "Test Fabric"
27 | modLoaders.add("fabric")
28 | }
29 |
30 | val forgeOptions = publishOptions {
31 | displayName = "Test Forge"
32 | modLoaders.add("forge")
33 | }
34 |
35 | val curseForgeOptions = curseforgeOptions {
36 | accessToken = "123"
37 | projectId = "123456"
38 | minecraftVersions.add("1.20.1")
39 | apiEndpoint = "${server.endpoint}"
40 | }
41 |
42 | val modrinthOptions = modrinthOptions {
43 | accessToken = "123"
44 | projectId = "12345678"
45 | minecraftVersions.add("1.20.1")
46 | apiEndpoint = "${server.endpoint}"
47 | }
48 |
49 | curseforge("curseforgeFabric") {
50 | from(curseForgeOptions, fabricOptions)
51 | requires {
52 | slug = "fabric-api"
53 | }
54 | }
55 |
56 | curseforge("curseforgeForge") {
57 | from(curseForgeOptions, forgeOptions)
58 | }
59 |
60 | modrinth("modrinthFabric") {
61 | from(modrinthOptions, fabricOptions)
62 | requires {
63 | slug = "fabric-api"
64 | }
65 | }
66 |
67 | modrinth("modrinthForge") {
68 | from(modrinthOptions, forgeOptions)
69 | }
70 | }
71 | """.trimIndent(),
72 | )
73 | .run("publishMods")
74 | server.close()
75 |
76 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/docs/pages/platforms/github.mdx:
--------------------------------------------------------------------------------
1 | ## Basic example
2 | See the following minimal example showing how to publish the `remapJar` task to GitHub:
3 | ```groovy
4 | publishMods {
5 | file = remapJar.archiveFile
6 | changelog = "Changelog"
7 | type = STABLE
8 | modLoaders.add("fabric")
9 |
10 | github {
11 | accessToken = providers.environmentVariable("GITHUB_TOKEN")
12 | repository = "Example/MyMod"
13 | commitish = "main" // This is the branch the release tag will be created from
14 | }
15 | }
16 |
17 | ```
18 | ## Multiple releases
19 | You can create multiple GitHub destinations by specifying a name like so:
20 | ```groovy
21 | publishMods {
22 | github("githubProjectA") {
23 | // Configure github settings for project A here
24 | }
25 |
26 | github("githubProjectB") {
27 | // Configure github settings for project B here
28 | }
29 | }
30 | ```
31 | This will create 2 separate GitHub releases.
32 |
33 | ## Parent releases
34 | If you wish to upload files to a GitHub release created by another task either in the same project or another subproject, you can use the `parent` option.
35 | This is useful where you have multiple subprojects that you want to publish to a single release, the following example shows how a root project can create the release and subprojects can upload files to it:
36 |
37 | #### Root project
38 | ```groovy
39 | publishMods {
40 | github {
41 | accessToken = providers.environmentVariable("GITHUB_TOKEN")
42 | repository = "Example/MyMod"
43 | commitish = "main"
44 | tagName = "release/1.0.0"
45 |
46 | // Allow the release to be initially created without any files.
47 | allowEmptyFiles = true
48 | }
49 | }
50 | ```
51 |
52 | #### Subproject
53 | When using the `parent` option, only the `accessToken` and files are required, the other options are forcefully inherited from the parent task.
54 | ```groovy
55 | publishMods {
56 | github {
57 | accessToken = providers.environmentVariable("GITHUB_TOKEN")
58 |
59 | // Specify the root project's github task to upload files to
60 | parent project(":").tasks.named("publishGithub")
61 | }
62 | }
63 | ```
64 |
65 | ## All options
66 | See the following example showing all the Github specific options:
67 | ```groovy
68 | publishMods {
69 | github {
70 | accessToken = providers.environmentVariable("GITHUB_TOKEN")
71 | repository = "Example/MyMod"
72 | commitish = "main"
73 | tagName = "release/1.0.0"
74 |
75 | // Optionally set the announcement title used by the discord publisher
76 | announcementTitle = "Download from GitHub"
77 |
78 | // Upload the files to a previously created release, by providing another github publish task
79 | // This is useful in multi-project builds where you want to publish multiple subprojects to a single release
80 | parent tasks.named("publishGithub")
81 |
82 | // Optionally allow the release to be created without any attached files.
83 | // This is useful when you have subprojects using the parent option that you want to publish a single release.
84 | allowEmptyFiles = true
85 | }
86 | }
87 | ```
88 |
--------------------------------------------------------------------------------
/docs/pages/platforms/curseforge.mdx:
--------------------------------------------------------------------------------
1 | ## Basic example
2 | See the following minimal example showing how to publish the `remapJar` task to CurseForge:
3 |
4 | ```groovy
5 | publishMods {
6 | file = remapJar.archiveFile
7 | changelog = "Changelog"
8 | type = STABLE
9 | modLoaders.add("fabric")
10 |
11 | curseforge {
12 | accessToken = providers.environmentVariable("CURSEFORGE_API_KEY")
13 | projectId = "308769"
14 | minecraftVersions.add("1.20.1")
15 | }
16 | }
17 |
18 | ```
19 |
20 | You will need to generate a CurseForge API token in your account settings [here](https://legacy.curseforge.com/account/api-tokens), you can set this an environment variable.
21 | If you plan to publish from your local machine using a Gradle user property may be more convenient:
22 |
23 | You can use the gradle property provider like so:
24 | `providers.gradleProperty('CURSEFORGE_API_KEY')`
25 |
26 | ## Multiple projects
27 | You can create multiple CurseForge destinations by providing a name like so:
28 | ```groovy
29 | publishMods {
30 | curseforge("curseforgeProjectA") {
31 | // Configure curseforge settings for project A here
32 | }
33 |
34 | curseforge("curseforgeProjectB") {
35 | // Configure curseforge settings for project B here
36 | }
37 | }
38 | ```
39 | ## All options
40 | See the following example showing all the CurseForge specific options:
41 | ```groovy
42 | publishMods {
43 | curseforge {
44 | accessToken = providers.environmentVariable("CURSEFORGE_TOKEN")
45 | projectId = "123456"
46 | minecraftVersions.add("1.20.1")
47 |
48 | // You can specify a range of Minecraft versions like so:
49 | minecraftVersionRange {
50 | start = "1.19.4"
51 | end = "1.20.2" // Set to "latest" to use the latest minecraft version
52 | }
53 |
54 | // Optionally set the announcement title used by the discord publisher
55 | announcementTitle = "Download from CurseForge"
56 |
57 | // Optionally specify the java version that is required to run the mod
58 | javaVersions.add(JavaVersion.VERSION_17)
59 |
60 | // Optionally specify an environment that is required to run the mod
61 | clientRequired = true
62 | serverRequired = true
63 |
64 | // When using the discord webhook you must also specify the project slug
65 | // This is due to limitations in the CurseForge API.
66 | projectSlug = "test-mod"
67 |
68 | // Add dependencies to your project
69 | requires("project-slug-1", "another-project")
70 | optional("project-slug")
71 | incompatible("project-slug")
72 | embeds("project-slug")
73 |
74 | // Set a changelog using text, markdown, or html (defaults to markdown)
75 | changelog = "# Markdown changelog content"
76 | changelogType = "markdown"
77 |
78 | // Set the display name of an additional file
79 | additionalFile(jar) {
80 | name = "Fabric"
81 | }
82 |
83 | // Set the display name of an additional file from another project
84 | additionalFile(project(":child")) {
85 | name = "Fabric"
86 | }
87 | }
88 | }
89 | ```
90 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/ProductionTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test
2 |
3 | import kotlinx.serialization.Serializable
4 | import kotlinx.serialization.json.Json
5 | import org.gradle.testkit.runner.TaskOutcome
6 | import java.io.File
7 | import kotlin.test.Ignore
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 |
11 | /**
12 | * Enable this test to publish real files, to the real sites! Create a options.json with all the tokens
13 | */
14 | @Ignore
15 | class ProductionTest : IntegrationTest {
16 | @Test
17 | fun run() {
18 | val json = Json { ignoreUnknownKeys = true }
19 | val options = json.decodeFromString(File("options.json").readText())
20 |
21 | val result = gradleTest()
22 | .buildScript(
23 | """
24 | publishMods {
25 | file = tasks.jar.flatMap { it.archiveFile }
26 | changelog = "- Changelog line 1\n- Changelog line 2"
27 | version = "1.0.0"
28 | type = BETA
29 | modLoaders.add("fabric")
30 | displayName = "Test Upload"
31 |
32 | curseforge {
33 | accessToken = "${options.curseforgeToken}"
34 | projectId = "${options.curseforgeProject}"
35 | projectSlug = "${options.curseforgeProjectSlug}"
36 | minecraftVersions.add("1.20.1")
37 | javaVersions.add(JavaVersion.VERSION_17)
38 | clientRequired = true
39 | serverRequired = true
40 |
41 | requires {
42 | slug = "fabric-api"
43 | }
44 | }
45 |
46 | // github {
47 | // accessToken = "${options.githubToken}"
48 | // repository = "${options.githubRepo}"
49 | // commitish = "main"
50 | // }
51 |
52 | modrinth {
53 | accessToken = "${options.modrinthToken}"
54 | projectId = "${options.modrinthProject}"
55 | minecraftVersions.add("1.20.1")
56 |
57 | requires {
58 | id = "P7dR8mSH"
59 | }
60 | }
61 |
62 | discord {
63 | username = "Great test mod"
64 | avatarUrl = "https://placekitten.com/500/500"
65 | content = changelog.map { "## A new version of my mod has been uploaded:\n" + it }
66 | webhookUrl = "${options.discordWebhook}"
67 | }
68 | }
69 | """.trimIndent(),
70 | )
71 | .run("publishMods")
72 |
73 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
74 | }
75 | }
76 |
77 | @Serializable
78 | data class ProductionOptions(
79 | val curseforgeToken: String,
80 | val curseforgeProject: String,
81 | val curseforgeProjectSlug: String,
82 | val modrinthToken: String,
83 | val modrinthProject: String,
84 | val githubToken: String,
85 | val githubRepo: String,
86 | val discordWebhook: String,
87 | )
88 |
--------------------------------------------------------------------------------
/docs/pages/platforms/gitea.mdx:
--------------------------------------------------------------------------------
1 | ## Basic example
2 | See the following minimal example showing how to publish the `remapJar` task to Gitea:
3 | ```groovy
4 | publishMods {
5 | file = remapJar.archiveFile
6 | changelog = "Changelog"
7 | type = STABLE
8 | modLoaders.add("fabric")
9 |
10 | gitea {
11 | accessToken = providers.environmentVariable("GITEA_TOKEN")
12 | hostUrl(new URI("https://gitea.example.com")) // This is the link for your Gitea host.
13 | repository = "Example/MyMod"
14 | commitish = "main" // This is the branch the release tag will be created from
15 | }
16 | }
17 |
18 | ```
19 | ## Multiple releases
20 | You can create multiple Gitea destinations by specifying a name like so:
21 | ```groovy
22 | publishMods {
23 | gitea("giteaProjectA") {
24 | // Configure gitea settings for project A here
25 | }
26 |
27 | gitea("giteaProjectB") {
28 | // Configure gitea settings for project B here
29 | }
30 | }
31 | ```
32 | This will create 2 separate Gitea releases.
33 |
34 | ## Parent releases
35 | If you wish to upload files to a Gitea release created by another task either in the same project or another subproject, you can use the `parent` option.
36 | This is useful where you have multiple subprojects that you want to publish to a single release, the following example shows how a root project can create the release and subprojects can upload files to it:
37 |
38 | #### Root project
39 | ```groovy
40 | publishMods {
41 | gitea {
42 | accessToken = providers.environmentVariable("GITEA_TOKEN")
43 | repository = "Example/MyMod"
44 | commitish = "main"
45 | tagName = "release/1.0.0"
46 |
47 | // Allow the release to be initially created without any files.
48 | allowEmptyFiles = true
49 | }
50 | }
51 | ```
52 |
53 | #### Subproject
54 | When using the `parent` option, only the `accessToken` and files are required, the other options are forcefully inherited from the parent task.
55 | ```groovy
56 | publishMods {
57 | gitea {
58 | accessToken = providers.environmentVariable("GITEA_TOKEN")
59 |
60 | // Specify the root project's gitea task to upload files to
61 | parent project(":").tasks.named("gitea")
62 | }
63 | }
64 | ```
65 |
66 | ## All options
67 | See the following example showing all the Gitea specific options:
68 | ```groovy
69 | publishMods {
70 | gitea {
71 | accessToken = providers.environmentVariable("GITEA_TOKEN")
72 | hostUrl(new URI("https://gitea.example.com"))
73 | repository = "Example/MyMod"
74 | commitish = "main"
75 | tagName = "release/1.0.0"
76 |
77 | // Optionally set the announcement title used by the discord publisher
78 | announcementTitle = "Download from Gitea"
79 | // Optionally set the display name for your host, which is used by the discord publisher if no custom title is set
80 | hostDisplayName = "Example"
81 | // Optionally set the brand color used by your host
82 | hostBrandColor = 0x1d8f4a
83 |
84 | // Upload the files to a previously created release, by providing another gitea publish task
85 | // This is useful in multi-project builds where you want to publish multiple subprojects to a single release
86 | parent tasks.named("publishGitea")
87 |
88 | // Optionally allow the release to be created without any attached files.
89 | // This is useful when you have subprojects using the parent option that you want to publish a single release.
90 | allowEmptyFiles = true
91 | }
92 | }
93 | ```
94 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/curseforge/MockCurseforgeApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.curseforge
2 |
3 | import io.javalin.apibuilder.ApiBuilder.before
4 | import io.javalin.apibuilder.ApiBuilder.get
5 | import io.javalin.apibuilder.ApiBuilder.path
6 | import io.javalin.apibuilder.ApiBuilder.post
7 | import io.javalin.apibuilder.EndpointGroup
8 | import io.javalin.http.BadRequestResponse
9 | import io.javalin.http.Context
10 | import io.javalin.http.UnauthorizedResponse
11 | import kotlinx.serialization.ExperimentalSerializationApi
12 | import kotlinx.serialization.json.Json
13 | import me.modmuss50.mpp.platforms.curseforge.CurseforgeApi
14 | import me.modmuss50.mpp.test.MockWebServer
15 | import java.io.BufferedReader
16 |
17 | class MockCurseforgeApi : MockWebServer.MockApi {
18 | @OptIn(ExperimentalSerializationApi::class)
19 | val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
20 | var lastMetadata: CurseforgeApi.UploadFileMetadata? = null
21 | var allMetadata: ArrayList = ArrayList()
22 | val files: ArrayList = ArrayList()
23 |
24 | override fun routes(): EndpointGroup {
25 | return EndpointGroup {
26 | path("api") {
27 | path("game/version-types") {
28 | before(this::authHandler)
29 | get(this::versionTypes)
30 | }
31 | path("game/versions") {
32 | before(this::authHandler)
33 | get(this::versions)
34 | }
35 | path("projects/{projectId}/upload-file") {
36 | before(this::authHandler)
37 | post(this::uploadFile)
38 | }
39 | }
40 | }
41 | }
42 |
43 | private fun authHandler(context: Context) {
44 | val apiToken = context.header("X-Api-Token")
45 |
46 | if (apiToken != "123") {
47 | throw UnauthorizedResponse(
48 | """
49 | {
50 | "errorCode": 401,
51 | "errorMessage": "You must provide an API token using the `X-Api-Token` header, the `token` query string parameter, your email address and an API token using HTTP basic authentication."
52 | }
53 | """.trimIndent(),
54 | )
55 | }
56 | }
57 |
58 | private fun versionTypes(context: Context) {
59 | val versions = readResource("curseforge_version_types.json")
60 | context.result(versions)
61 | }
62 |
63 | private fun versions(context: Context) {
64 | val versions = readResource("curseforge_versions.json")
65 | context.result(versions)
66 | }
67 |
68 | private fun uploadFile(context: Context) {
69 | val metadata = context.formParam("metadata")
70 | val file = context.uploadedFile("file")
71 |
72 | if (metadata == null) {
73 | throw BadRequestResponse("No metadata")
74 | }
75 |
76 | if (file == null) {
77 | throw BadRequestResponse("No file")
78 | }
79 |
80 | lastMetadata = json.decodeFromString(metadata)
81 | allMetadata.add(lastMetadata!!)
82 | files.add(file.filename())
83 |
84 | context.result("""{"id": "20402"}""")
85 | }
86 |
87 | private fun readResource(path: String): String {
88 | this::class.java.classLoader!!.getResourceAsStream(path).use { inputStream ->
89 | BufferedReader(inputStream!!.reader()).use { reader ->
90 | return reader.readText()
91 | }
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/docs/pages/platforms/forgejo.mdx:
--------------------------------------------------------------------------------
1 | ## Basic example
2 | See the following minimal example showing how to publish the `remapJar` task to Forgejo:
3 | ```groovy
4 | publishMods {
5 | file = remapJar.archiveFile
6 | changelog = "Changelog"
7 | type = STABLE
8 | modLoaders.add("fabric")
9 |
10 | forgejo {
11 | accessToken = providers.environmentVariable("FORGEJO_TOKEN")
12 | hostUrl(new URI("https://codeberg.org")) // This is the link for your Forgejo host.
13 | repository = "Example/MyMod"
14 | commitish = "main" // This is the branch the release tag will be created from
15 | }
16 | }
17 |
18 | ```
19 | ## Multiple releases
20 | You can create multiple Forgejo destinations by specifying a name like so:
21 | ```groovy
22 | publishMods {
23 | forgejo("forgejoProjectA") {
24 | // Configure forgejo settings for project A here
25 | }
26 |
27 | forgejo("forgejoProjectB") {
28 | // Configure forgejo settings for project B here
29 | }
30 | }
31 | ```
32 | This will create 2 separate Forgejo releases.
33 |
34 | ## Parent releases
35 | If you wish to upload files to a Forgejo release created by another task either in the same project or another subproject, you can use the `parent` option.
36 | This is useful where you have multiple subprojects that you want to publish to a single release, the following example shows how a root project can create the release and subprojects can upload files to it:
37 |
38 | #### Root project
39 | ```groovy
40 | publishMods {
41 | forgejo {
42 | accessToken = providers.environmentVariable("FORGEJO_TOKEN")
43 | repository = "Example/MyMod"
44 | commitish = "main"
45 | tagName = "release/1.0.0"
46 |
47 | // Allow the release to be initially created without any files.
48 | allowEmptyFiles = true
49 | }
50 | }
51 | ```
52 |
53 | #### Subproject
54 | When using the `parent` option, only the `accessToken` and files are required, the other options are forcefully inherited from the parent task.
55 | ```groovy
56 | publishMods {
57 | forgejo {
58 | accessToken = providers.environmentVariable("FORGEJO_TOKEN")
59 |
60 | // Specify the root project's forgejo task to upload files to
61 | parent project(":").tasks.named("forgejo")
62 | }
63 | }
64 | ```
65 |
66 | ## All options
67 | See the following example showing all the Forgejo specific options:
68 | ```groovy
69 | publishMods {
70 | forgejo {
71 | accessToken = providers.environmentVariable("FORGEJO_TOKEN")
72 | hostUrl(new URI("https://forgejo.example.com"))
73 | repository = "Example/MyMod"
74 | commitish = "main"
75 | tagName = "release/1.0.0"
76 |
77 | // Optionally set the announcement title used by the discord publisher
78 | announcementTitle = "Download from Forgejo"
79 | // Optionally set the display name for your host, which is used by the discord publisher if no custom title is set
80 | hostDisplayName = "Example"
81 | // Optionally set the brand color used by your host
82 | hostBrandColor = 0xff5500
83 |
84 | // Upload the files to a previously created release, by providing another forgejo publish task
85 | // This is useful in multi-project builds where you want to publish multiple subprojects to a single release
86 | parent tasks.named("publishForgejo")
87 |
88 | // Optionally allow the release to be created without any attached files.
89 | // This is useful when you have subprojects using the parent option that you want to publish a single release.
90 | allowEmptyFiles = true
91 | }
92 | }
93 | ```
94 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/misc/MultiPlatformTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.misc
2 |
3 | import me.modmuss50.mpp.test.IntegrationTest
4 | import me.modmuss50.mpp.test.MockWebServer
5 | import me.modmuss50.mpp.test.curseforge.MockCurseforgeApi
6 | import me.modmuss50.mpp.test.modrinth.MockModrinthApi
7 | import org.gradle.testkit.runner.TaskOutcome
8 | import org.junit.jupiter.api.Test
9 | import kotlin.test.assertEquals
10 |
11 | class MultiPlatformTest : IntegrationTest {
12 | @Test
13 | fun publishMultiplatform() {
14 | val server = MockWebServer(MockWebServer.CombinedApi(listOf(MockCurseforgeApi(), MockModrinthApi())))
15 |
16 | val result = gradleTest()
17 | .buildScript(
18 | """
19 | publishMods {
20 | changelog = "Changelog goes here"
21 | version = "1.0.0"
22 | type = STABLE
23 |
24 | // CurseForge options used by both Fabric and Forge
25 | val cfOptions = curseforgeOptions {
26 | accessToken = "123"
27 | projectId = "123456"
28 | minecraftVersions.add("1.20.1")
29 | apiEndpoint = "${server.endpoint}"
30 | }
31 |
32 | // Modrinth options used by both Fabric and Forge
33 | val mrOptions = modrinthOptions {
34 | accessToken = "123"
35 | projectId = "12345678"
36 | minecraftVersions.add("1.20.1")
37 | apiEndpoint = "${server.endpoint}"
38 | }
39 |
40 | // Fabric specific options for CurseForge
41 | curseforge("curseforgeFabric") {
42 | from(cfOptions)
43 | file(project(":fabric"))
44 | modLoaders.add("fabric")
45 | requires {
46 | slug = "fabric-api"
47 | }
48 | }
49 |
50 | // Forge specific options for CurseForge
51 | curseforge("curseforgeForge") {
52 | from(cfOptions)
53 | file(project(":forge"))
54 | modLoaders.add("forge")
55 | }
56 |
57 | // Fabric specific options for Modrinth
58 | modrinth("modrinthFabric") {
59 | from(mrOptions)
60 | file(project(":fabric"))
61 | modLoaders.add("fabric")
62 | requires {
63 | slug = "fabric-api"
64 | }
65 | }
66 |
67 | // Forge specific options for Modrinth
68 | modrinth("modrinthForge") {
69 | from(mrOptions)
70 | file(project(":forge"))
71 | modLoaders.add("forge")
72 | }
73 | }
74 | """.trimIndent(),
75 | )
76 | .subProject("fabric")
77 | .subProject("forge")
78 | .run("publishMods")
79 | server.close()
80 |
81 | assertEquals(TaskOutcome.SUCCESS, result.task(":fabric:jar")!!.outcome)
82 | assertEquals(TaskOutcome.SUCCESS, result.task(":forge:jar")!!.outcome)
83 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/docs/pages/platforms/modrinth.mdx:
--------------------------------------------------------------------------------
1 | ## Basic example
2 | See the following minimal example showing how to publish the `remapJar` task to Modrinth:
3 | ```groovy
4 | publishMods {
5 | file = remapJar.archiveFile
6 | changelog = "Changelog"
7 | type = STABLE
8 | modLoaders.add("fabric")
9 |
10 | modrinth {
11 | accessToken = providers.environmentVariable("MODRINTH_API_KEY")
12 | projectId = "12345678"
13 | minecraftVersions.add("1.20.1")
14 | }
15 | }
16 |
17 | ```
18 |
19 | You will need to generate a Modrinth PAT token in your account settings [here](https://modrinth.com/settings/pats), you can set this an environment variable.
20 | If you plan to publish from your local machine using a Gradle user property may be more convenient:
21 |
22 | You can use the gradle property provider like so:
23 | `providers.gradleProperty('CURSEFORGE_API_KEY')`
24 |
25 |
26 | ## Multiple projects
27 | You can create multiple Modrinth destinations by providing a name like so:
28 | ```groovy
29 | publishMods {
30 | modrinth("modrinthProjectA") {
31 | // Configure Modrinth settings for project A here
32 | }
33 |
34 | modrinth("modrinthProjectB") {
35 | // Configure Modrinth settings for project B here
36 | }
37 | }
38 | ```
39 | ## Token options
40 | When creating your Modrinth personal access token, you should give it the following scopes:
41 | - Create versions
42 | - Read versions
43 | - Write versions
44 |
45 | If you are using the `projectDescription` option, you will need also need to give it the following scopes:
46 | - Read projects
47 | - Write projects
48 |
49 | ## All options
50 | See the following example showing all the Modrinth specific options:
51 | ```groovy
52 | publishMods {
53 | modrinth {
54 | accessToken = providers.environmentVariable("MODRINTH_API_KEY")
55 | projectId = "12345678"
56 | minecraftVersions.add("1.20.1")
57 |
58 | // Optionally set the announcement title used by the discord publisher
59 | announcementTitle = "Download from Modrinth"
60 |
61 | // Optionally update the project description after publishing a file
62 | // This can be useful if you want to sync your Github readme with the Modrinth project description
63 | projectDescription = providers.fileContents(layout.projectDirectory.file("readme.md")).asText
64 |
65 | // You can specify a range of Minecraft versions like so:
66 | minecraftVersionRange {
67 | start = "1.19.4"
68 | end = "1.20.2" // Set to "latest" to use the latest minecraft version
69 |
70 | // Optionally include snapshot versions, defaults to false
71 | includeSnapshots = true
72 | }
73 |
74 | // You can specify either the project id OR slug, but not both.
75 | requires {
76 | id = "12345678"
77 | slug = "project-slug"
78 |
79 | // You can optionally specify a version id or name for the dependency
80 | version = "IIJJKKLL"
81 | }
82 | optional {
83 | id = "12345678"
84 | slug = "project-slug"
85 | }
86 | incompatible {
87 | id = "12345678"
88 | slug = "project-slug"
89 | }
90 | embeds {
91 | id = "12345678"
92 | slug = "project-slug"
93 | }
94 |
95 | // You can use the shortened method if you only need to specify the slug
96 | requires("project-slug")
97 | optional("project-slug")
98 | incompatible("project-slug")
99 | embeds("project-slug")
100 | }
101 | }
102 | ```
103 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/modrinth/MockModrinthApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.modrinth
2 |
3 | import io.javalin.apibuilder.ApiBuilder.before
4 | import io.javalin.apibuilder.ApiBuilder.get
5 | import io.javalin.apibuilder.ApiBuilder.patch
6 | import io.javalin.apibuilder.ApiBuilder.path
7 | import io.javalin.apibuilder.ApiBuilder.post
8 | import io.javalin.apibuilder.EndpointGroup
9 | import io.javalin.http.BadRequestResponse
10 | import io.javalin.http.Context
11 | import io.javalin.http.UnauthorizedResponse
12 | import kotlinx.serialization.encodeToString
13 | import kotlinx.serialization.json.Json
14 | import me.modmuss50.mpp.platforms.modrinth.ModrinthApi
15 | import me.modmuss50.mpp.test.MockWebServer
16 |
17 | class MockModrinthApi : MockWebServer.MockApi {
18 | val json = Json { ignoreUnknownKeys = true }
19 | var lastCreateVersion: ModrinthApi.CreateVersion? = null
20 | var projectBody: String? = null
21 |
22 | override fun routes(): EndpointGroup {
23 | return EndpointGroup {
24 | path("/project/{slug}/version") {
25 | get(this::listVersions)
26 | }
27 | path("/version") {
28 | before(this::authHandler)
29 | post(this::createVersion)
30 | }
31 | path("project/{slug}") {
32 | patch(this::modifyProject)
33 | }
34 | path("/project/{slug}/check") {
35 | get(this::checkProject)
36 | }
37 | }
38 | }
39 |
40 | private fun listVersions(context: Context) {
41 | context.result(
42 | json.encodeToString(
43 | arrayOf(
44 | ModrinthApi.ListVersionsResponse(
45 | "0.92.2+1.20.1",
46 | "P7uGFii0",
47 | ),
48 | ModrinthApi.ListVersionsResponse(
49 | "0.92.1+1.20.1",
50 | "ba99D9Qf",
51 | ),
52 | ),
53 | ),
54 | )
55 | }
56 |
57 | private fun authHandler(context: Context) {
58 | val apiToken = context.header("Authorization")
59 |
60 | if (apiToken != "123") {
61 | throw UnauthorizedResponse("Invalid access token")
62 | }
63 | }
64 |
65 | private fun createVersion(context: Context) {
66 | val data = context.formParam("data")
67 | ?: throw BadRequestResponse("No metadata")
68 |
69 | val createVersion = json.decodeFromString(data)
70 | lastCreateVersion = createVersion
71 |
72 | for (filePart in createVersion.fileParts) {
73 | context.uploadedFile(filePart)
74 | ?: throw BadRequestResponse("No file")
75 | }
76 |
77 | context.result(
78 | json.encodeToString(
79 | ModrinthApi.CreateVersionResponse(
80 | id = "hFdJG9fY",
81 | projectId = createVersion.projectId,
82 | authorId = "JZA4dW8o",
83 | ),
84 | ),
85 | )
86 | }
87 |
88 | private fun modifyProject(context: Context) {
89 | val modifyProject = json.decodeFromString(context.body())
90 | projectBody = modifyProject.body
91 | context.result()
92 | }
93 |
94 | private fun checkProject(context: Context) {
95 | context.result(
96 | """
97 | {
98 | "id": "AABBCCDD"
99 | }
100 | """.trimIndent(),
101 | )
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/Platform.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import org.gradle.api.Action
4 | import org.gradle.api.Named
5 | import org.gradle.api.logging.Logger
6 | import org.gradle.api.model.ObjectFactory
7 | import org.gradle.api.provider.ListProperty
8 | import org.gradle.api.provider.Property
9 | import org.gradle.api.provider.ProviderFactory
10 | import org.gradle.api.tasks.Input
11 | import org.gradle.api.tasks.Internal
12 | import org.gradle.api.tasks.Optional
13 | import org.jetbrains.annotations.ApiStatus
14 | import java.util.*
15 | import javax.inject.Inject
16 | import kotlin.reflect.KClass
17 |
18 | interface PlatformOptions : PublishOptions {
19 | @get:Optional
20 | @get:Input
21 | val accessToken: Property
22 |
23 | @get:Optional
24 | @get:Input
25 | val announcementTitle: Property
26 |
27 | fun from(other: PlatformOptions) {
28 | super.from(other)
29 | accessToken.convention(other.accessToken)
30 | announcementTitle.convention(other.announcementTitle)
31 | }
32 | }
33 |
34 | @ApiStatus.Internal
35 | interface PlatformOptionsInternal {
36 | fun setInternalDefaults()
37 | }
38 |
39 | interface PlatformDependencyContainer {
40 | @get:Input
41 | val dependencies: ListProperty
42 |
43 | fun requires(action: Action) {
44 | addInternal(PlatformDependency.DependencyType.REQUIRED, action)
45 | }
46 |
47 | fun optional(action: Action) {
48 | addInternal(PlatformDependency.DependencyType.OPTIONAL, action)
49 | }
50 |
51 | fun incompatible(action: Action) {
52 | addInternal(PlatformDependency.DependencyType.INCOMPATIBLE, action)
53 | }
54 |
55 | fun embeds(action: Action) {
56 | addInternal(PlatformDependency.DependencyType.EMBEDDED, action)
57 | }
58 |
59 | fun fromDependencies(other: PlatformDependencyContainer) {
60 | dependencies.convention(other.dependencies)
61 | }
62 |
63 | @get:ApiStatus.Internal
64 | @get:Inject
65 | val objectFactory: ObjectFactory
66 |
67 | @get:ApiStatus.Internal
68 | @get:Inject
69 | val providerFactory: ProviderFactory
70 |
71 | @get:ApiStatus.OverrideOnly
72 | @get:Internal
73 | val platformDependencyKClass: KClass
74 |
75 | @Internal
76 | fun addInternal(type: PlatformDependency.DependencyType, action: Action) {
77 | val dep = objectFactory.newInstance(platformDependencyKClass.java)
78 | dep.type.set(type)
79 | dep.type.finalizeValue()
80 | action.execute(dep)
81 | dependencies.add(dep)
82 | }
83 | }
84 |
85 | interface PlatformDependency {
86 | val type: Property
87 |
88 | enum class DependencyType {
89 | REQUIRED,
90 | OPTIONAL,
91 | INCOMPATIBLE,
92 | EMBEDDED,
93 | }
94 | }
95 |
96 | abstract class Platform @Inject constructor(private val name: String) : Named, PlatformOptions {
97 | @ApiStatus.Internal
98 | open fun validateInputs() {
99 | }
100 |
101 | @ApiStatus.Internal
102 | abstract fun publish(context: PublishContext)
103 |
104 | @ApiStatus.Internal
105 | abstract fun dryRunPublishResult(): PublishResult
106 |
107 | @ApiStatus.Internal
108 | abstract fun printDryRunInfo(logger: Logger)
109 |
110 | @get:ApiStatus.Internal
111 | @get:Internal
112 | val taskName: String
113 | get() = "publish" + titlecase(name)
114 |
115 | init {
116 | (this as PlatformOptionsInternal<*>).setInternalDefaults()
117 | }
118 |
119 | @Input
120 | override fun getName(): String {
121 | return name
122 | }
123 | }
124 |
125 | fun titlecase(string: String): String {
126 | return string.replaceFirstChar {
127 | if (it.isLowerCase()) {
128 | it.titlecase(Locale.ROOT)
129 | } else {
130 | it.toString()
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/PlatformInternal.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 | import kotlinx.serialization.encodeToString
6 | import kotlinx.serialization.json.Json
7 | import org.gradle.api.Action
8 | import org.gradle.api.file.RegularFile
9 | import org.gradle.api.file.RegularFileProperty
10 | import org.gradle.workers.WorkAction
11 | import org.gradle.workers.WorkParameters
12 | import org.gradle.workers.WorkQueue
13 | import org.intellij.lang.annotations.Language
14 | import org.jetbrains.annotations.ApiStatus
15 | import java.lang.IllegalStateException
16 | import kotlin.reflect.KClass
17 |
18 | @ApiStatus.Internal
19 | interface PublishWorkParameters : WorkParameters {
20 | val result: RegularFileProperty
21 | }
22 |
23 | @ApiStatus.Internal
24 | interface PublishWorkAction : WorkAction {
25 | fun publish(): PublishResult
26 |
27 | override fun execute() {
28 | val result = publish()
29 |
30 | parameters.result.get().asFile.writeText(
31 | Json.encodeToString(result),
32 | )
33 | }
34 | }
35 |
36 | @ApiStatus.Internal
37 | @Serializable
38 | sealed class PublishResult {
39 | abstract val type: String
40 | abstract val link: String
41 | abstract val title: String
42 | abstract val brandColor: Int
43 |
44 | companion object {
45 | fun fromJson(@Language("json") string: String): PublishResult {
46 | val json = Json { ignoreUnknownKeys = true }
47 | return json.decodeFromString(string)
48 | }
49 | }
50 | }
51 |
52 | @Serializable
53 | @SerialName("curseforge")
54 | data class CurseForgePublishResult(
55 | val projectId: String,
56 | val projectSlug: String?,
57 | val fileId: Int,
58 | override val title: String,
59 | ) : PublishResult() {
60 | override val type: String
61 | get() = "curseforge"
62 | override val link: String
63 | get() {
64 | if (projectSlug == null) {
65 | // Thanks CF...
66 | throw IllegalStateException("The CurseForge projectSlug property must be set to generate a link to the uploaded file")
67 | }
68 |
69 | return "https://curseforge.com/minecraft/mc-mods/$projectSlug/files/$fileId"
70 | }
71 | override val brandColor: Int
72 | get() = 0xF16436
73 | }
74 |
75 | @Serializable
76 | @SerialName("github")
77 | data class GithubPublishResult(
78 | val repository: String,
79 | val releaseId: Long,
80 | val url: String,
81 | override val title: String,
82 | ) : PublishResult() {
83 | override val type: String
84 | get() = "github"
85 | override val link: String
86 | get() = url
87 | override val brandColor: Int
88 | get() = 0xF6F0FC
89 | }
90 |
91 | @Serializable
92 | @SerialName("modrinth")
93 | data class ModrinthPublishResult(
94 | val id: String,
95 | val projectId: String,
96 | override val title: String,
97 | ) : PublishResult() {
98 | override val type: String
99 | get() = "modrinth"
100 | override val link: String
101 | get() = "https://modrinth.com/mod/$projectId/version/$id"
102 | override val brandColor: Int
103 | get() = 0x1BD96A
104 | }
105 |
106 | @Serializable
107 | @SerialName("gitea")
108 | data class GiteaPublishResult(
109 | val repository: String,
110 | val releaseId: Long,
111 | val url: String,
112 | override val title: String,
113 | override val brandColor: Int,
114 | ) : PublishResult() {
115 | override val type: String
116 | get() = "gitea"
117 | override val link: String
118 | get() = url
119 | }
120 |
121 | @ApiStatus.Internal
122 | class PublishContext(private val queue: WorkQueue, private val result: RegularFile) {
123 | fun submit(workActionClass: KClass>, parameterAction: Action) {
124 | queue.submit(workActionClass.java) {
125 | it.result.set(result)
126 | parameterAction.execute(it)
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/HttpUtils.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import kotlinx.serialization.json.Json
4 | import okhttp3.OkHttpClient
5 | import okhttp3.Request
6 | import okhttp3.RequestBody
7 | import okhttp3.Response
8 | import org.slf4j.LoggerFactory
9 | import java.io.IOException
10 | import java.time.Duration
11 |
12 | class HttpUtils(val exceptionFactory: HttpExceptionFactory = DefaultHttpExceptionFactory(), timeout: Duration = Duration.ofSeconds(30)) {
13 | val httpClient = OkHttpClient.Builder()
14 | .connectTimeout(timeout)
15 | .readTimeout(timeout)
16 | .writeTimeout(timeout)
17 | .addNetworkInterceptor { chain ->
18 | chain.proceed(
19 | chain.request()
20 | .newBuilder()
21 | .header("User-Agent", "modmuss50/mod-publish-plugin/${HttpUtils::class.java.`package`.implementationVersion}")
22 | .build(),
23 | )
24 | }
25 | .build()
26 | val json = Json { ignoreUnknownKeys = true }
27 |
28 | inline fun get(url: String, headers: Map): T {
29 | return request(
30 | Request.Builder()
31 | .url(url),
32 | headers,
33 | )
34 | }
35 |
36 | inline fun post(url: String, body: RequestBody, headers: Map): T {
37 | return request(
38 | Request.Builder()
39 | .url(url)
40 | .post(body),
41 | headers,
42 | )
43 | }
44 |
45 | inline fun patch(url: String, body: RequestBody, headers: Map): T {
46 | return request(
47 | Request.Builder()
48 | .url(url)
49 | .patch(body),
50 | headers,
51 | )
52 | }
53 |
54 | inline fun request(requestBuilder: Request.Builder, headers: Map): T {
55 | for ((name, value) in headers) {
56 | requestBuilder.header(name, value)
57 | }
58 |
59 | val request = requestBuilder.build()
60 | httpClient.newCall(request).execute().use { response ->
61 | if (!response.isSuccessful) {
62 | throw exceptionFactory.createException(response)
63 | }
64 |
65 | var body = response.body!!.string()
66 |
67 | if (body.isBlank()) {
68 | // Bit of a hack, but handle empty body's as an empty string.
69 | body = "\"\""
70 | }
71 |
72 | return json.decodeFromString(body)
73 | }
74 | }
75 |
76 | companion object {
77 | private val LOGGER = LoggerFactory.getLogger(HttpUtils::class.java)
78 |
79 | /**
80 | * Retry server errors
81 | */
82 | fun retry(maxRetries: Int, message: String, closure: () -> T): T {
83 | var exception: RuntimeException? = null
84 | var count = 0
85 |
86 | while (count < maxRetries) {
87 | try {
88 | return closure()
89 | } catch (e: Exception) {
90 | count++
91 | exception = exception ?: RuntimeException("$message after $maxRetries attempts with error: ${e.message}")
92 | exception.addSuppressed(e)
93 | }
94 | }
95 |
96 | LOGGER.error("$message failed after $maxRetries retries", exception)
97 | throw exception!!
98 | }
99 | }
100 |
101 | interface HttpExceptionFactory {
102 | fun createException(response: Response): HttpException
103 | }
104 |
105 | private class DefaultHttpExceptionFactory : HttpExceptionFactory {
106 | override fun createException(response: Response): HttpException {
107 | return HttpException(response, response.body?.string() ?: response.message)
108 | }
109 | }
110 |
111 | class HttpException(val response: Response, message: String) : IOException("Request failed, status: ${response.code} message: $message url: ${response.request.url}")
112 | }
113 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/platforms/gitea/GiteaApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.platforms.gitea
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 | import kotlinx.serialization.SerializationException
6 | import kotlinx.serialization.encodeToString
7 | import kotlinx.serialization.json.Json
8 | import me.modmuss50.mpp.HttpUtils
9 | import okhttp3.MediaType.Companion.toMediaTypeOrNull
10 | import okhttp3.MultipartBody
11 | import okhttp3.RequestBody.Companion.asRequestBody
12 | import okhttp3.RequestBody.Companion.toRequestBody
13 | import okhttp3.Response
14 | import java.io.File
15 |
16 | class GiteaApi(private val accessToken: String, private val baseUrl: String, private val repository: String) {
17 | private val httpUtils = HttpUtils(
18 | exceptionFactory = GiteaHttpExceptionFactory(),
19 | )
20 |
21 | @Serializable
22 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoGetRelease
23 | data class Release(
24 | val id: Long,
25 | @SerialName("html_url")
26 | val htmlUrl: String,
27 | @SerialName("upload_url")
28 | val uploadUrl: String,
29 | )
30 |
31 | // Some of the below are nullable, but we don't need their nullability here.
32 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoCreateRelease
33 | @Serializable
34 | data class CreateRelease(
35 | val body: String? = null,
36 | val draft: Boolean,
37 | val name: String? = null,
38 | val prerelease: Boolean,
39 | @SerialName("tag_name")
40 | val tagName: String,
41 | val targetCommitish: String,
42 | )
43 |
44 | // Error responses are consistent between hooks.
45 | @Serializable
46 | data class ErrorResponse(
47 | val message: String,
48 | val url: String,
49 | )
50 |
51 | private val headers: Map
52 | get() = mapOf(
53 | "Authorization" to "token $accessToken",
54 | "Content-Type" to "application/json",
55 | )
56 |
57 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoCreateRelease
58 | fun createRelease(metadata: CreateRelease): Release {
59 | val body = Json.encodeToString(metadata).toRequestBody()
60 | return httpUtils.post("$baseUrl/repos/$repository/releases", body, headers)
61 | }
62 |
63 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoGetRelease
64 | fun getRelease(id: Long): Release {
65 | return httpUtils.get("$baseUrl/repos/$repository/releases/$id", headers)
66 | }
67 |
68 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoCreateReleaseAttachment
69 | fun uploadAsset(release: Release, file: File) {
70 | val mediaType = "application/java-archive".toMediaTypeOrNull()
71 |
72 | val bodyBuilder = MultipartBody.Builder()
73 | .setType(MultipartBody.FORM)
74 | .addFormDataPart("attachment", file.name, file.asRequestBody(mediaType))
75 |
76 | return httpUtils.post(release.uploadUrl, bodyBuilder.build(), headers)
77 | }
78 |
79 | // https://docs.gitea.com/api/1.24/#tag/repository/operation/repoEditRelease
80 | fun publishRelease(release: Release) {
81 | val body = """
82 | {
83 | "draft": false
84 | }
85 | """.trimIndent().toRequestBody()
86 | return httpUtils.patch("$baseUrl/repos/$repository/releases/${release.id}", body, headers)
87 | }
88 |
89 | // Error responses are consistent between hooks.
90 | private class GiteaHttpExceptionFactory : HttpUtils.HttpExceptionFactory {
91 | val json = Json { ignoreUnknownKeys = true }
92 |
93 | override fun createException(response: Response): HttpUtils.HttpException {
94 | return try {
95 | val errorResponse = json.decodeFromString(response.body!!.string())
96 | HttpUtils.HttpException(response, errorResponse.message)
97 | } catch (e: SerializationException) {
98 | HttpUtils.HttpException(response, "Unknown error")
99 | }
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/docs/pages/platforms/discord.mdx:
--------------------------------------------------------------------------------
1 | ## Basic example
2 |
3 | 
4 |
5 | See the following minimal example showing how a message can be sent to a discord channel using a webhook.
6 |
7 | ```groovy
8 | publishMods {
9 | // ...
10 | discord {
11 | webhookUrl = providers.environmentVariable("DISCORD_WEBHOOK")
12 | }
13 | }
14 | ```
15 |
16 | Please note, when using the discord webhook you must also specify the `projectSlug` in any CurseForge publications. This is due to limitations in the CurseForge API.
17 |
18 | By default discord will publish a message with the changelog to the supplied webhook. A link to all of the platforms will also be included.
19 |
20 | ```groovy
21 | publishMods {
22 | // ...
23 | discord {
24 | // Pass in the secret webhook URL. Required
25 | webhookUrl = providers.environmentVariable("DISCORD_WEBHOOK")
26 |
27 | // Pass in the secret webhook URL to post the messages when running a dry run. Optional
28 | dryRunWebhookUrl = providers.environmentVariable("DISCORD_WEBHOOK_DRY_RUN")
29 |
30 | // Set the username used to send the webhook, defaults to "Mod Publish Plugin"
31 | username = "My Cool Mod"
32 |
33 | // Set the avatar image url for the webhook, defaults to none.
34 | avatarUrl = "https://placekitten.com/500/500"
35 |
36 | // Set the content message, in this example a header is added before the changelog. Defaults to just the changelog
37 | content = changelog.map { "# A new version of my cool mod has been released! \n" + it}
38 |
39 | // If you wish to only link to certain platform you can do the following
40 | setPlatforms(publishMods.platforms.curseforge, publishMods.platforms.github)
41 |
42 | // Instead if you wish to link to all platform in a specific Gradle project you can do the following
43 | setPlatformsAllFrom(project(":child1"), project(":child2"))
44 | }
45 | }
46 | ```
47 |
48 | You can customise the platform specific message by using the `announcementTitle` property present on all of the platforms. This will be used as the title of the embed.
49 |
50 | ```groovy
51 | publishMods {
52 | curseforge {
53 | announcementTitle = "Download from CurseForge"
54 | }
55 | }
56 | ```
57 |
58 | The webhook message can be customized to different styles
59 | The look can be set to `CLASSIC` or `MODERN`
60 |
61 |
62 | Look examples
63 | ## Classic look
64 | The classic look is used by default by the plugin
65 | 
66 |
67 | ## Modern look
68 | 
69 |
70 | ```groovy
71 | publishMods {
72 | // ...
73 | discord {
74 | // ...
75 | style {
76 | // ...
77 | look = "MODERN"
78 | }
79 | }
80 | }
81 | ```
82 |
83 | The style tag also has the options `thumbnailUrl` and `color`, this options allow you to customize more the look of your embeds
84 |
85 | The `color` option must be a string, the string must be a full `RRGGBB` hex prefixed with a `#`.
86 | The string can also be `modrinth`, `github` or `curseforge`, with those working as alias for the colors used for the embed links for those platforms
87 |
88 | 
89 | ```groovy
90 | publishMods {
91 | // ...
92 | discord {
93 | // ...
94 | style {
95 | // ...
96 | look = "MODERN"
97 | thumbnailUrl = "https://example.com"
98 | color = "#f6f0fc"
99 | }
100 | }
101 | }
102 | ```
103 |
104 |
105 | You can also set your links to be posted as `EMBED` or `BUTTON`
106 |
107 | Link examples
108 | ## Embed link
109 | The embed links are used by default by the plugin
110 | 
111 | ## Button links
112 | 
113 |
114 | ```groovy
115 | publishMods {
116 | // ...
117 | discord {
118 | // ...
119 | style {
120 | // ...
121 | link = "BUTTON"
122 | }
123 | }
124 | }
125 | ```
126 |
127 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/PublishModTask.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import kotlinx.serialization.encodeToString
4 | import kotlinx.serialization.json.Json
5 | import me.modmuss50.mpp.platforms.gitea.GiteaOptions
6 | import me.modmuss50.mpp.platforms.github.GithubOptions
7 | import org.gradle.api.DefaultTask
8 | import org.gradle.api.file.DirectoryProperty
9 | import org.gradle.api.file.RegularFileProperty
10 | import org.gradle.api.provider.Property
11 | import org.gradle.api.tasks.Input
12 | import org.gradle.api.tasks.Nested
13 | import org.gradle.api.tasks.OutputDirectory
14 | import org.gradle.api.tasks.OutputFile
15 | import org.gradle.api.tasks.TaskAction
16 | import org.gradle.work.DisableCachingByDefault
17 | import org.gradle.workers.WorkerExecutor
18 | import org.jetbrains.annotations.ApiStatus
19 | import java.io.FileNotFoundException
20 | import javax.inject.Inject
21 |
22 | @DisableCachingByDefault(because = "Re-upload mod each time")
23 | abstract class PublishModTask @Inject constructor(@Nested val platform: Platform) : DefaultTask() {
24 | @get:ApiStatus.Internal
25 | @get:Input
26 | abstract val dryRun: Property
27 |
28 | @get:ApiStatus.Internal
29 | @get:OutputFile
30 | abstract val result: RegularFileProperty
31 |
32 | @get:ApiStatus.Internal
33 | @get:OutputDirectory
34 | abstract val dryRunDirectory: DirectoryProperty
35 |
36 | @get:Inject
37 | protected abstract val workerExecutor: WorkerExecutor
38 |
39 | init {
40 | group = "publishing"
41 | outputs.upToDateWhen { false }
42 | dryRun.set(project.modPublishExtension.dryRun)
43 | dryRun.finalizeValue()
44 | result.set(project.layout.buildDirectory.file("publishMods/$name.json"))
45 | result.finalizeValue()
46 | dryRunDirectory.set(project.layout.buildDirectory.dir("publishMods/$name"))
47 | dryRunDirectory.finalizeValue()
48 | }
49 |
50 | @TaskAction
51 | fun publish() {
52 | platform.validateInputs()
53 |
54 | if (dryRun.get()) {
55 | logger.lifecycle("Dry run $name:")
56 | platform.printDryRunInfo(logger)
57 |
58 | dryRunCopyMainFile()
59 |
60 | for (additionalFile in platform.additionalFiles.files) {
61 | if (!additionalFile.exists()) {
62 | throw FileNotFoundException("$additionalFile not found")
63 | }
64 |
65 | additionalFile.copyTo(dryRunDirectory.get().asFile.resolve(additionalFile.name), overwrite = true)
66 | logger.lifecycle("Additional file: ${additionalFile.name}")
67 | }
68 |
69 | logger.lifecycle("Display name: ${platform.displayName.get()}")
70 | logger.lifecycle("Version: ${platform.version.get()}")
71 | logger.lifecycle("Changelog: ${platform.changelog.get()}")
72 |
73 | result.get().asFile.writeText(
74 | Json.encodeToString(platform.dryRunPublishResult()),
75 | )
76 |
77 | return
78 | }
79 |
80 | // Ensure that we have an access token when not dry running.
81 | platform.accessToken.get()
82 |
83 | val workQueue = workerExecutor.noIsolation()
84 | val context = PublishContext(queue = workQueue, result = result.get())
85 | platform.publish(context)
86 | }
87 |
88 | private fun dryRunCopyMainFile() {
89 | // A bit of a hack to handle the optional main file for GitHub.
90 | if (platform is GithubOptions) {
91 | if (!platform.file.isPresent && platform.allowEmptyFiles.get()) {
92 | return
93 | }
94 | }
95 |
96 | // Repeat the hack for Gitea.
97 | if (platform is GiteaOptions) {
98 | if (!platform.file.isPresent && platform.allowEmptyFiles.get()) {
99 | return
100 | }
101 | }
102 |
103 | val file = platform.file.get().asFile
104 |
105 | if (!file.exists()) {
106 | throw FileNotFoundException("$file not found")
107 | }
108 |
109 | file.copyTo(dryRunDirectory.get().asFile.resolve(file.name), overwrite = true)
110 | logger.lifecycle("Main file: ${file.name}")
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/IntegrationTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test
2 |
3 | import org.gradle.testkit.runner.BuildResult
4 | import org.gradle.testkit.runner.GradleRunner
5 | import org.intellij.lang.annotations.Language
6 | import java.io.File
7 |
8 | interface IntegrationTest {
9 | companion object {
10 | @Language("gradle")
11 | val kotlinHeader = """
12 | plugins {
13 | java
14 | id("me.modmuss50.mod-publish-plugin")
15 | }
16 | """.trimIndent()
17 |
18 | @Language("gradle")
19 | val groovyHeader = """
20 | plugins {
21 | id 'java'
22 | id 'me.modmuss50.mod-publish-plugin'
23 | }
24 | """.trimIndent()
25 | }
26 |
27 | fun gradleTest(groovy: Boolean = false): TestBuilder {
28 | return TestBuilder(groovy)
29 | }
30 |
31 | class TestBuilder(groovy: Boolean) {
32 | private val runner = GradleRunner.create()
33 | .withPluginClasspath()
34 | .forwardOutput()
35 | .withDebug(true)
36 |
37 | private val gradleHome: File
38 | private val projectDir: File
39 | private val buildScript: File
40 | private val gradleSettings: File
41 | private var arguments = ArrayList()
42 | private var notConfigCacheCompatible = false
43 |
44 | init {
45 | val testDir = File("build/intergation_test")
46 | val ext = if (groovy) { "" } else { ".kts" }
47 | gradleHome = File(testDir, "home")
48 | projectDir = File(testDir, "project")
49 | buildScript = File(projectDir, "build.gradle$ext")
50 | gradleSettings = File(projectDir, "settings.gradle$ext")
51 |
52 | projectDir.mkdirs()
53 |
54 | // Clean up
55 | File(projectDir, "build.gradle").delete()
56 | File(projectDir, "build.gradle.kts").delete()
57 | File(projectDir, "settings.gradle").delete()
58 | File(projectDir, "settings.gradle.kts").delete()
59 |
60 | // Create a fmj for modrith
61 | val resources = File(projectDir, "src/main/resources")
62 | resources.mkdirs()
63 | File(resources, "fabric.mod.json").writeText("{}")
64 |
65 | buildScript(if (groovy) groovyHeader else kotlinHeader)
66 |
67 | gradleSettings.writeText("rootProject.name = \"mpp-example\"")
68 |
69 | runner.withProjectDir(projectDir)
70 | argument("--gradle-user-home", gradleHome.absolutePath)
71 | argument("--stacktrace")
72 | argument("--warning-mode", "fail")
73 | argument("clean")
74 | }
75 |
76 | // Disables the configuration cache for this test
77 | fun notConfigCacheCompatible(): TestBuilder {
78 | notConfigCacheCompatible = true
79 | return this
80 | }
81 |
82 | // Appends to an existing buildscript
83 | fun buildScript(@Language("gradle") script: String): TestBuilder {
84 | buildScript.appendText(script + "\n")
85 | return this
86 | }
87 |
88 | // Creates a new file in the project directory with the given content
89 | fun file(path: String, content: String): TestBuilder {
90 | val file = File(projectDir, path)
91 | file.writeText(content)
92 | return this
93 | }
94 |
95 | fun subProject(name: String, @Language("gradle") script: String = ""): TestBuilder {
96 | val subProjectDir = File(projectDir, name)
97 |
98 | if (subProjectDir.exists()) {
99 | subProjectDir.deleteRecursively()
100 | }
101 |
102 | subProjectDir.mkdirs()
103 |
104 | val subBuildScript = File(subProjectDir, "build.gradle.kts")
105 | subBuildScript.appendText(kotlinHeader + "\n")
106 | subBuildScript.appendText(script)
107 |
108 | gradleSettings.appendText("\ninclude(\"$name\")")
109 |
110 | return this
111 | }
112 |
113 | fun argument(vararg args: String) {
114 | arguments.addAll(args)
115 | }
116 |
117 | fun run(task: String): BuildResult {
118 | if (!notConfigCacheCompatible) {
119 | argument("--configuration-cache")
120 | }
121 | argument(task)
122 | runner.withArguments(arguments)
123 | return runner.run()
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/test/kotlin/me/modmuss50/mpp/test/discord/DiscordIntegrationTest.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.test.discord
2 |
3 | import kotlinx.serialization.json.Json
4 | import me.modmuss50.mpp.test.IntegrationTest
5 | import me.modmuss50.mpp.test.ProductionOptions
6 | import org.gradle.testkit.runner.TaskOutcome
7 | import org.intellij.lang.annotations.Language
8 | import java.io.File
9 | import kotlin.test.Ignore
10 | import kotlin.test.Test
11 | import kotlin.test.assertEquals
12 |
13 | /**
14 | * Enable this test to execute a dry run, this is more to test the discord integration. Create a options.json with all the tokens
15 | */
16 | @Ignore
17 | class DiscordIntegrationTest : IntegrationTest {
18 | @Test
19 | fun run() {
20 | //region Classic message body
21 | var result = gradleTest()
22 | .buildScript(
23 | createScript(""),
24 | )
25 | .run("publishMods")
26 |
27 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
28 |
29 | result = gradleTest()
30 | .buildScript(
31 | createScript(
32 | """
33 | style {
34 | link = "BUTTON"
35 | }
36 | """.trimIndent(),
37 | ),
38 | )
39 | .run("publishMods")
40 |
41 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
42 |
43 | result = gradleTest()
44 | .buildScript(
45 | createScript(
46 | """
47 | style {
48 | link = "INLINE"
49 | }
50 | """.trimIndent(),
51 | ),
52 | )
53 | .run("publishMods")
54 |
55 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
56 | //endregion
57 |
58 | //region Modern message body
59 | result = gradleTest()
60 | .buildScript(
61 | createScript(
62 | """
63 | style {
64 | look = "MODERN"
65 | }
66 | """.trimIndent(),
67 | ),
68 | )
69 | .run("publishMods")
70 |
71 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
72 |
73 | result = gradleTest()
74 | .buildScript(
75 | createScript(
76 | """
77 | style {
78 | look = "MODERN"
79 | link = "BUTTON"
80 | }
81 | """.trimIndent(),
82 | ),
83 | )
84 | .run("publishMods")
85 |
86 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
87 |
88 | result = gradleTest()
89 | .buildScript(
90 | createScript(
91 | """
92 | style {
93 | look = "MODERN"
94 | link = "INLINE"
95 | }
96 | """.trimIndent(),
97 | ),
98 | )
99 | .run("publishMods")
100 |
101 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome)
102 | //endregion
103 | }
104 |
105 | private fun createScript(@Language("gradle") style: String): String {
106 | val json = Json { ignoreUnknownKeys = true }
107 | val options = json.decodeFromString(File("options.json").readText())
108 |
109 | @Language("gradle")
110 | val buildScript = """
111 | publishMods {
112 | file = tasks.jar.flatMap { it.archiveFile }
113 | changelog = "- Changelog line 1\n- Changelog line 2"
114 | version = "1.0.0"
115 | type = BETA
116 | modLoaders.add("fabric")
117 | displayName = "Test Upload"
118 | dryRun = true
119 |
120 | curseforge {
121 | accessToken = "${options.curseforgeToken}"
122 | projectId = "${options.curseforgeProject}"
123 | projectSlug = "${options.curseforgeProjectSlug}"
124 | minecraftVersions.add("1.20.1")
125 | javaVersions.add(JavaVersion.VERSION_17)
126 | clientRequired = true
127 | serverRequired = true
128 |
129 | requires {
130 | slug = "fabric-api"
131 | }
132 | }
133 |
134 | // github {
135 | // accessToken = "${options.githubToken}"
136 | // repository = "${options.githubRepo}"
137 | // commitish = "main"
138 | // }
139 |
140 | modrinth {
141 | accessToken = "${options.modrinthToken}"
142 | projectId = "${options.modrinthProject}"
143 | minecraftVersions.add("1.20.1")
144 |
145 | requires {
146 | id = "P7dR8mSH"
147 | }
148 | }
149 |
150 | discord {
151 | username = "Great test mod"
152 | avatarUrl = "https://placekitten.com/500/500"
153 | content = changelog.map { "## A new version of my mod has been uploaded:\n" + it }
154 | webhookUrl = "${options.discordWebhook}"
155 | dryRunWebhookUrl = "${options.discordWebhook}"
156 | $style
157 | }
158 | }
159 | """
160 |
161 | return buildScript.trimIndent()
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/platforms/discord/DiscordAPI.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.platforms.discord
2 |
3 | import kotlinx.serialization.ExperimentalSerializationApi
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 | import kotlinx.serialization.encodeToString
7 | import kotlinx.serialization.json.Json
8 | import me.modmuss50.mpp.HttpUtils
9 | import okhttp3.RequestBody.Companion.toRequestBody
10 |
11 | object DiscordAPI {
12 | @OptIn(ExperimentalSerializationApi::class)
13 | val json = Json { explicitNulls = false; classDiscriminator = "class"; encodeDefaults = true }
14 | private val httpUtils = HttpUtils()
15 | private val headers: Map = mapOf("Content-Type" to "application/json")
16 |
17 | // https://discord.com/developers/docs/resources/webhook#execute-webhook
18 | fun executeWebhook(url: String, webhook: Webhook) {
19 | val body = json.encodeToString(webhook).toRequestBody()
20 | httpUtils.post(url, body, headers)
21 | }
22 |
23 | // https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params
24 | @Serializable
25 | data class Webhook(
26 | val content: String? = null,
27 | val username: String? = null,
28 | @SerialName("avatar_url")
29 | val avatarUrl: String? = null,
30 | val tts: Boolean? = null,
31 | val embeds: List