├── .gitattributes
├── .github
└── workflows
│ ├── docs.yml
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── build.gradle.kts
├── docs
├── .gitignore
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── pages
│ ├── _meta.json
│ ├── getting_started.mdx
│ ├── github_actions.mdx
│ ├── index.mdx
│ ├── multi_platform.mdx
│ ├── options.mdx
│ ├── platforms
│ │ ├── curseforge.mdx
│ │ ├── discord.mdx
│ │ ├── github.mdx
│ │ └── modrinth.mdx
│ └── shared_options.mdx
├── public
│ └── images
│ │ ├── discord_button.png
│ │ ├── discord_example.png
│ │ ├── discord_modern.png
│ │ └── discord_modern_button_thumbnail.png
├── theme.config.tsx
└── tsconfig.json
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
├── main
└── kotlin
│ └── me
│ └── modmuss50
│ └── mpp
│ ├── HttpUtils.kt
│ ├── MinecraftApi.kt
│ ├── ModPublishExtension.kt
│ ├── MppPlugin.kt
│ ├── Platform.kt
│ ├── PlatformInternal.kt
│ ├── PublishModTask.kt
│ ├── PublishOptions.kt
│ ├── ReleaseType.kt
│ ├── Validators.kt
│ └── platforms
│ ├── curseforge
│ ├── Curseforge.kt
│ ├── CurseforgeApi.kt
│ └── CurseforgeVersions.kt
│ ├── discord
│ ├── DiscordAPI.kt
│ └── DiscordWebhookTask.kt
│ ├── github
│ └── Github.kt
│ └── modrinth
│ ├── Modrinth.kt
│ └── ModrinthApi.kt
└── test
├── kotlin
└── me
│ └── modmuss50
│ └── mpp
│ └── test
│ ├── IntegrationTest.kt
│ ├── MockWebServer.kt
│ ├── ProductionTest.kt
│ ├── curseforge
│ ├── CurseforgeTest.kt
│ ├── CurseforgeVersionsTest.kt
│ └── MockCurseforgeApi.kt
│ ├── discord
│ ├── BotTokenGenerator.kt
│ ├── DiscordIntegrationTest.kt
│ ├── DiscordTest.kt
│ └── MockDiscordApi.kt
│ ├── github
│ ├── GithubTest.kt
│ └── MockGithubApi.kt
│ ├── misc
│ ├── MinecraftApiTest.kt
│ ├── MockMinecraftApi.kt
│ ├── MultiPlatformTest.kt
│ ├── OptionsTest.kt
│ └── PublishResultTest.kt
│ └── modrinth
│ ├── MockModrinthApi.kt
│ └── ModrinthTest.kt
└── resources
├── curseforge_version_types.json
├── curseforge_versions.json
└── version_manifest_v2.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | *.bat text eol=crlf
--------------------------------------------------------------------------------
/.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/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
--------------------------------------------------------------------------------
/.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/wrapper-validation-action@v2
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
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 "0.8.4"
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.
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2 |
3 | plugins {
4 | `java-gradle-plugin`
5 | `maven-publish`
6 | embeddedKotlin("jvm")
7 | embeddedKotlin("plugin.serialization")
8 | id("com.diffplug.spotless") version "6.18.0"
9 | id("com.gradle.plugin-publish") version "1.2.1"
10 | }
11 |
12 | group = "me.modmuss50"
13 | version = "0.8.4"
14 | description = "The Mod Publish Plugin is a plugin for the Gradle build system to help upload artifacts to a range of common destinations."
15 |
16 | repositories {
17 | mavenCentral()
18 | }
19 |
20 | dependencies {
21 | implementation(gradleApi())
22 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
23 | implementation("com.squareup.okhttp3:okhttp:4.12.0")
24 | implementation("org.kohsuke:github-api:1.324")
25 |
26 | testImplementation(kotlin("test"))
27 | testImplementation("io.javalin:javalin:6.3.0")
28 | testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
29 | testRuntimeOnly("org.junit.platform:junit-platform-launcher")
30 | }
31 |
32 | tasks.withType(KotlinCompile::class.java).all {
33 | kotlinOptions {
34 | jvmTarget = "1.8"
35 | freeCompilerArgs = listOf("-Xjvm-default=all")
36 | }
37 | }
38 |
39 | // Workaround https://github.com/gradle/gradle/issues/25898
40 | tasks.withType(Test::class.java).configureEach {
41 | jvmArgs = listOf(
42 | "--add-opens=java.base/java.lang=ALL-UNNAMED",
43 | "--add-opens=java.base/java.util=ALL-UNNAMED",
44 | "--add-opens=java.base/java.lang.invoke=ALL-UNNAMED",
45 | "--add-opens=java.base/java.net=ALL-UNNAMED"
46 | )
47 | }
48 |
49 | tasks.withType(JavaCompile::class.java).all {
50 | options.release = 8
51 | }
52 |
53 | tasks.test {
54 | useJUnitPlatform()
55 | }
56 |
57 | java {
58 | withSourcesJar()
59 | }
60 |
61 | tasks.jar {
62 | manifest {
63 | attributes(mapOf("Implementation-Version" to version))
64 | }
65 | }
66 |
67 | spotless {
68 | lineEndings = com.diffplug.spotless.LineEnding.UNIX
69 | kotlin {
70 | ktlint()
71 | }
72 | }
73 |
74 | gradlePlugin {
75 | website = "https://github.com/modmuss50/mod-publish-plugin"
76 | vcsUrl = "https://github.com/modmuss50/mod-publish-plugin"
77 | testSourceSet(sourceSets["test"])
78 |
79 | plugins.create("mod-publish-plugin") {
80 | id = "me.modmuss50.mod-publish-plugin"
81 | implementationClass = "me.modmuss50.mpp.MppPlugin"
82 | displayName = "Mod Publish Plugin"
83 | description = project.description
84 | version = project.version
85 | tags = listOf("minecraft", )
86 | }
87 | }
88 |
89 | fun replaceVersion(path: String) {
90 | var content = project.file(path).readText()
91 |
92 | content = content.replace("(version \").*(\")".toRegex(), "version \"${project.version}\"")// project.version.toString())
93 |
94 | project.file(path).writeText(content)
95 | }
96 |
97 | replaceVersion("README.md")
98 | replaceVersion("docs/pages/getting_started.mdx")
99 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/docs/pages/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "index": "Home",
3 | "getting_started": "Getting Started",
4 | "platforms": "Platforms"
5 | }
6 |
--------------------------------------------------------------------------------
/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 "0.8.4"
12 | }
13 | ```
14 |
15 |
16 | ```kotlin
17 | plugins {
18 | id("me.modmuss50.mod-publish-plugin") version "0.8.4"
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/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.
--------------------------------------------------------------------------------
/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 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 |
13 | ### FAQ
14 | - Why use Kotlin?
15 | - I wanted to explore using Kotlin for a larger project, as Gradle already includes Kotlin there should be little downside to the end user.
16 | - Gradle includes Kotlin so there is no additional dependency for the end user.
17 | - Groovy buildscripts are fully supported.
18 | - Why do I need to specify the minecraft versions for both CurseForge and Modrinth?
19 | - Curseforge and Modrinth use different versioning for snapshots, thus they must be defined for each platform.
20 | - Feature x or platform y is not supported
21 | - Please open an issue on this repo!
22 | - I have a question and need some help
23 | - Please use the mod publish plugin discord channel on the [Team Reborn Discord](https://discord.gg/teamreborn)
24 |
25 | ### Changelog
26 | #### 0.8.4
27 | - Fix project files attempting to include transitive dependencies
28 | #### 0.8.3
29 | - Add helper to depend on files from another subproject
30 | - Add documentation and tests for the common use case of multi platform setups
31 | #### 0.8.2
32 | - Allow providing the version name instead of version id for a modrinth dependency
33 | #### 0.8.1
34 | - Fix to accommodate a change in Modrinth's API for updating project descriptions
35 | #### 0.8.0
36 | - Add modern Discord message styles and link button and inline link support
37 | - Support setting CurseForge additional file display name.
38 | - Fail on duplicate Github file names.
39 | - Use `convention` instead of `set` in `from` functions, removes the need for `from` to be first.
40 |
--------------------------------------------------------------------------------
/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 | ```
--------------------------------------------------------------------------------
/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 | ```
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 | ```
--------------------------------------------------------------------------------
/docs/public/images/discord_button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/918886e7f0846a347ee01c3b7c65e5bfe52b4125/docs/public/images/discord_button.png
--------------------------------------------------------------------------------
/docs/public/images/discord_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/918886e7f0846a347ee01c3b7c65e5bfe52b4125/docs/public/images/discord_example.png
--------------------------------------------------------------------------------
/docs/public/images/discord_modern.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/918886e7f0846a347ee01c3b7c65e5bfe52b4125/docs/public/images/discord_modern.png
--------------------------------------------------------------------------------
/docs/public/images/discord_modern_button_thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/918886e7f0846a347ee01c3b7c65e5bfe52b4125/docs/public/images/discord_modern_button_thumbnail.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/modmuss50/mod-publish-plugin/918886e7f0846a347ee01c3b7c65e5bfe52b4125/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
90 | ' "$PWD" ) || exit
91 |
92 | # Use the maximum available, or set MAX_FD != -1 to use that value.
93 | MAX_FD=maximum
94 |
95 | warn () {
96 | echo "$*"
97 | } >&2
98 |
99 | die () {
100 | echo
101 | echo "$*"
102 | echo
103 | exit 1
104 | } >&2
105 |
106 | # OS specific support (must be 'true' or 'false').
107 | cygwin=false
108 | msys=false
109 | darwin=false
110 | nonstop=false
111 | case "$( uname )" in #(
112 | CYGWIN* ) cygwin=true ;; #(
113 | Darwin* ) darwin=true ;; #(
114 | MSYS* | MINGW* ) msys=true ;; #(
115 | NONSTOP* ) nonstop=true ;;
116 | esac
117 |
118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
119 |
120 |
121 | # Determine the Java command to use to start the JVM.
122 | if [ -n "$JAVA_HOME" ] ; then
123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
124 | # IBM's JDK on AIX uses strange locations for the executables
125 | JAVACMD=$JAVA_HOME/jre/sh/java
126 | else
127 | JAVACMD=$JAVA_HOME/bin/java
128 | fi
129 | if [ ! -x "$JAVACMD" ] ; then
130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
131 |
132 | Please set the JAVA_HOME variable in your environment to match the
133 | location of your Java installation."
134 | fi
135 | else
136 | JAVACMD=java
137 | if ! command -v java >/dev/null 2>&1
138 | then
139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
140 |
141 | Please set the JAVA_HOME variable in your environment to match the
142 | location of your Java installation."
143 | fi
144 | fi
145 |
146 | # Increase the maximum file descriptors if we can.
147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
148 | case $MAX_FD in #(
149 | max*)
150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
151 | # shellcheck disable=SC2039,SC3045
152 | MAX_FD=$( ulimit -H -n ) ||
153 | warn "Could not query maximum file descriptor limit"
154 | esac
155 | case $MAX_FD in #(
156 | '' | soft) :;; #(
157 | *)
158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
159 | # shellcheck disable=SC2039,SC3045
160 | ulimit -n "$MAX_FD" ||
161 | warn "Could not set maximum file descriptor limit to $MAX_FD"
162 | esac
163 | fi
164 |
165 | # Collect all arguments for the java command, stacking in reverse order:
166 | # * args from the command line
167 | # * the main class name
168 | # * -classpath
169 | # * -D...appname settings
170 | # * --module-path (only if needed)
171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
172 |
173 | # For Cygwin or MSYS, switch paths to Windows format before running java
174 | if "$cygwin" || "$msys" ; then
175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
177 |
178 | JAVACMD=$( cygpath --unix "$JAVACMD" )
179 |
180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
181 | for arg do
182 | if
183 | case $arg in #(
184 | -*) false ;; # don't mess with options #(
185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
186 | [ -e "$t" ] ;; #(
187 | *) false ;;
188 | esac
189 | then
190 | arg=$( cygpath --path --ignore --mixed "$arg" )
191 | fi
192 | # Roll the args list around exactly as many times as the number of
193 | # args, so each arg winds up back in the position where it started, but
194 | # possibly modified.
195 | #
196 | # NB: a `for` loop captures its iteration list before it begins, so
197 | # changing the positional parameters here affects neither the number of
198 | # iterations, nor the values presented in `arg`.
199 | shift # remove old arg
200 | set -- "$@" "$arg" # push replacement arg
201 | done
202 | fi
203 |
204 |
205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
207 |
208 | # Collect all arguments for the java command:
209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
210 | # and any embedded shellness will be escaped.
211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
212 | # treated as '${Hostname}' itself on the command line.
213 |
214 | set -- \
215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
216 | -classpath "$CLASSPATH" \
217 | org.gradle.wrapper.GradleWrapperMain \
218 | "$@"
219 |
220 | # Stop when "xargs" is not available.
221 | if ! command -v xargs >/dev/null 2>&1
222 | then
223 | die "xargs is not available"
224 | fi
225 |
226 | # Use "xargs" to parse quoted args.
227 | #
228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
229 | #
230 | # In Bash we could simply go:
231 | #
232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
233 | # set -- "${ARGS[@]}" "$@"
234 | #
235 | # but POSIX shell has neither arrays nor command substitution, so instead we
236 | # post-process each arg (as a line of input to sed) to backslash-escape any
237 | # character that might be a shell metacharacter, then use eval to reverse
238 | # that process (while maintaining the separation between arguments), and wrap
239 | # the whole thing up as a single "set" statement.
240 | #
241 | # This will of course break if any of these variables contains a newline or
242 | # an unmatched quote.
243 | #
244 |
245 | eval "set -- $(
246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
247 | xargs -n1 |
248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
249 | tr '\n' ' '
250 | )" '"$@"'
251 |
252 | exec "$JAVACMD" "$@"
253 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "mod-publish-plugin"
--------------------------------------------------------------------------------
/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.lang.RuntimeException
11 | import java.time.Duration
12 |
13 | class HttpUtils(val exceptionFactory: HttpExceptionFactory = DefaultHttpExceptionFactory(), timeout: Duration = Duration.ofSeconds(30)) {
14 | val httpClient = OkHttpClient.Builder()
15 | .connectTimeout(timeout)
16 | .readTimeout(timeout)
17 | .writeTimeout(timeout)
18 | .addNetworkInterceptor { chain ->
19 | chain.proceed(
20 | chain.request()
21 | .newBuilder()
22 | .header("User-Agent", "modmuss50/mod-publish-plugin/${HttpUtils::class.java.`package`.implementationVersion}")
23 | .build(),
24 | )
25 | }
26 | .build()
27 | val json = Json { ignoreUnknownKeys = true }
28 |
29 | inline fun get(url: String, headers: Map): T {
30 | return request(
31 | Request.Builder()
32 | .url(url),
33 | headers,
34 | )
35 | }
36 |
37 | inline fun post(url: String, body: RequestBody, headers: Map): T {
38 | return request(
39 | Request.Builder()
40 | .url(url)
41 | .post(body),
42 | headers,
43 | )
44 | }
45 |
46 | inline fun patch(url: String, body: RequestBody, headers: Map): T {
47 | return request(
48 | Request.Builder()
49 | .url(url)
50 | .patch(body),
51 | headers,
52 | )
53 | }
54 |
55 | inline fun request(requestBuilder: Request.Builder, headers: Map): T {
56 | for ((name, value) in headers) {
57 | requestBuilder.header(name, value)
58 | }
59 |
60 | val request = requestBuilder.build()
61 | httpClient.newCall(request).execute().use { response ->
62 | if (!response.isSuccessful) {
63 | throw exceptionFactory.createException(response)
64 | }
65 |
66 | var body = response.body!!.string()
67 |
68 | if (body.isBlank()) {
69 | // Bit of a hack, but handle empty body's as an empty string.
70 | body = "\"\""
71 | }
72 |
73 | return json.decodeFromString(body)
74 | }
75 | }
76 |
77 | companion object {
78 | private val LOGGER = LoggerFactory.getLogger(HttpUtils::class.java)
79 |
80 | /**
81 | * Retry server errors
82 | */
83 | fun retry(maxRetries: Int, message: String, closure: () -> T): T {
84 | var exception: RuntimeException? = null
85 | var count = 0
86 |
87 | while (count < maxRetries) {
88 | try {
89 | return closure()
90 | } catch (e: HttpException) {
91 | if (e.response.code / 100 != 5) {
92 | throw e
93 | }
94 |
95 | // Only retry 5xx server errors
96 | count++
97 | exception = exception ?: RuntimeException("$message after $maxRetries attempts with error: ${e.message}")
98 | exception.addSuppressed(e)
99 | }
100 | }
101 |
102 | LOGGER.error("$message failed after $maxRetries retries", exception)
103 | throw exception!!
104 | }
105 | }
106 |
107 | interface HttpExceptionFactory {
108 | fun createException(response: Response): HttpException
109 | }
110 |
111 | private class DefaultHttpExceptionFactory : HttpExceptionFactory {
112 | override fun createException(response: Response): HttpException {
113 | return HttpException(response, response.body?.string() ?: response.message)
114 | }
115 | }
116 |
117 | class HttpException(val response: Response, message: String) : IOException("Request failed, status: ${response.code} message: $message url: ${response.request.url}")
118 | }
119 |
--------------------------------------------------------------------------------
/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/main/kotlin/me/modmuss50/mpp/ModPublishExtension.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp
2 |
3 | import groovy.lang.Closure
4 | import groovy.lang.DelegatesTo
5 | import me.modmuss50.mpp.platforms.curseforge.Curseforge
6 | import me.modmuss50.mpp.platforms.curseforge.CurseforgeOptions
7 | import me.modmuss50.mpp.platforms.discord.DiscordWebhookTask
8 | import me.modmuss50.mpp.platforms.github.Github
9 | import me.modmuss50.mpp.platforms.github.GithubOptions
10 | import me.modmuss50.mpp.platforms.modrinth.Modrinth
11 | import me.modmuss50.mpp.platforms.modrinth.ModrinthOptions
12 | import org.gradle.api.Action
13 | import org.gradle.api.ExtensiblePolymorphicDomainObjectContainer
14 | import org.gradle.api.NamedDomainObjectProvider
15 | import org.gradle.api.PolymorphicDomainObjectContainer
16 | import org.gradle.api.Project
17 | import org.gradle.api.file.RegularFileProperty
18 | import org.gradle.api.provider.Property
19 | import org.gradle.api.provider.Provider
20 | import org.gradle.api.tasks.TaskProvider
21 | import java.nio.file.Path
22 | import kotlin.reflect.KClass
23 |
24 | abstract class ModPublishExtension(val project: Project) : PublishOptions {
25 | // Removes the need to import the release type, a little gross tho?
26 | val BETA = ReleaseType.BETA
27 | val ALPHA = ReleaseType.ALPHA
28 | val STABLE = ReleaseType.STABLE
29 |
30 | abstract val dryRun: Property
31 | val platforms: ExtensiblePolymorphicDomainObjectContainer = project.objects.polymorphicDomainObjectContainer(Platform::class.java)
32 |
33 | init {
34 | dryRun.convention(false)
35 | maxRetries.convention(3)
36 | version.convention(project.provider(this::getProjectVersion))
37 | displayName.convention(version.map { "${project.name} $it" })
38 |
39 | // Inherit the platform options from this extension.
40 | platforms.whenObjectAdded {
41 | it.from(this)
42 | }
43 | }
44 |
45 | fun publishOptions(@DelegatesTo(PublishOptions::class) closure: Closure<*>): Provider {
46 | return publishOptions {
47 | project.configure(it, closure)
48 | }
49 | }
50 |
51 | fun publishOptions(action: Action): Provider {
52 | return project.provider {
53 | val options = project.objects.newInstance(PublishOptions::class.java)
54 | options.from(this)
55 | action.execute(options)
56 | return@provider options
57 | }
58 | }
59 |
60 | // Curseforge
61 |
62 | fun curseforge(@DelegatesTo(value = Curseforge::class) closure: Closure<*>): NamedDomainObjectProvider {
63 | return curseforge {
64 | project.configure(it, closure)
65 | }
66 | }
67 |
68 | fun curseforge(action: Action): NamedDomainObjectProvider {
69 | return curseforge("curseforge", action)
70 | }
71 |
72 | fun curseforge(name: String, @DelegatesTo(value = Curseforge::class) closure: Closure<*>): NamedDomainObjectProvider {
73 | return curseforge(name) {
74 | project.configure(it, closure)
75 | }
76 | }
77 |
78 | fun curseforge(name: String, action: Action): NamedDomainObjectProvider {
79 | return platforms.maybeRegister(name, action)
80 | }
81 |
82 | fun curseforgeOptions(@DelegatesTo(value = Curseforge::class) closure: Closure<*>): Provider {
83 | return curseforgeOptions {
84 | project.configure(it, closure)
85 | }
86 | }
87 |
88 | fun curseforgeOptions(action: Action): Provider {
89 | return configureOptions(CurseforgeOptions::class) {
90 | it.from(this)
91 | action.execute(it)
92 | }
93 | }
94 |
95 | // Modirth
96 |
97 | fun modrinth(@DelegatesTo(value = Modrinth::class) closure: Closure<*>): NamedDomainObjectProvider {
98 | return modrinth {
99 | project.configure(it, closure)
100 | }
101 | }
102 |
103 | fun modrinth(action: Action): NamedDomainObjectProvider {
104 | return modrinth("modrinth", action)
105 | }
106 |
107 | fun modrinth(name: String, @DelegatesTo(value = Modrinth::class) closure: Closure<*>): NamedDomainObjectProvider {
108 | return modrinth(name) {
109 | project.configure(it, closure)
110 | }
111 | }
112 |
113 | fun modrinth(name: String, action: Action): NamedDomainObjectProvider {
114 | return platforms.maybeRegister(name, action)
115 | }
116 |
117 | fun modrinthOptions(@DelegatesTo(value = Modrinth::class) closure: Closure<*>): Provider {
118 | return modrinthOptions {
119 | project.configure(it, closure)
120 | }
121 | }
122 |
123 | fun modrinthOptions(action: Action): Provider {
124 | return configureOptions(ModrinthOptions::class) {
125 | it.from(this)
126 | action.execute(it)
127 | }
128 | }
129 |
130 | // Github
131 |
132 | fun github(@DelegatesTo(value = Github::class) closure: Closure<*>): NamedDomainObjectProvider {
133 | return github {
134 | project.configure(it, closure)
135 | }
136 | }
137 |
138 | fun github(action: Action): NamedDomainObjectProvider {
139 | return github("github", action)
140 | }
141 |
142 | fun github(name: String, @DelegatesTo(value = Github::class) closure: Closure<*>): NamedDomainObjectProvider {
143 | return github(name) {
144 | project.configure(it, closure)
145 | }
146 | }
147 |
148 | fun github(name: String, action: Action): NamedDomainObjectProvider {
149 | return platforms.maybeRegister(name, action)
150 | }
151 |
152 | fun githubOptions(@DelegatesTo(value = Github::class) closure: Closure<*>): Provider {
153 | return githubOptions {
154 | project.configure(it, closure)
155 | }
156 | }
157 |
158 | fun githubOptions(action: Action): Provider {
159 | return configureOptions(GithubOptions::class) {
160 | it.from(this)
161 | action.execute(it)
162 | }
163 | }
164 |
165 | // Discord
166 |
167 | fun discord(@DelegatesTo(value = DiscordWebhookTask::class) closure: Closure<*>): TaskProvider {
168 | return discord("announceDiscord", closure)
169 | }
170 |
171 | fun discord(action: Action): TaskProvider {
172 | return discord("announceDiscord", action)
173 | }
174 |
175 | fun discord(name: String, @DelegatesTo(value = DiscordWebhookTask::class) closure: Closure<*>): TaskProvider {
176 | return discord(name) {
177 | project.configure(it, closure)
178 | }
179 | }
180 |
181 | fun discord(name: String, action: Action): TaskProvider {
182 | val task = project.tasks.register(name, DiscordWebhookTask::class.java) {
183 | action.execute(it)
184 | }
185 |
186 | project.tasks.named("publishMods").configure {
187 | it.dependsOn(task.get())
188 | }
189 |
190 | return task
191 | }
192 |
193 | // Misc
194 |
195 | private inline fun PolymorphicDomainObjectContainer.maybeRegister(name: String, action: Action): NamedDomainObjectProvider {
196 | return if (name in platforms.names) {
197 | named(name, T::class.java, action)
198 | } else {
199 | register(name, T::class.java, action)
200 | }
201 | }
202 |
203 | private fun > configureOptions(klass: KClass, action: Action): Provider {
204 | return project.provider {
205 | val options = project.objects.newInstance(klass.java)
206 | options.setInternalDefaults()
207 | action.execute(options)
208 | return@provider options
209 | }
210 | }
211 |
212 | // Returns the project version as a string, or throws if not set.
213 | private fun getProjectVersion(): String {
214 | val version = project.version
215 |
216 | if (version == Project.DEFAULT_VERSION) {
217 | throw IllegalStateException("Gradle version is unspecified")
218 | }
219 |
220 | return version.toString()
221 | }
222 | }
223 |
224 | internal val Project.modPublishExtension: ModPublishExtension
225 | get() = extensions.getByType(ModPublishExtension::class.java)
226 |
227 | internal val RegularFileProperty.path: Path
228 | get() = get().asFile.toPath()
229 |
--------------------------------------------------------------------------------
/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.github.Github
5 | import me.modmuss50.mpp.platforms.modrinth.Modrinth
6 | import org.gradle.api.Plugin
7 | import org.gradle.api.Project
8 | import org.gradle.api.reflect.TypeOf
9 | import org.gradle.api.tasks.TaskProvider
10 |
11 | @Suppress("unused")
12 | class MppPlugin : Plugin {
13 | override fun apply(project: Project) {
14 | val extension = project.extensions.create(TypeOf.typeOf(ModPublishExtension::class.java), "publishMods", ModPublishExtension::class.java, project)
15 |
16 | extension.platforms.registerFactory(Curseforge::class.java) {
17 | project.objects.newInstance(Curseforge::class.java, it)
18 | }
19 | extension.platforms.registerFactory(Github::class.java) {
20 | project.objects.newInstance(Github::class.java, it)
21 | }
22 | extension.platforms.registerFactory(Modrinth::class.java) {
23 | project.objects.newInstance(Modrinth::class.java, it)
24 | }
25 |
26 | val publishModsTask = project.tasks.register("publishMods") {
27 | it.group = "publishing"
28 | }
29 |
30 | extension.platforms.whenObjectAdded { platform ->
31 | val publishPlatformTask = configureTask(project, platform)
32 |
33 | publishModsTask.configure { task ->
34 | task.dependsOn(publishPlatformTask)
35 | }
36 | }
37 | }
38 |
39 | private fun configureTask(project: Project, platform: Platform): TaskProvider {
40 | return project.tasks.register(platform.taskName, PublishModTask::class.java, platform)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/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 | @ApiStatus.Internal
107 | class PublishContext(private val queue: WorkQueue, private val result: RegularFile) {
108 | fun submit(workActionClass: KClass>, parameterAction: Action) {
109 | queue.submit(workActionClass.java) {
110 | it.result.set(result)
111 | parameterAction.execute(it)
112 | }
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/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.github.GithubOptions
6 | import org.gradle.api.DefaultTask
7 | import org.gradle.api.file.DirectoryProperty
8 | import org.gradle.api.file.RegularFileProperty
9 | import org.gradle.api.provider.Property
10 | import org.gradle.api.tasks.Input
11 | import org.gradle.api.tasks.Nested
12 | import org.gradle.api.tasks.OutputDirectory
13 | import org.gradle.api.tasks.OutputFile
14 | import org.gradle.api.tasks.TaskAction
15 | import org.gradle.work.DisableCachingByDefault
16 | import org.gradle.workers.WorkerExecutor
17 | import org.jetbrains.annotations.ApiStatus
18 | import java.io.FileNotFoundException
19 | import javax.inject.Inject
20 |
21 | @DisableCachingByDefault(because = "Re-upload mod each time")
22 | abstract class PublishModTask @Inject constructor(@Nested val platform: Platform) : DefaultTask() {
23 | @get:ApiStatus.Internal
24 | @get:Input
25 | abstract val dryRun: Property
26 |
27 | @get:ApiStatus.Internal
28 | @get:OutputFile
29 | abstract val result: RegularFileProperty
30 |
31 | @get:ApiStatus.Internal
32 | @get:OutputDirectory
33 | abstract val dryRunDirectory: DirectoryProperty
34 |
35 | @get:Inject
36 | protected abstract val workerExecutor: WorkerExecutor
37 |
38 | init {
39 | group = "publishing"
40 | outputs.upToDateWhen { false }
41 | dryRun.set(project.modPublishExtension.dryRun)
42 | dryRun.finalizeValue()
43 | result.set(project.layout.buildDirectory.file("publishMods/$name.json"))
44 | result.finalizeValue()
45 | dryRunDirectory.set(project.layout.buildDirectory.dir("publishMods/$name"))
46 | dryRunDirectory.finalizeValue()
47 | }
48 |
49 | @TaskAction
50 | fun publish() {
51 | platform.validateInputs()
52 |
53 | if (dryRun.get()) {
54 | logger.lifecycle("Dry run $name:")
55 | platform.printDryRunInfo(logger)
56 |
57 | dryRunCopyMainFile()
58 |
59 | for (additionalFile in platform.additionalFiles.files) {
60 | if (!additionalFile.exists()) {
61 | throw FileNotFoundException("$additionalFile not found")
62 | }
63 |
64 | additionalFile.copyTo(dryRunDirectory.get().asFile.resolve(additionalFile.name), overwrite = true)
65 | logger.lifecycle("Additional file: ${additionalFile.name}")
66 | }
67 |
68 | logger.lifecycle("Display name: ${platform.displayName.get()}")
69 | logger.lifecycle("Version: ${platform.version.get()}")
70 | logger.lifecycle("Changelog: ${platform.changelog.get()}")
71 |
72 | result.get().asFile.writeText(
73 | Json.encodeToString(platform.dryRunPublishResult()),
74 | )
75 |
76 | return
77 | }
78 |
79 | // Ensure that we have an access token when not dry running.
80 | platform.accessToken.get()
81 |
82 | val workQueue = workerExecutor.noIsolation()
83 | val context = PublishContext(queue = workQueue, result = result.get())
84 | platform.publish(context)
85 | }
86 |
87 | private fun dryRunCopyMainFile() {
88 | // A bit of a hack to handle the optional main file for Github.
89 | if (platform is GithubOptions) {
90 | if (!platform.file.isPresent && platform.allowEmptyFiles.get()) {
91 | return
92 | }
93 | }
94 |
95 | val file = platform.file.get().asFile
96 |
97 | if (!file.exists()) {
98 | throw FileNotFoundException("$file not found")
99 | }
100 |
101 | file.copyTo(dryRunDirectory.get().asFile.resolve(file.name), overwrite = true)
102 | logger.lifecycle("Main file: ${file.name}")
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/platforms/curseforge/Curseforge.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.platforms.curseforge
2 |
3 | import me.modmuss50.mpp.CurseForgePublishResult
4 | import me.modmuss50.mpp.HttpUtils
5 | import me.modmuss50.mpp.MinecraftApi
6 | import me.modmuss50.mpp.Platform
7 | import me.modmuss50.mpp.PlatformDependency
8 | import me.modmuss50.mpp.PlatformDependencyContainer
9 | import me.modmuss50.mpp.PlatformOptions
10 | import me.modmuss50.mpp.PlatformOptionsInternal
11 | import me.modmuss50.mpp.PublishContext
12 | import me.modmuss50.mpp.PublishOptions
13 | import me.modmuss50.mpp.PublishResult
14 | import me.modmuss50.mpp.PublishWorkAction
15 | import me.modmuss50.mpp.PublishWorkParameters
16 | import me.modmuss50.mpp.Validators
17 | import me.modmuss50.mpp.path
18 | import org.gradle.api.Action
19 | import org.gradle.api.JavaVersion
20 | import org.gradle.api.Project
21 | import org.gradle.api.file.ConfigurableFileCollection
22 | import org.gradle.api.logging.Logger
23 | import org.gradle.api.provider.ListProperty
24 | import org.gradle.api.provider.MapProperty
25 | import org.gradle.api.provider.Property
26 | import org.gradle.api.provider.Provider
27 | import org.gradle.api.tasks.Input
28 | import org.gradle.api.tasks.Internal
29 | import org.gradle.api.tasks.Nested
30 | import org.gradle.api.tasks.Optional
31 | import org.jetbrains.annotations.ApiStatus
32 | import javax.inject.Inject
33 | import kotlin.random.Random
34 | import kotlin.reflect.KClass
35 |
36 | interface CurseforgeOptions : PlatformOptions, PlatformOptionsInternal, CurseforgeDependencyContainer {
37 | @get:Input
38 | val projectId: Property
39 |
40 | // Project slug, used by discord webhook to link to the uploaded file.
41 | @get:Input
42 | @get:Optional
43 | val projectSlug: Property
44 |
45 | @get:Input
46 | val minecraftVersions: ListProperty
47 |
48 | @get:Input
49 | @get:Optional
50 | val clientRequired: Property
51 |
52 | @get:Input
53 | @get:Optional
54 | val serverRequired: Property
55 |
56 | @get:Input
57 | val javaVersions: ListProperty
58 |
59 | @get:Input
60 | val apiEndpoint: Property
61 |
62 | @get:Input
63 | val changelogType: Property
64 |
65 | @get:Nested
66 | @get:ApiStatus.Internal
67 | val additionalFilesExt: MapProperty
68 |
69 | fun from(other: CurseforgeOptions) {
70 | super.from(other)
71 | fromDependencies(other)
72 | projectId.convention(other.projectId)
73 | projectSlug.convention(other.projectSlug)
74 | minecraftVersions.convention(other.minecraftVersions)
75 | clientRequired.convention(other.clientRequired)
76 | serverRequired.convention(other.serverRequired)
77 | javaVersions.convention(other.javaVersions)
78 | apiEndpoint.convention(other.apiEndpoint)
79 | changelogType.convention(other.changelogType)
80 | additionalFilesExt.convention(other.additionalFilesExt)
81 | }
82 |
83 | fun from(other: Provider) {
84 | from(other.get())
85 | }
86 |
87 | fun from(other: Provider, publishOptions: Provider) {
88 | from(other)
89 | from(publishOptions.get())
90 | }
91 |
92 | fun minecraftVersionRange(action: Action) {
93 | val options = objectFactory.newInstance(CurseforgeVersionRangeOptions::class.java)
94 | action.execute(options)
95 |
96 | val startId = options.start.get()
97 | val endId = options.end.get()
98 |
99 | minecraftVersions.addAll(
100 | providerFactory.provider {
101 | MinecraftApi().getVersionsInRange(startId, endId)
102 | },
103 | )
104 | }
105 |
106 | fun additionalFile(file: Any, action: Action) {
107 | val options = objectFactory.newInstance(AdditionalFileOptions::class.java)
108 | action.execute(options)
109 |
110 | val fileCollection = objectFactory.fileCollection()
111 | fileCollection.from(
112 | when (file) {
113 | is Project -> {
114 | val configuration = _thisProject.configurations.detachedConfiguration(
115 | _thisProject.dependencyFactory.create(file).setTransitive(false),
116 | )
117 | configuration.elements.map { it.single().asFile }
118 | }
119 | else -> {
120 | file
121 | }
122 | },
123 | )
124 |
125 | additionalFiles.from(fileCollection)
126 | additionalFilesExt.put(fileCollection, options)
127 | }
128 |
129 | override fun setInternalDefaults() {
130 | apiEndpoint.convention("https://minecraft.curseforge.com")
131 | changelogType.convention("markdown")
132 | }
133 |
134 | override val platformDependencyKClass: KClass
135 | get() = CurseforgeDependency::class
136 | }
137 |
138 | interface CurseforgeDependency : PlatformDependency {
139 | @get:Input
140 | val slug: Property
141 | }
142 |
143 | interface CurseforgeVersionRangeOptions {
144 | /**
145 | * The start version of the range (inclusive)
146 | */
147 | val start: Property
148 |
149 | /**
150 | * The end version of the range (exclusive)
151 | */
152 | val end: Property
153 | }
154 |
155 | /**
156 | * Options for additional files to upload alongside the main file
157 | */
158 | interface AdditionalFileOptions {
159 | /**
160 | * The display name of the additional file
161 | */
162 | @get:Input
163 | val name: Property
164 | }
165 |
166 | /**
167 | * Provides shorthand methods for adding dependencies to curseforge
168 | */
169 | interface CurseforgeDependencyContainer : PlatformDependencyContainer {
170 | fun requires(vararg slugs: String) {
171 | addInternal(PlatformDependency.DependencyType.REQUIRED, slugs)
172 | }
173 | fun optional(vararg slugs: String) {
174 | addInternal(PlatformDependency.DependencyType.OPTIONAL, slugs)
175 | }
176 | fun incompatible(vararg slugs: String) {
177 | addInternal(PlatformDependency.DependencyType.INCOMPATIBLE, slugs)
178 | }
179 | fun embeds(vararg slugs: String) {
180 | addInternal(PlatformDependency.DependencyType.EMBEDDED, slugs)
181 | }
182 |
183 | @Internal
184 | fun addInternal(type: PlatformDependency.DependencyType, slugs: Array) {
185 | slugs.forEach {
186 | dependencies.add(
187 | objectFactory.newInstance(CurseforgeDependency::class.java).apply {
188 | this.slug.set(it)
189 | this.type.set(type)
190 | },
191 | )
192 | }
193 | }
194 | }
195 |
196 | abstract class Curseforge @Inject constructor(name: String) : Platform(name), CurseforgeOptions {
197 | override fun validateInputs() {
198 | super.validateInputs()
199 | Validators.validateUnique("minecraftVersions", minecraftVersions)
200 | Validators.validateUnique("javaVersions", javaVersions)
201 | }
202 |
203 | override fun publish(context: PublishContext) {
204 | context.submit(UploadWorkAction::class) {
205 | it.from(this)
206 | }
207 | }
208 |
209 | override fun dryRunPublishResult(): PublishResult {
210 | return CurseForgePublishResult(
211 | projectId = projectId.get(),
212 | projectSlug = projectSlug.map { "dry-run" }.orNull,
213 | // Use a random file ID so that the URL is different each time, this is needed because discord drops duplicate URLs
214 | fileId = Random.nextInt(0, 1000000),
215 | title = announcementTitle.getOrElse("Download from CurseForge"),
216 | )
217 | }
218 |
219 | override fun printDryRunInfo(logger: Logger) {
220 | for (dependency in dependencies.get()) {
221 | logger.lifecycle("Dependency(slug: ${dependency.slug.get()}, type: ${dependency.type.get()})")
222 | }
223 | }
224 |
225 | interface UploadParams : PublishWorkParameters, CurseforgeOptions
226 |
227 | abstract class UploadWorkAction : PublishWorkAction {
228 | override fun publish(): PublishResult {
229 | with(parameters) {
230 | val api = CurseforgeApi(accessToken.get(), apiEndpoint.get())
231 | val versions = CurseforgeVersions(
232 | HttpUtils.retry(maxRetries.get(), "Failed to get game version types") {
233 | api.getVersionTypes()
234 | },
235 | HttpUtils.retry(maxRetries.get(), "Failed to get game versions") {
236 | api.getGameVersions()
237 | },
238 | )
239 |
240 | val gameVersions = ArrayList()
241 | for (version in minecraftVersions.get()) {
242 | gameVersions.add(versions.getMinecraftVersion(version))
243 | }
244 |
245 | for (modLoader in modLoaders.get()) {
246 | gameVersions.add(versions.getModLoaderVersion(modLoader))
247 | }
248 |
249 | if (clientRequired.isPresent && clientRequired.get()) {
250 | gameVersions.add(versions.getClientVersion())
251 | }
252 |
253 | if (serverRequired.isPresent && serverRequired.get()) {
254 | gameVersions.add(versions.getServerVersion())
255 | }
256 |
257 | for (javaVersion in javaVersions.get()) {
258 | gameVersions.add(versions.getJavaVersion(javaVersion))
259 | }
260 |
261 | val projectRelations = dependencies.get().map {
262 | CurseforgeApi.ProjectFileRelation(
263 | slug = it.slug.get(),
264 | type = CurseforgeApi.RelationType.valueOf(it.type.get()),
265 | )
266 | }
267 |
268 | val relations = if (projectRelations.isNotEmpty()) {
269 | CurseforgeApi.UploadFileRelations(
270 | projects = projectRelations,
271 | )
272 | } else {
273 | null
274 | }
275 |
276 | val metadata = CurseforgeApi.UploadFileMetadata(
277 | changelog = changelog.get(),
278 | changelogType = CurseforgeApi.ChangelogType.of(changelogType.get()),
279 | displayName = displayName.get(),
280 | gameVersions = gameVersions,
281 | releaseType = CurseforgeApi.ReleaseType.valueOf(type.get()),
282 | relations = relations,
283 | )
284 |
285 | val response = HttpUtils.retry(maxRetries.get(), "Failed to upload file") {
286 | api.uploadFile(projectId.get(), file.path, metadata)
287 | }
288 |
289 | val additionalFileOptions = additionalFilesExt.get().map { (key, value) ->
290 | key.singleFile.toPath() to value
291 | }.toMap()
292 |
293 | for (additionalFile in additionalFiles.files) {
294 | val fileOptions = additionalFileOptions[additionalFile.toPath()]
295 | val additionalMetadata = metadata.copy(parentFileID = response.id, gameVersions = null, displayName = fileOptions?.name?.orNull)
296 |
297 | HttpUtils.retry(maxRetries.get(), "Failed to upload additional file") {
298 | api.uploadFile(projectId.get(), additionalFile.toPath(), additionalMetadata)
299 | }
300 | }
301 |
302 | return CurseForgePublishResult(
303 | projectId = projectId.get(),
304 | projectSlug = projectSlug.orNull,
305 | fileId = response.id,
306 | title = announcementTitle.getOrElse("Download from CurseForge"),
307 | )
308 | }
309 | }
310 | }
311 | }
312 |
--------------------------------------------------------------------------------
/src/main/kotlin/me/modmuss50/mpp/platforms/curseforge/CurseforgeApi.kt:
--------------------------------------------------------------------------------
1 | package me.modmuss50.mpp.platforms.curseforge
2 |
3 | import kotlinx.serialization.ExperimentalSerializationApi
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 | import kotlinx.serialization.SerializationException
7 | import kotlinx.serialization.encodeToString
8 | import kotlinx.serialization.json.Json
9 | import me.modmuss50.mpp.HttpUtils
10 | import me.modmuss50.mpp.PlatformDependency
11 | import okhttp3.MediaType.Companion.toMediaTypeOrNull
12 | import okhttp3.MultipartBody
13 | import okhttp3.RequestBody.Companion.asRequestBody
14 | import okhttp3.Response
15 | import java.nio.file.Path
16 | import kotlin.io.path.name
17 |
18 | // https://support.curseforge.com/en/support/solutions/articles/9000197321-curseforge-upload-api
19 | class CurseforgeApi(private val accessToken: String, private val baseUrl: String) {
20 | // dont serialize nulls
21 | @OptIn(ExperimentalSerializationApi::class)
22 | val json = Json { explicitNulls = false }
23 |
24 | private val httpUtils = HttpUtils(exceptionFactory = CurseforgeHttpExceptionFactory())
25 |
26 | @Serializable
27 | data class GameVersionType(
28 | val id: Int,
29 | val name: String,
30 | val slug: String,
31 | )
32 |
33 | @Serializable
34 | data class GameVersion(
35 | val id: Int,
36 | val gameVersionTypeID: Int,
37 | val name: String,
38 | val slug: String,
39 | )
40 |
41 | @Serializable
42 | enum class ReleaseType {
43 | @SerialName("alpha")
44 | ALPHA,
45 |
46 | @SerialName("beta")
47 | BETA,
48 |
49 | @SerialName("release")
50 | RELEASE,
51 | ;
52 |
53 | companion object {
54 | fun valueOf(type: me.modmuss50.mpp.ReleaseType): ReleaseType {
55 | return when (type) {
56 | me.modmuss50.mpp.ReleaseType.STABLE -> RELEASE
57 | me.modmuss50.mpp.ReleaseType.BETA -> BETA
58 | me.modmuss50.mpp.ReleaseType.ALPHA -> ALPHA
59 | }
60 | }
61 | }
62 | }
63 |
64 | @Serializable
65 | enum class ChangelogType {
66 | @SerialName("text")
67 | TEXT,
68 |
69 | @SerialName("html")
70 | HTML,
71 |
72 | @SerialName("markdown")
73 | MARKDOWN,
74 | ;
75 |
76 | companion object {
77 | @JvmStatic
78 | fun of(value: String): ChangelogType {
79 | val upper = value.uppercase()
80 | try {
81 | return ChangelogType.valueOf(upper)
82 | } catch (e: java.lang.IllegalArgumentException) {
83 | throw java.lang.IllegalArgumentException("Invalid changelog type: $upper. Must be one of: TEXT, HTML, MARKDOWN")
84 | }
85 | }
86 | }
87 | }
88 |
89 | @Serializable
90 | data class UploadFileMetadata(
91 | val changelog: String, // Can be HTML or markdown if changelogType is set.
92 | val changelogType: ChangelogType? = null, // Optional: defaults to text
93 | val displayName: String? = null, // Optional: A friendly display name used on the site if provided.
94 | val parentFileID: Int? = null, // Optional: The parent file of this file.
95 | val gameVersions: List?, // A list of supported game versions, see the Game Versions API for details. Not supported if parentFileID is provided.
96 | val releaseType: ReleaseType,
97 | val relations: UploadFileRelations? = null,
98 | )
99 |
100 | @Serializable
101 | data class UploadFileRelations(
102 | val projects: List,
103 | )
104 |
105 | enum class RelationType {
106 | @SerialName("embeddedLibrary")
107 | EMBEDDED_LIBRARY,
108 |
109 | @SerialName("incompatible")
110 | INCOMPATIBLE,
111 |
112 | @SerialName("optionalDependency")
113 | OPTIONAL_DEPENDENCY,
114 |
115 | @SerialName("requiredDependency")
116 | REQUIRED_DEPENDENCY,
117 |
118 | @SerialName("tool")
119 | TOOL,
120 | ;
121 |
122 | companion object {
123 | fun valueOf(type: PlatformDependency.DependencyType): RelationType {
124 | return when (type) {
125 | PlatformDependency.DependencyType.REQUIRED -> REQUIRED_DEPENDENCY
126 | PlatformDependency.DependencyType.OPTIONAL -> OPTIONAL_DEPENDENCY
127 | PlatformDependency.DependencyType.INCOMPATIBLE -> INCOMPATIBLE
128 | PlatformDependency.DependencyType.EMBEDDED -> EMBEDDED_LIBRARY
129 | }
130 | }
131 | }
132 | }
133 |
134 | @Serializable
135 | data class ProjectFileRelation(
136 | val slug: String, // Slug of related plugin.
137 | val type: RelationType,
138 | )
139 |
140 | @Serializable
141 | data class UploadFileResponse(
142 | val id: Int,
143 | )
144 |
145 | @Serializable
146 | data class ErrorResponse(
147 | val errorCode: Int,
148 | val errorMessage: String,
149 | )
150 |
151 | private val headers: Map
152 | get() = mapOf("X-Api-Token" to accessToken)
153 |
154 | fun getVersionTypes(): List {
155 | return httpUtils.get("$baseUrl/api/game/version-types", headers)
156 | }
157 |
158 | fun getGameVersions(): List {
159 | return httpUtils.get("$baseUrl/api/game/versions", headers)
160 | }
161 |
162 | fun uploadFile(projectId: String, path: Path, uploadMetadata: UploadFileMetadata): UploadFileResponse {
163 | val mediaType = "application/java-archive".toMediaTypeOrNull()
164 | val fileBody = path.toFile().asRequestBody(mediaType)
165 | val metadataJson = json.encodeToString(uploadMetadata)
166 |
167 | val requestBody = MultipartBody.Builder()
168 | .setType(MultipartBody.FORM)
169 | .addFormDataPart("file", path.name, fileBody)
170 | .addFormDataPart("metadata", metadataJson)
171 | .build()
172 |
173 | return httpUtils.post("$baseUrl/api/projects/$projectId/upload-file", requestBody, headers)
174 | }
175 |
176 | private class CurseforgeHttpExceptionFactory : HttpUtils.HttpExceptionFactory {
177 | val json = Json { ignoreUnknownKeys = true }
178 |
179 | override fun createException(response: Response): HttpUtils.HttpException {
180 | return try {
181 | val errorResponse = json.decodeFromString(response.body!!.string())
182 | HttpUtils.HttpException(response, errorResponse.errorMessage)
183 | } catch (e: SerializationException) {
184 | HttpUtils.HttpException(response, "Unknown error")
185 | }
186 | }
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/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/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