├── .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 | ![Discord example](/images/discord_example.png) 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 | ![Classic look](/images/discord_example.png) 66 | 67 | ## Modern look 68 | ![Modern look](/images/discord_modern.png) 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 | ![Example with thumbnail](/images/discord_modern_button_thumbnail.png) 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 | ![Embed links](/images/discord_example.png) 111 | ## Button links 112 | ![Button links](/images/discord_button.png) 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? = null, 32 | // allowedMentions -- Skip this as we don't need it 33 | val components: List? = null, 34 | // files -- Skip these as we don't need them 35 | // payload_json 36 | // attachments 37 | val flags: Int? = null, 38 | @SerialName("thread_name") 39 | val threadName: String? = null, 40 | ) 41 | 42 | @Serializable 43 | sealed class Component { 44 | protected abstract val type: Int 45 | } 46 | 47 | @Serializable 48 | data class ActionRow( 49 | val components: List? = null, 50 | ) : Component() { 51 | override val type: Int = 1 52 | } 53 | 54 | @Serializable 55 | data class ButtonComponent( 56 | val label: String? = null, 57 | // emoji 58 | // @SerialName("custom_id") 59 | // val customId: String?, 60 | // sku_id 61 | val url: String? = null, 62 | // disabled 63 | ) : Component() { 64 | override val type: Int = 2 65 | val style: Int = 5 // Shouldn't be touched as we only work with links 66 | } 67 | 68 | // https://discord.com/developers/docs/resources/message#embed-object 69 | @Serializable 70 | data class Embed( 71 | val title: String? = null, 72 | val type: String? = null, 73 | val description: String? = null, 74 | val url: String? = null, 75 | val timestamp: String? = null, // ISO8601 timestamp 76 | val color: Int? = null, 77 | val footer: EmbedFooter? = null, 78 | val image: EmbedImage? = null, 79 | val thumbnail: EmbedThumbnail? = null, 80 | val video: EmbedVideo? = null, 81 | val provider: EmbedProvider? = null, 82 | val author: EmbedAuthor? = null, 83 | val fields: List? = null, 84 | ) 85 | 86 | // https://discord.com/developers/docs/resources/message#embed-object-embed-footer-structure 87 | @Serializable 88 | data class EmbedFooter( 89 | val text: String, 90 | @SerialName("icon_url") 91 | val iconUrl: String? = null, 92 | @SerialName("proxy_icon_url") 93 | val proxyIconUrl: String? = null, 94 | ) 95 | 96 | // https://discord.com/developers/docs/resources/message#embed-object-embed-image-structure 97 | @Serializable 98 | data class EmbedImage( 99 | val url: String, 100 | @SerialName("proxy_url") 101 | val proxyUrl: String? = null, 102 | val height: Int? = null, 103 | val width: Int? = null, 104 | ) 105 | 106 | // https://discord.com/developers/docs/resources/message#embed-object-embed-thumbnail-structure 107 | @Serializable 108 | data class EmbedThumbnail( 109 | val url: String, 110 | @SerialName("proxy_url") 111 | val proxyUrl: String? = null, 112 | val height: Int? = null, 113 | val width: Int? = null, 114 | ) 115 | 116 | // https://discord.com/developers/docs/resources/message#embed-object-embed-video-structure 117 | @Serializable 118 | data class EmbedVideo( 119 | val url: String? = null, 120 | @SerialName("proxy_url") 121 | val proxyUrl: String? = null, 122 | val height: Int? = null, 123 | val width: Int? = null, 124 | ) 125 | 126 | // https://discord.com/developers/docs/resources/message#embed-object-embed-provider-structure 127 | @Serializable 128 | data class EmbedProvider( 129 | val name: String? = null, 130 | val url: String? = null, 131 | ) 132 | 133 | // https://discord.com/developers/docs/resources/message#embed-object-embed-author-structure 134 | @Serializable 135 | data class EmbedAuthor( 136 | val name: String, 137 | val url: String? = null, 138 | @SerialName("icon_url") 139 | val iconUrl: String? = null, 140 | @SerialName("proxy_icon_url") 141 | val proxyIconUrl: String? = null, 142 | ) 143 | 144 | // https://discord.com/developers/docs/resources/message#embed-object-embed-field-structure 145 | @Serializable 146 | data class EmbedField( 147 | val name: String, 148 | val value: String, 149 | val inline: Boolean? = null, 150 | ) 151 | 152 | // https://discord.com/developers/docs/resources/webhook#get-webhook 153 | fun getWebhook(url: String): WebhookData { 154 | val response = httpUtils.get(url, headers) 155 | return response 156 | } 157 | 158 | /** 159 | * The response from getting the webhook data 160 | */ 161 | @Serializable 162 | data class WebhookData( 163 | // Only get the application id 164 | // as this is the only thing that matters 165 | @SerialName("application_id") 166 | val applicationId: String?, 167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /src/main/kotlin/me/modmuss50/mpp/platforms/discord/DiscordWebhookTask.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.platforms.discord 2 | 3 | import me.modmuss50.mpp.Platform 4 | import me.modmuss50.mpp.PublishModTask 5 | import me.modmuss50.mpp.PublishResult 6 | import me.modmuss50.mpp.modPublishExtension 7 | import org.gradle.api.Action 8 | import org.gradle.api.DefaultTask 9 | import org.gradle.api.NamedDomainObjectCollection 10 | import org.gradle.api.Project 11 | import org.gradle.api.Task 12 | import org.gradle.api.file.ConfigurableFileCollection 13 | import org.gradle.api.provider.Property 14 | import org.gradle.api.tasks.Input 15 | import org.gradle.api.tasks.InputFiles 16 | import org.gradle.api.tasks.Nested 17 | import org.gradle.api.tasks.Optional 18 | import org.gradle.api.tasks.TaskAction 19 | import org.gradle.api.tasks.TaskProvider 20 | import org.gradle.work.DisableCachingByDefault 21 | import org.gradle.workers.WorkAction 22 | import org.gradle.workers.WorkParameters 23 | import org.gradle.workers.WorkerExecutor 24 | import org.jetbrains.annotations.ApiStatus 25 | import javax.inject.Inject 26 | 27 | interface DiscordWebhookOptions { 28 | @get:Input 29 | val webhookUrl: Property 30 | 31 | @get:Input 32 | @get:Optional 33 | val dryRunWebhookUrl: Property 34 | 35 | @get:Input 36 | val username: Property 37 | 38 | @get:Input 39 | @get:Optional 40 | val avatarUrl: Property 41 | 42 | @get:Input 43 | val content: Property 44 | 45 | @get:Nested 46 | val style: Property 47 | 48 | fun from(other: DiscordWebhookOptions) { 49 | webhookUrl.convention(other.webhookUrl) 50 | dryRunWebhookUrl.convention(other.dryRunWebhookUrl) 51 | username.convention(other.username) 52 | avatarUrl.convention(other.avatarUrl) 53 | content.convention(other.content) 54 | style.convention(other.style) 55 | } 56 | 57 | fun style(style: Action) { 58 | style.execute(this.style.get()) 59 | } 60 | } 61 | 62 | @Suppress("MemberVisibilityCanBePrivate") 63 | interface MessageStyle { 64 | @get:Input 65 | val look: Property 66 | 67 | @get:Input 68 | @get:Optional 69 | val thumbnailUrl: Property 70 | 71 | @get:Input 72 | @get:Optional 73 | val color: Property 74 | 75 | @get:Input 76 | @get:Optional 77 | val link: Property 78 | 79 | fun from(other: MessageStyle) { 80 | look.convention(other.look) 81 | thumbnailUrl.convention(other.thumbnailUrl) 82 | color.convention(other.color) 83 | link.convention(other.link) 84 | } 85 | } 86 | 87 | enum class MessageLook { 88 | MODERN, 89 | CLASSIC, 90 | } 91 | 92 | enum class LinkType { 93 | EMBED, 94 | BUTTON, 95 | INLINE, 96 | } 97 | 98 | @DisableCachingByDefault(because = "Publish webhook each time") 99 | abstract class DiscordWebhookTask : DefaultTask(), DiscordWebhookOptions { 100 | @get:ApiStatus.Internal 101 | @get:Input 102 | abstract val dryRun: Property 103 | 104 | @get:InputFiles 105 | abstract val publishResults: ConfigurableFileCollection 106 | 107 | @get:Inject 108 | protected abstract val workerExecutor: WorkerExecutor 109 | 110 | init { 111 | group = "publishing" 112 | username.convention("Mod Publish Plugin") 113 | content.convention(project.modPublishExtension.changelog) 114 | 115 | dryRun.set(project.modPublishExtension.dryRun) 116 | dryRun.finalizeValue() 117 | 118 | with(project.objects.newInstance(MessageStyle::class.java)) { 119 | look.convention("CLASSIC") 120 | link.convention("EMBED") 121 | 122 | style.convention(this) 123 | } 124 | 125 | // By default, announce all the platforms. 126 | publishResults.from( 127 | project.modPublishExtension.platforms 128 | .map { platform -> project.tasks.getByName(platform.taskName) as PublishModTask } 129 | .map { task -> task.result }, 130 | ) 131 | } 132 | 133 | /** 134 | * Set the platforms to announce. 135 | */ 136 | fun setPlatforms(vararg platforms: Platform) { 137 | publishResults.setFrom( 138 | platforms 139 | .map { platform -> project.tasks.getByName(platform.taskName) as PublishModTask } 140 | .map { task -> task.result }, 141 | ) 142 | } 143 | 144 | /** 145 | * Set the platforms to announce, by passing in publish tasks. 146 | */ 147 | fun setPlatforms(vararg tasks: TaskProvider) { 148 | publishResults.setFrom( 149 | tasks 150 | .map { task -> task.map { it as PublishModTask } } 151 | .map { task -> task.flatMap { it.result } }, 152 | ) 153 | 154 | setPlatforms(project.tasks.containerWithType(PublishModTask::class.java)) 155 | } 156 | 157 | /** 158 | * Set the platforms to announce, by passing in publish tasks. 159 | */ 160 | fun setPlatforms(tasks: NamedDomainObjectCollection) { 161 | publishResults.setFrom( 162 | tasks 163 | .map { it as PublishModTask } 164 | .map { it.result }, 165 | ) 166 | } 167 | 168 | /** 169 | * Set the platforms to announce, by passing in projects, using all the mod publish tasks from each project. 170 | */ 171 | fun setPlatformsAllFrom(vararg projects: Project) { 172 | publishResults.setFrom( 173 | projects 174 | .map { it.tasks.withType(PublishModTask::class.java) } 175 | .flatMap { it.toList() } 176 | .map { it.result }, 177 | ) 178 | } 179 | 180 | @TaskAction 181 | fun announce() { 182 | val workQueue = workerExecutor.noIsolation() 183 | workQueue.submit(DiscordWorkAction::class.java) { 184 | it.from(this) 185 | it.publishResults.setFrom(publishResults) 186 | it.dryRun.set(dryRun) 187 | it.style.set(style) 188 | } 189 | } 190 | 191 | interface DiscordWorkParameters : WorkParameters, DiscordWebhookOptions { 192 | val publishResults: ConfigurableFileCollection 193 | 194 | val dryRun: Property 195 | } 196 | 197 | @Suppress("MemberVisibilityCanBePrivate") 198 | abstract class DiscordWorkAction : WorkAction { 199 | override fun execute() { 200 | with(parameters) { 201 | if (dryRun.get() && !dryRunWebhookUrl.isPresent) { 202 | // Don't announce if we're dry running and don't have a dry run webhook URL. 203 | return 204 | } 205 | 206 | val url = if (dryRun.get()) dryRunWebhookUrl else webhookUrl 207 | 208 | if (LinkType.valueOf(style.get().link.get()) == LinkType.BUTTON) { 209 | // Verify that the webhook is application owned, 210 | // as only ones made by an application can use components/buttons 211 | val webhook = DiscordAPI.getWebhook(url.get()) 212 | if (webhook.applicationId == null) { 213 | throw UnsupportedOperationException("Button links require the use of an application owned webhook") 214 | } 215 | } 216 | 217 | // Get all the embeds used on the message 218 | val embeds = createEmbeds() 219 | 220 | // Get all components used on the message 221 | // A message can only have 5 action rows, so we split if needed 222 | val components = createComponents().chunked(5).iterator() 223 | 224 | var firstRequest = true 225 | 226 | // Split the embeds across multiple messages if needed 227 | val embedChunks = embeds.chunked(10) 228 | embedChunks.forEachIndexed { index, chunk -> 229 | DiscordAPI.executeWebhook( 230 | url.get(), 231 | DiscordAPI.Webhook( 232 | username = username.get(), 233 | content = if (index == 0) createClassicMessage() else null, 234 | avatarUrl = avatarUrl.orNull, 235 | embeds = chunk, 236 | // Only the last embed should have buttons 237 | components = if (index == embedChunks.lastIndex && components.hasNext()) { 238 | components.next() 239 | } else { 240 | null 241 | }, 242 | ), 243 | ) 244 | 245 | firstRequest = false 246 | } 247 | 248 | // Send the remaining buttons that didn't fit on the last message 249 | components.forEachRemaining { 250 | DiscordAPI.executeWebhook( 251 | url.get(), 252 | DiscordAPI.Webhook( 253 | content = if (firstRequest) createClassicMessage() else null, 254 | username = username.get(), 255 | avatarUrl = avatarUrl.orNull, 256 | components = components.next(), 257 | ), 258 | ) 259 | 260 | firstRequest = false 261 | } 262 | 263 | // Message has no embeds nor buttons, and was not sent yet 264 | if (firstRequest) { 265 | DiscordAPI.executeWebhook( 266 | url.get(), 267 | DiscordAPI.Webhook( 268 | content = createClassicMessage(), 269 | username = username.get(), 270 | avatarUrl = avatarUrl.orNull, 271 | ), 272 | ) 273 | 274 | firstRequest = false 275 | } 276 | } 277 | } 278 | 279 | /** 280 | * Create the embeds used for the message 281 | * The list has the content and the links. 282 | * 283 | * Depending on the message style, only the links may be present, 284 | * or it may be empty 285 | */ 286 | fun createEmbeds(): List { 287 | with(parameters) { 288 | return when (MessageLook.valueOf(style.get().look.get())) { 289 | MessageLook.CLASSIC -> createLinkEmbeds() 290 | // Get the link embeds and the modern embed 291 | MessageLook.MODERN -> listOf(createModernEmbed()) + createLinkEmbeds() 292 | } 293 | } 294 | } 295 | 296 | /** 297 | * Create the message body for the classic style 298 | * 299 | * It may be null depending on the configured style 300 | */ 301 | fun createClassicMessage(): String? { 302 | with(parameters) { 303 | if (MessageLook.valueOf(style.get().look.get()) != MessageLook.CLASSIC) { 304 | return null 305 | } 306 | 307 | return createMessageBody() 308 | } 309 | } 310 | 311 | /** 312 | * Create the message embed for the modern style 313 | * 314 | * It will never be null 315 | */ 316 | fun createModernEmbed(): DiscordAPI.Embed { 317 | with(parameters) { 318 | val style: MessageStyle = style.get() 319 | return DiscordAPI.Embed( 320 | thumbnail = style.thumbnailUrl.map { DiscordAPI.EmbedThumbnail(url = it) }.orNull, 321 | description = createMessageBody(), 322 | color = style.color.map { parseColor(it) }.orNull, 323 | ) 324 | } 325 | } 326 | 327 | private fun parseColor(str: String): Int { 328 | return when (str) { 329 | "modrinth" -> 0x1BD96A 330 | "github" -> 0xF6F0FC 331 | "curseforge" -> 0xF16436 332 | else -> parseHexStringOrThrow(str) 333 | } 334 | } 335 | 336 | private fun parseHexStringOrThrow(str: String): Int { 337 | if (!str.startsWith("#")) { 338 | throw IllegalArgumentException("Hex color must start with #") 339 | } 340 | if (str.length != 7) { 341 | throw IllegalArgumentException("Hex color must be 7 characters long") 342 | } 343 | return str.removePrefix("#").toInt(16) 344 | } 345 | 346 | /** 347 | * Create the link embeds for the message 348 | * 349 | * It may be an empty list depending on the configured style 350 | */ 351 | fun createLinkEmbeds(): List { 352 | with(parameters) { 353 | if (LinkType.valueOf(style.get().link.get()) != LinkType.EMBED) { 354 | // Return empty list as there is no link embed 355 | // Doing this helps keeping createEmbeds cleaner 356 | return listOf() 357 | } 358 | 359 | val embeds = publishResults.files.map { 360 | PublishResult.fromJson(it.readText()) 361 | }.map { 362 | DiscordAPI.Embed( 363 | title = it.title, 364 | url = it.link, 365 | color = it.brandColor, 366 | ) 367 | }.toList() 368 | 369 | // Find any embeds with duplicate URLs and throw and error if there are any. 370 | for (embed in embeds) { 371 | val count = embeds.count { it.url == embed.url } 372 | if (count > 1) { 373 | throw IllegalStateException("Duplicate embed URL: ${embed.url} for ${embed.title}") 374 | } 375 | } 376 | 377 | return embeds 378 | } 379 | } 380 | 381 | /** 382 | * Create the link buttons for the message 383 | * 384 | * It may be an empty list depending on the configured style 385 | */ 386 | fun createComponents(): List { 387 | with(parameters) { 388 | if (LinkType.valueOf(style.get().link.get()) != LinkType.BUTTON) { 389 | // Return empty list as there is no button 390 | // Doing this helps keeping createEmbeds cleaner 391 | return listOf() 392 | } 393 | 394 | val components = publishResults.files.map { 395 | PublishResult.fromJson(it.readText()) 396 | }.map { 397 | // Create URL button for the message 398 | DiscordAPI.ButtonComponent( 399 | // Button label, the title for the publisher 400 | label = it.title, 401 | // The URL for the mod page 402 | url = it.link, 403 | ) 404 | }.toList() 405 | 406 | // Find any embeds with duplicate URLs and throw and error if there are any. 407 | for (component in components) { 408 | val count = components.count { it.url == component.url } 409 | if (count > 1) { 410 | throw IllegalStateException("Duplicate component URL: ${component.url} for ${component.label}") 411 | } 412 | } 413 | 414 | // An action row is a row of components, 415 | // it can have up to 5 of them 416 | return components.chunked(5).map { 417 | DiscordAPI.ActionRow( 418 | components = it, 419 | ) 420 | } 421 | } 422 | } 423 | 424 | /** 425 | * Create the body of the message 426 | * 427 | * This is used to inject content if needed 428 | */ 429 | fun createMessageBody(): String { 430 | with(parameters) { 431 | var content = content.get() 432 | 433 | if (LinkType.valueOf(style.get().link.get()) == LinkType.INLINE) { 434 | publishResults.files.map { 435 | PublishResult.fromJson(it.readText()) 436 | }.forEach { 437 | // Append the links to the end of the message 438 | // !!! The current implementation does not support emotes for inline links !!! 439 | content += "\n[${it.title}](${it.link})" 440 | } 441 | } 442 | 443 | return content 444 | } 445 | } 446 | } 447 | } 448 | -------------------------------------------------------------------------------- /src/main/kotlin/me/modmuss50/mpp/platforms/github/Github.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.platforms.github 2 | 3 | import me.modmuss50.mpp.GithubPublishResult 4 | import me.modmuss50.mpp.Platform 5 | import me.modmuss50.mpp.PlatformOptions 6 | import me.modmuss50.mpp.PlatformOptionsInternal 7 | import me.modmuss50.mpp.PublishContext 8 | import me.modmuss50.mpp.PublishModTask 9 | import me.modmuss50.mpp.PublishOptions 10 | import me.modmuss50.mpp.PublishResult 11 | import me.modmuss50.mpp.PublishWorkAction 12 | import me.modmuss50.mpp.PublishWorkParameters 13 | import me.modmuss50.mpp.ReleaseType 14 | import org.gradle.api.Task 15 | import org.gradle.api.file.RegularFileProperty 16 | import org.gradle.api.logging.Logger 17 | import org.gradle.api.provider.Property 18 | import org.gradle.api.provider.Provider 19 | import org.gradle.api.tasks.Input 20 | import org.gradle.api.tasks.InputFile 21 | import org.gradle.api.tasks.Optional 22 | import org.gradle.api.tasks.TaskProvider 23 | import org.jetbrains.annotations.ApiStatus.Internal 24 | import org.kohsuke.github.GHRelease 25 | import org.kohsuke.github.GHReleaseBuilder 26 | import org.kohsuke.github.GHRepository 27 | import org.kohsuke.github.GitHub 28 | import javax.inject.Inject 29 | import kotlin.random.Random 30 | 31 | interface GithubOptions : PlatformOptions, PlatformOptionsInternal { 32 | @get:InputFile 33 | @get:Optional 34 | override val file: RegularFileProperty 35 | 36 | /** 37 | * "owner/repo" 38 | */ 39 | @get:Input 40 | val repository: Property 41 | 42 | /** 43 | * Specifies the commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. 44 | */ 45 | @get:Input 46 | val commitish: Property 47 | 48 | @get:Input 49 | val tagName: Property 50 | 51 | @get:Input 52 | @get:Optional 53 | val apiEndpoint: Property 54 | 55 | @get:Input 56 | val allowEmptyFiles: Property 57 | 58 | @get:InputFile 59 | @get:Optional 60 | @get:Internal 61 | val releaseResult: RegularFileProperty 62 | 63 | override fun setInternalDefaults() { 64 | tagName.convention(version) 65 | allowEmptyFiles.convention(false) 66 | } 67 | 68 | fun from(other: GithubOptions) { 69 | super.from(other) 70 | repository.convention(other.repository) 71 | commitish.convention(other.commitish) 72 | tagName.convention(other.tagName) 73 | apiEndpoint.convention(other.apiEndpoint) 74 | allowEmptyFiles.convention(other.allowEmptyFiles) 75 | releaseResult.convention(other.releaseResult) 76 | } 77 | 78 | fun from(other: Provider) { 79 | from(other.get()) 80 | } 81 | 82 | fun from(other: Provider, publishOptions: Provider) { 83 | from(other) 84 | from(publishOptions.get()) 85 | } 86 | 87 | /** 88 | * Publish to an existing release, created by another task. 89 | */ 90 | fun parent(task: TaskProvider) { 91 | val publishTask = task.map { it as PublishModTask } 92 | releaseResult.set(publishTask.flatMap { it.result }) 93 | 94 | val options = publishTask.map { it.platform as GithubOptions } 95 | version.set(options.flatMap { it.version }) 96 | version.finalizeValue() 97 | changelog.set(options.flatMap { it.changelog }) 98 | changelog.finalizeValue() 99 | type.set(options.flatMap { it.type }) 100 | type.finalizeValue() 101 | displayName.set(options.flatMap { it.displayName }) 102 | displayName.finalizeValue() 103 | repository.set(options.flatMap { it.repository }) 104 | repository.finalizeValue() 105 | commitish.set(options.flatMap { it.commitish }) 106 | commitish.finalizeValue() 107 | tagName.set(options.flatMap { it.tagName }) 108 | tagName.finalizeValue() 109 | } 110 | } 111 | 112 | abstract class Github @Inject constructor(name: String) : Platform(name), GithubOptions { 113 | override fun publish(context: PublishContext) { 114 | val files = additionalFiles.files.toMutableList() 115 | 116 | if (file.isPresent) { 117 | files.add(file.get().asFile) 118 | } 119 | 120 | if (files.isEmpty() && !allowEmptyFiles.get()) { 121 | throw IllegalStateException("No files to upload to GitHub.") 122 | } 123 | 124 | context.submit(UploadWorkAction::class) { 125 | it.from(this) 126 | } 127 | } 128 | 129 | override fun dryRunPublishResult(): PublishResult { 130 | return GithubPublishResult( 131 | repository = repository.get(), 132 | releaseId = 0, 133 | url = "https://github.com/modmuss50/mod-publish-plugin/dry-run?random=${Random.nextInt(0, 1000000)}", 134 | title = announcementTitle.getOrElse("Download from GitHub"), 135 | ) 136 | } 137 | 138 | override fun printDryRunInfo(logger: Logger) { 139 | } 140 | 141 | interface UploadParams : PublishWorkParameters, GithubOptions 142 | 143 | abstract class UploadWorkAction : PublishWorkAction { 144 | // TODO: Maybe look at moving away from using a large library for this. 145 | override fun publish(): PublishResult { 146 | with(parameters) { 147 | val repo = connect().getRepository(repository.get()) 148 | val release = getOrCreateRelease(repo) 149 | 150 | val files = additionalFiles.files.toMutableList() 151 | 152 | if (file.isPresent) { 153 | files.add(file.get().asFile) 154 | } 155 | 156 | val noneUnique = files.groupingBy { it.name }.eachCount().filter { it.value > 1 } 157 | if (noneUnique.isNotEmpty()) { 158 | val noneUniqueNames = noneUnique.keys.joinToString(", ") 159 | throw IllegalStateException("Github file names must be unique within a release, found duplicates: $noneUniqueNames") 160 | } 161 | 162 | for (file in files) { 163 | release.uploadAsset(file, "application/java-archive") 164 | } 165 | 166 | return GithubPublishResult( 167 | repository = repository.get(), 168 | releaseId = release.id, 169 | url = release.htmlUrl.toString(), 170 | title = announcementTitle.getOrElse("Download from GitHub"), 171 | ) 172 | } 173 | } 174 | 175 | private fun getOrCreateRelease(repo: GHRepository): GHRelease { 176 | with(parameters) { 177 | if (releaseResult.isPresent) { 178 | val result = PublishResult.fromJson(releaseResult.get().asFile.readText()) as GithubPublishResult 179 | return repo.getRelease(result.releaseId) 180 | } 181 | 182 | return with(GHReleaseBuilder(repo, tagName.get())) { 183 | name(displayName.get()) 184 | body(changelog.get()) 185 | prerelease(type.get() != ReleaseType.STABLE) 186 | commitish(commitish.get()) 187 | }.create() 188 | } 189 | } 190 | 191 | private fun connect(): GitHub { 192 | val accessToken = parameters.accessToken.get() 193 | val endpoint = parameters.apiEndpoint.orNull 194 | 195 | if (endpoint != null) { 196 | return GitHub.connectUsingOAuth(endpoint, accessToken) 197 | } 198 | 199 | return GitHub.connectUsingOAuth(accessToken) 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/main/kotlin/me/modmuss50/mpp/platforms/modrinth/Modrinth.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.platforms.modrinth 2 | 3 | import me.modmuss50.mpp.HttpUtils 4 | import me.modmuss50.mpp.MinecraftApi 5 | import me.modmuss50.mpp.ModrinthPublishResult 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.logging.Logger 20 | import org.gradle.api.provider.ListProperty 21 | import org.gradle.api.provider.Property 22 | import org.gradle.api.provider.Provider 23 | import org.gradle.api.tasks.Input 24 | import org.gradle.api.tasks.Internal 25 | import org.gradle.api.tasks.Optional 26 | import org.jetbrains.annotations.ApiStatus 27 | import java.nio.file.Path 28 | import javax.inject.Inject 29 | import kotlin.random.Random 30 | import kotlin.reflect.KClass 31 | 32 | interface ModrinthOptions : PlatformOptions, PlatformOptionsInternal, ModrinthDependencyContainer { 33 | companion object { 34 | // https://github.com/modrinth/labrinth/blob/ae1c5342f2017c1c93008d1e87f1a29549dca92f/src/scheduler.rs#L112 35 | @JvmStatic 36 | val WALL_OF_SHAME = mapOf( 37 | "1.14.2 Pre-Release 4" to "1.14.2-pre4", 38 | "1.14.2 Pre-Release 3" to "1.14.2-pre3", 39 | "1.14.2 Pre-Release 2" to "1.14.2-pre2", 40 | "1.14.2 Pre-Release 1" to "1.14.2-pre1", 41 | "1.14.1 Pre-Release 2" to "1.14.1-pre2", 42 | "1.14.1 Pre-Release 1" to "1.14.1-pre1", 43 | "1.14 Pre-Release 5" to "1.14-pre5", 44 | "1.14 Pre-Release 4" to "1.14-pre4", 45 | "1.14 Pre-Release 3" to "1.14-pre3", 46 | "1.14 Pre-Release 2" to "1.14-pre2", 47 | "1.14 Pre-Release 1" to "1.14-pre1", 48 | "3D Shareware v1.34" to "3D-Shareware-v1.34", 49 | ) 50 | } 51 | 52 | @get:Input 53 | val projectId: Property 54 | 55 | @get:Input 56 | val minecraftVersions: ListProperty 57 | 58 | @get:Input 59 | val featured: Property 60 | 61 | /** 62 | * When set, this will update the project description to the provided value. 63 | */ 64 | @get:Input 65 | @get:Optional 66 | val projectDescription: Property 67 | 68 | @get:Input 69 | val apiEndpoint: Property 70 | 71 | @ApiStatus.Internal 72 | override fun setInternalDefaults() { 73 | featured.convention(false) 74 | apiEndpoint.convention("https://api.modrinth.com/v2") 75 | } 76 | 77 | fun minecraftVersionRange(action: Action) { 78 | val options = objectFactory.newInstance(ModrinthVersionRangeOptions::class.java) 79 | options.includeSnapshots.convention(false) 80 | action.execute(options) 81 | 82 | val startId = options.start.get() 83 | val endId = options.end.get() 84 | val includeSnapshots = options.includeSnapshots.get() 85 | 86 | minecraftVersions.addAll( 87 | providerFactory.provider { 88 | MinecraftApi().getVersionsInRange(startId, endId, includeSnapshots).map { WALL_OF_SHAME.getOrDefault(it, it) } 89 | }, 90 | ) 91 | } 92 | 93 | fun from(other: ModrinthOptions) { 94 | super.from(other) 95 | fromDependencies(other) 96 | projectId.convention(other.projectId) 97 | minecraftVersions.convention(other.minecraftVersions) 98 | featured.convention(other.featured) 99 | projectDescription.convention(other.projectDescription) 100 | apiEndpoint.convention(other.apiEndpoint) 101 | } 102 | 103 | fun from(other: Provider) { 104 | from(other.get()) 105 | } 106 | 107 | fun from(other: Provider, publishOptions: Provider) { 108 | from(other) 109 | from(publishOptions.get()) 110 | } 111 | 112 | override val platformDependencyKClass: KClass 113 | get() = ModrinthDependency::class 114 | } 115 | 116 | /** 117 | * Provides shorthand methods for adding dependencies to modrinth 118 | */ 119 | interface ModrinthDependencyContainer : PlatformDependencyContainer { 120 | fun requires(vararg slugs: String) { 121 | addInternal(PlatformDependency.DependencyType.REQUIRED, slugs) 122 | } 123 | fun optional(vararg slugs: String) { 124 | addInternal(PlatformDependency.DependencyType.OPTIONAL, slugs) 125 | } 126 | fun incompatible(vararg slugs: String) { 127 | addInternal(PlatformDependency.DependencyType.INCOMPATIBLE, slugs) 128 | } 129 | fun embeds(vararg slugs: String) { 130 | addInternal(PlatformDependency.DependencyType.EMBEDDED, slugs) 131 | } 132 | 133 | @Internal 134 | fun addInternal(type: PlatformDependency.DependencyType, slugs: Array) { 135 | slugs.forEach { 136 | dependencies.add( 137 | objectFactory.newInstance(ModrinthDependency::class.java).apply { 138 | this.slug.set(it) 139 | this.type.set(type) 140 | }, 141 | ) 142 | } 143 | } 144 | } 145 | 146 | interface ModrinthDependency : PlatformDependency { 147 | @get:Input 148 | @get:Optional 149 | val id: Property 150 | 151 | @get:Input 152 | @get:Optional 153 | val slug: Property 154 | 155 | @get:Input 156 | @get:Optional 157 | val version: Property 158 | 159 | @Deprecated("For removal", ReplaceWith("id")) 160 | val projectId: Property get() = id 161 | } 162 | 163 | interface ModrinthVersionRangeOptions { 164 | /** 165 | * The start version of the range (inclusive) 166 | */ 167 | val start: Property 168 | 169 | /** 170 | * The end version of the range (exclusive) 171 | */ 172 | val end: Property 173 | 174 | /** 175 | * Whether to include snapshot versions in the range 176 | */ 177 | val includeSnapshots: Property 178 | } 179 | 180 | abstract class Modrinth @Inject constructor(name: String) : Platform(name), ModrinthOptions { 181 | override fun validateInputs() { 182 | super.validateInputs() 183 | Validators.validateUnique("minecraftVersions", minecraftVersions) 184 | } 185 | 186 | override fun publish(context: PublishContext) { 187 | context.submit(UploadWorkAction::class) { 188 | it.from(this) 189 | } 190 | } 191 | 192 | override fun dryRunPublishResult(): PublishResult { 193 | return ModrinthPublishResult( 194 | // Use a random file ID so that the URL is different each time, this is needed because discord drops duplicate URLs 195 | id = "${Random.nextInt(0, 1000000)}", 196 | projectId = "dry-run", 197 | title = announcementTitle.getOrElse("Download from Modrinth"), 198 | ) 199 | } 200 | 201 | override fun printDryRunInfo(logger: Logger) { 202 | for (dependency in dependencies.get()) { 203 | val idOrSlug = dependency.id.orNull ?: dependency.slug.get() 204 | logger.lifecycle("Dependency(id/slug: $idOrSlug, version: ${dependency.version.orNull})") 205 | } 206 | } 207 | 208 | interface UploadParams : PublishWorkParameters, ModrinthOptions 209 | 210 | abstract class UploadWorkAction : PublishWorkAction { 211 | override fun publish(): PublishResult { 212 | with(parameters) { 213 | val api = ModrinthApi(accessToken.get(), apiEndpoint.get()) 214 | 215 | val primaryFileKey = "primaryFile" 216 | val files = HashMap() 217 | files[primaryFileKey] = file.path 218 | 219 | additionalFiles.files.forEachIndexed { index, additionalFile -> 220 | files["file_$index"] = additionalFile.toPath() 221 | } 222 | 223 | val dependencies = dependencies.get().map { toApiDependency(it, api) } 224 | 225 | val metadata = ModrinthApi.CreateVersion( 226 | name = displayName.get(), 227 | versionNumber = version.get(), 228 | changelog = changelog.orNull, 229 | dependencies = dependencies, 230 | gameVersions = minecraftVersions.get(), 231 | versionType = ModrinthApi.VersionType.valueOf(type.get()), 232 | loaders = modLoaders.get().map { it.lowercase() }, 233 | featured = featured.get(), 234 | projectId = projectId.get().modrinthId, 235 | fileParts = files.keys.toList(), 236 | primaryFile = primaryFileKey, 237 | ) 238 | 239 | val response = HttpUtils.retry(maxRetries.get(), "Failed to create version") { 240 | api.createVersion(metadata, files) 241 | } 242 | 243 | if (projectDescription.isPresent) { 244 | HttpUtils.retry(maxRetries.get(), "Failed to update project description") { 245 | api.modifyProject(projectId.get().modrinthId, ModrinthApi.ModifyProject(body = projectDescription.get())) 246 | } 247 | } 248 | 249 | return ModrinthPublishResult( 250 | id = response.id, 251 | projectId = response.projectId, 252 | title = announcementTitle.getOrElse("Download from Modrinth"), 253 | ) 254 | } 255 | } 256 | 257 | private fun toApiDependency(dependency: ModrinthDependency, api: ModrinthApi): ModrinthApi.Dependency { 258 | with(dependency) { 259 | var projectId: String? = null 260 | var versionId: String? = null 261 | 262 | // Use the project ID if we have it 263 | if (id.isPresent) { 264 | projectId = id.get().modrinthId 265 | } 266 | 267 | // Lookup the project ID from the slug 268 | if (slug.isPresent) { 269 | // Don't allow a slug and id to both be specified 270 | if (projectId != null) { 271 | throw IllegalStateException("Modrinth dependency cannot specify both projectId and projectSlug") 272 | } 273 | 274 | projectId = HttpUtils.retry(parameters.maxRetries.get(), "Failed to lookup project id from slug: ${slug.get()}") { 275 | api.checkProject(slug.get()) 276 | }.id 277 | } 278 | 279 | // Ensure we have an id 280 | if (projectId == null) { 281 | throw IllegalStateException("Modrinth dependency has no configured projectId or projectSlug value") 282 | } 283 | 284 | if (version.isPresent) { 285 | val response = HttpUtils.retry(parameters.maxRetries.get(), "Failed to list versions from slug/id: ${version.get()}") { 286 | api.listVersions(projectId) 287 | } 288 | 289 | val versions = response.filter { 290 | it.id == version.get() || it.versionNumber == version.get() 291 | } 292 | 293 | versionId = when (versions.size) { 294 | 0 -> throw IllegalStateException("Modrinth dependency has a version configured but no matches found for version: ${version.get()}") 295 | 1 -> versions.first().id 296 | else -> throw IllegalStateException("Modrinth dependency has a version configured but multiple matches found for version: ${version.get()}") 297 | } 298 | } 299 | 300 | return ModrinthApi.Dependency( 301 | projectId = projectId, 302 | versionId = versionId, 303 | dependencyType = ModrinthApi.DependencyType.valueOf(type.get()), 304 | ) 305 | } 306 | } 307 | } 308 | } 309 | 310 | private val ID_REGEX = Regex("[0-9a-zA-Z]{8}") 311 | 312 | // Returns a validated ModrithID 313 | private val String.modrinthId: String 314 | get() { 315 | if (!this.matches(ID_REGEX)) { 316 | throw IllegalArgumentException("$this is not a valid Modrinth ID") 317 | } 318 | 319 | return this 320 | } 321 | -------------------------------------------------------------------------------- /src/main/kotlin/me/modmuss50/mpp/platforms/modrinth/ModrinthApi.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.platforms.modrinth 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.SerializationException 6 | import kotlinx.serialization.encodeToString 7 | import kotlinx.serialization.json.Json 8 | import me.modmuss50.mpp.HttpUtils 9 | import me.modmuss50.mpp.PlatformDependency 10 | import me.modmuss50.mpp.ReleaseType 11 | import okhttp3.MediaType.Companion.toMediaTypeOrNull 12 | import okhttp3.MultipartBody 13 | import okhttp3.RequestBody.Companion.asRequestBody 14 | import okhttp3.RequestBody.Companion.toRequestBody 15 | import okhttp3.Response 16 | import java.nio.file.Path 17 | import java.time.Duration 18 | import kotlin.io.path.name 19 | 20 | // https://docs.modrinth.com/api-spec/#tag/versions/operation/createVersion 21 | class ModrinthApi(private val accessToken: String, private val baseUrl: String) { 22 | private val httpUtils = HttpUtils( 23 | exceptionFactory = ModrinthHttpExceptionFactory(), 24 | // Increase the timeout as Modrinth can be slow 25 | timeout = Duration.ofSeconds(60), 26 | ) 27 | 28 | @Serializable 29 | enum class VersionType { 30 | @SerialName("alpha") 31 | ALPHA, 32 | 33 | @SerialName("beta") 34 | BETA, 35 | 36 | @SerialName("release") 37 | RELEASE, 38 | ; 39 | 40 | companion object { 41 | fun valueOf(type: ReleaseType): VersionType { 42 | return when (type) { 43 | ReleaseType.STABLE -> RELEASE 44 | ReleaseType.BETA -> BETA 45 | ReleaseType.ALPHA -> ALPHA 46 | } 47 | } 48 | } 49 | } 50 | 51 | @Serializable 52 | data class CreateVersion( 53 | val name: String, 54 | @SerialName("version_number") 55 | val versionNumber: String, 56 | val changelog: String? = null, 57 | val dependencies: List, 58 | @SerialName("game_versions") 59 | val gameVersions: List, 60 | @SerialName("version_type") 61 | val versionType: VersionType, 62 | val loaders: List, 63 | val featured: Boolean, 64 | val status: String? = null, 65 | @SerialName("requested_status") 66 | val requestedStatus: String? = null, 67 | @SerialName("project_id") 68 | val projectId: String, 69 | @SerialName("file_parts") 70 | val fileParts: List, 71 | @SerialName("primary_file") 72 | val primaryFile: String? = null, 73 | ) 74 | 75 | @Serializable 76 | data class Dependency( 77 | @SerialName("version_id") 78 | val versionId: String? = null, 79 | @SerialName("project_id") 80 | val projectId: String? = null, 81 | @SerialName("file_name") 82 | val fileName: String? = null, 83 | @SerialName("dependency_type") 84 | val dependencyType: DependencyType, 85 | ) 86 | 87 | @Serializable 88 | enum class DependencyType { 89 | @SerialName("required") 90 | REQUIRED, 91 | 92 | @SerialName("optional") 93 | OPTIONAL, 94 | 95 | @SerialName("incompatible") 96 | INCOMPATIBLE, 97 | 98 | @SerialName("embedded") 99 | EMBEDDED, 100 | ; 101 | 102 | companion object { 103 | fun valueOf(type: PlatformDependency.DependencyType): DependencyType { 104 | return when (type) { 105 | PlatformDependency.DependencyType.REQUIRED -> REQUIRED 106 | PlatformDependency.DependencyType.OPTIONAL -> OPTIONAL 107 | PlatformDependency.DependencyType.INCOMPATIBLE -> INCOMPATIBLE 108 | PlatformDependency.DependencyType.EMBEDDED -> EMBEDDED 109 | } 110 | } 111 | } 112 | } 113 | 114 | // There's more but we don't need it 115 | @Serializable 116 | data class ListVersionsResponse( 117 | @SerialName("version_number") 118 | val versionNumber: String, 119 | val id: String, 120 | ) 121 | 122 | // There is a lot more to this response, however we dont need it. 123 | @Serializable 124 | data class CreateVersionResponse( 125 | val id: String, 126 | @SerialName("project_id") 127 | val projectId: String, 128 | @SerialName("author_id") 129 | val authorId: String, 130 | ) 131 | 132 | @Serializable 133 | data class ProjectCheckResponse( 134 | val id: String, 135 | ) 136 | 137 | // https://docs.modrinth.com/#tag/projects/operation/modifyProject 138 | @Serializable 139 | data class ModifyProject( 140 | val body: String, 141 | ) 142 | 143 | @Serializable 144 | data class ErrorResponse( 145 | val error: String, 146 | val description: String, 147 | ) 148 | 149 | private val headers: Map 150 | get() = mapOf( 151 | "Authorization" to accessToken, 152 | "Content-Type" to "application/json", 153 | ) 154 | 155 | fun listVersions(projectSlug: String): Array { 156 | return httpUtils.get("$baseUrl/project/$projectSlug/version", headers) 157 | } 158 | 159 | fun createVersion(metadata: CreateVersion, files: Map): CreateVersionResponse { 160 | val mediaType = "application/java-archive".toMediaTypeOrNull() 161 | val metadataJson = Json.encodeToString(metadata) 162 | 163 | val bodyBuilder = MultipartBody.Builder() 164 | .setType(MultipartBody.FORM) 165 | .addFormDataPart("data", metadataJson) 166 | 167 | for ((name, path) in files) { 168 | bodyBuilder.addFormDataPart(name, path.name, path.toFile().asRequestBody(mediaType)) 169 | } 170 | 171 | return httpUtils.post("$baseUrl/version", bodyBuilder.build(), headers) 172 | } 173 | 174 | fun checkProject(projectSlug: String): ProjectCheckResponse { 175 | return httpUtils.get("$baseUrl/project/$projectSlug/check", headers) 176 | } 177 | 178 | fun modifyProject(projectSlug: String, modifyProject: ModifyProject) { 179 | val body = Json.encodeToString(modifyProject).toRequestBody() 180 | httpUtils.patch("$baseUrl/project/$projectSlug", body, headers) 181 | } 182 | 183 | private class ModrinthHttpExceptionFactory : HttpUtils.HttpExceptionFactory { 184 | val json = Json { ignoreUnknownKeys = true } 185 | 186 | override fun createException(response: Response): HttpUtils.HttpException { 187 | return try { 188 | val errorResponse = json.decodeFromString(response.body!!.string()) 189 | HttpUtils.HttpException(response, errorResponse.description) 190 | } catch (e: SerializationException) { 191 | HttpUtils.HttpException(response, "Unknown error") 192 | } 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/IntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test 2 | 3 | import org.gradle.testkit.runner.BuildResult 4 | import org.gradle.testkit.runner.GradleRunner 5 | import org.intellij.lang.annotations.Language 6 | import java.io.File 7 | 8 | interface IntegrationTest { 9 | companion object { 10 | @Language("gradle") 11 | val kotlinHeader = """ 12 | plugins { 13 | java 14 | id("me.modmuss50.mod-publish-plugin") 15 | } 16 | """.trimIndent() 17 | 18 | @Language("gradle") 19 | val groovyHeader = """ 20 | plugins { 21 | id 'java' 22 | id 'me.modmuss50.mod-publish-plugin' 23 | } 24 | """.trimIndent() 25 | } 26 | 27 | fun gradleTest(groovy: Boolean = false): TestBuilder { 28 | return TestBuilder(groovy) 29 | } 30 | 31 | class TestBuilder(groovy: Boolean) { 32 | private val runner = GradleRunner.create() 33 | .withPluginClasspath() 34 | .forwardOutput() 35 | .withDebug(true) 36 | 37 | private val gradleHome: File 38 | private val projectDir: File 39 | private val buildScript: File 40 | private val gradleSettings: File 41 | private var arguments = ArrayList() 42 | private var notConfigCacheCompatible = false 43 | 44 | init { 45 | val testDir = File("build/intergation_test") 46 | val ext = if (groovy) { "" } else { ".kts" } 47 | gradleHome = File(testDir, "home") 48 | projectDir = File(testDir, "project") 49 | buildScript = File(projectDir, "build.gradle$ext") 50 | gradleSettings = File(projectDir, "settings.gradle$ext") 51 | 52 | projectDir.mkdirs() 53 | 54 | // Clean up 55 | File(projectDir, "build.gradle").delete() 56 | File(projectDir, "build.gradle.kts").delete() 57 | File(projectDir, "settings.gradle").delete() 58 | File(projectDir, "settings.gradle.kts").delete() 59 | 60 | // Create a fmj for modrith 61 | val resources = File(projectDir, "src/main/resources") 62 | resources.mkdirs() 63 | File(resources, "fabric.mod.json").writeText("{}") 64 | 65 | buildScript(if (groovy) groovyHeader else kotlinHeader) 66 | 67 | gradleSettings.writeText("rootProject.name = \"mpp-example\"") 68 | 69 | runner.withProjectDir(projectDir) 70 | argument("--gradle-user-home", gradleHome.absolutePath) 71 | argument("--stacktrace") 72 | argument("--warning-mode", "fail") 73 | argument("clean") 74 | } 75 | 76 | // Disables the configuration cache for this test 77 | fun notConfigCacheCompatible(): TestBuilder { 78 | notConfigCacheCompatible = true 79 | return this 80 | } 81 | 82 | // Appends to an existing buildscript 83 | fun buildScript(@Language("gradle") script: String): TestBuilder { 84 | buildScript.appendText(script + "\n") 85 | return this 86 | } 87 | 88 | // Creates a new file in the project directory with the given content 89 | fun file(path: String, content: String): TestBuilder { 90 | val file = File(projectDir, path) 91 | file.writeText(content) 92 | return this 93 | } 94 | 95 | fun subProject(name: String, @Language("gradle") script: String = ""): TestBuilder { 96 | val subProjectDir = File(projectDir, name) 97 | 98 | if (subProjectDir.exists()) { 99 | subProjectDir.deleteRecursively() 100 | } 101 | 102 | subProjectDir.mkdirs() 103 | 104 | val subBuildScript = File(subProjectDir, "build.gradle.kts") 105 | subBuildScript.appendText(kotlinHeader + "\n") 106 | subBuildScript.appendText(script) 107 | 108 | gradleSettings.appendText("\ninclude(\"$name\")") 109 | 110 | return this 111 | } 112 | 113 | fun argument(vararg args: String) { 114 | arguments.addAll(args) 115 | } 116 | 117 | fun run(task: String): BuildResult { 118 | if (!notConfigCacheCompatible) { 119 | argument("--configuration-cache") 120 | } 121 | argument(task) 122 | runner.withArguments(arguments) 123 | return runner.run() 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/MockWebServer.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test 2 | 3 | import io.javalin.Javalin 4 | import io.javalin.apibuilder.EndpointGroup 5 | 6 | class MockWebServer(val api: T) : AutoCloseable { 7 | private val server: Javalin = Javalin.create { config -> 8 | config.router.apiBuilder(api.routes()) 9 | }.start(9082) 10 | 11 | val endpoint: String 12 | get() = "http://localhost:9082" 13 | 14 | override fun close() { 15 | server.stop() 16 | } 17 | 18 | interface MockApi { 19 | fun routes(): EndpointGroup 20 | } 21 | 22 | class CombinedApi(val apis: List) : MockApi { 23 | override fun routes(): EndpointGroup { 24 | return EndpointGroup { 25 | for (api in apis) { 26 | api.routes().addEndpoints() 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/ProductionTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.json.Json 5 | import org.gradle.testkit.runner.TaskOutcome 6 | import java.io.File 7 | import kotlin.test.Ignore 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | 11 | /** 12 | * Enable this test to publish real files, to the real sites! Create a options.json with all the tokens 13 | */ 14 | @Ignore 15 | class ProductionTest : IntegrationTest { 16 | @Test 17 | fun run() { 18 | val json = Json { ignoreUnknownKeys = true } 19 | val options = json.decodeFromString(File("options.json").readText()) 20 | 21 | val result = gradleTest() 22 | .buildScript( 23 | """ 24 | publishMods { 25 | file = tasks.jar.flatMap { it.archiveFile } 26 | changelog = "- Changelog line 1\n- Changelog line 2" 27 | version = "1.0.0" 28 | type = BETA 29 | modLoaders.add("fabric") 30 | displayName = "Test Upload" 31 | 32 | curseforge { 33 | accessToken = "${options.curseforgeToken}" 34 | projectId = "${options.curseforgeProject}" 35 | projectSlug = "${options.curseforgeProjectSlug}" 36 | minecraftVersions.add("1.20.1") 37 | javaVersions.add(JavaVersion.VERSION_17) 38 | clientRequired = true 39 | serverRequired = true 40 | 41 | requires { 42 | slug = "fabric-api" 43 | } 44 | } 45 | 46 | // github { 47 | // accessToken = "${options.githubToken}" 48 | // repository = "${options.githubRepo}" 49 | // commitish = "main" 50 | // } 51 | 52 | modrinth { 53 | accessToken = "${options.modrinthToken}" 54 | projectId = "${options.modrinthProject}" 55 | minecraftVersions.add("1.20.1") 56 | 57 | requires { 58 | id = "P7dR8mSH" 59 | } 60 | } 61 | 62 | discord { 63 | username = "Great test mod" 64 | avatarUrl = "https://placekitten.com/500/500" 65 | content = changelog.map { "## A new version of my mod has been uploaded:\n" + it } 66 | webhookUrl = "${options.discordWebhook}" 67 | } 68 | } 69 | """.trimIndent(), 70 | ) 71 | .run("publishMods") 72 | 73 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 74 | } 75 | } 76 | 77 | @Serializable 78 | data class ProductionOptions( 79 | val curseforgeToken: String, 80 | val curseforgeProject: String, 81 | val curseforgeProjectSlug: String, 82 | val modrinthToken: String, 83 | val modrinthProject: String, 84 | val githubToken: String, 85 | val githubRepo: String, 86 | val discordWebhook: String, 87 | ) 88 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/curseforge/CurseforgeVersionsTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.curseforge 2 | 3 | import kotlinx.serialization.json.Json 4 | import me.modmuss50.mpp.platforms.curseforge.CurseforgeVersions 5 | import org.gradle.api.JavaVersion 6 | import org.junit.jupiter.api.Test 7 | import java.io.BufferedReader 8 | import kotlin.test.assertEquals 9 | 10 | class CurseforgeVersionsTest { 11 | val json = Json { ignoreUnknownKeys = true } 12 | 13 | @Test 14 | fun minecraftVersions() { 15 | val versions = createVersions() 16 | assertEquals(9990, versions.getMinecraftVersion("1.20.1")) 17 | } 18 | 19 | @Test 20 | fun modLoader() { 21 | val versions = createVersions() 22 | assertEquals(7499, versions.getModLoaderVersion("fabric")) 23 | } 24 | 25 | @Test 26 | fun client() { 27 | val versions = createVersions() 28 | assertEquals(9638, versions.getClientVersion()) 29 | } 30 | 31 | @Test 32 | fun server() { 33 | val versions = createVersions() 34 | assertEquals(9639, versions.getServerVersion()) 35 | } 36 | 37 | @Test 38 | fun javaVersions() { 39 | val versions = createVersions() 40 | assertEquals(4458, versions.getJavaVersion(JavaVersion.VERSION_1_8)) 41 | assertEquals(8320, versions.getJavaVersion(JavaVersion.VERSION_11)) 42 | assertEquals(8326, versions.getJavaVersion(JavaVersion.VERSION_17)) 43 | } 44 | 45 | private fun createVersions(): CurseforgeVersions { 46 | val versionTypes = readResource("curseforge_version_types.json") 47 | val versions = readResource("curseforge_versions.json") 48 | return CurseforgeVersions(json.decodeFromString(versionTypes), json.decodeFromString(versions)) 49 | } 50 | 51 | private fun readResource(path: String): String { 52 | this::class.java.classLoader!!.getResourceAsStream(path).use { inputStream -> 53 | BufferedReader(inputStream!!.reader()).use { reader -> 54 | return reader.readText() 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/curseforge/MockCurseforgeApi.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.curseforge 2 | 3 | import io.javalin.apibuilder.ApiBuilder.before 4 | import io.javalin.apibuilder.ApiBuilder.get 5 | import io.javalin.apibuilder.ApiBuilder.path 6 | import io.javalin.apibuilder.ApiBuilder.post 7 | import io.javalin.apibuilder.EndpointGroup 8 | import io.javalin.http.BadRequestResponse 9 | import io.javalin.http.Context 10 | import io.javalin.http.UnauthorizedResponse 11 | import kotlinx.serialization.ExperimentalSerializationApi 12 | import kotlinx.serialization.json.Json 13 | import me.modmuss50.mpp.platforms.curseforge.CurseforgeApi 14 | import me.modmuss50.mpp.test.MockWebServer 15 | import java.io.BufferedReader 16 | 17 | class MockCurseforgeApi : MockWebServer.MockApi { 18 | @OptIn(ExperimentalSerializationApi::class) 19 | val json = Json { ignoreUnknownKeys = true; explicitNulls = false } 20 | var lastMetadata: CurseforgeApi.UploadFileMetadata? = null 21 | var allMetadata: ArrayList = ArrayList() 22 | val files: ArrayList = ArrayList() 23 | 24 | override fun routes(): EndpointGroup { 25 | return EndpointGroup { 26 | path("api") { 27 | path("game/version-types") { 28 | before(this::authHandler) 29 | get(this::versionTypes) 30 | } 31 | path("game/versions") { 32 | before(this::authHandler) 33 | get(this::versions) 34 | } 35 | path("projects/{projectId}/upload-file") { 36 | before(this::authHandler) 37 | post(this::uploadFile) 38 | } 39 | } 40 | } 41 | } 42 | 43 | private fun authHandler(context: Context) { 44 | val apiToken = context.header("X-Api-Token") 45 | 46 | if (apiToken != "123") { 47 | throw UnauthorizedResponse( 48 | """ 49 | { 50 | "errorCode": 401, 51 | "errorMessage": "You must provide an API token using the `X-Api-Token` header, the `token` query string parameter, your email address and an API token using HTTP basic authentication." 52 | } 53 | """.trimIndent(), 54 | ) 55 | } 56 | } 57 | 58 | private fun versionTypes(context: Context) { 59 | val versions = readResource("curseforge_version_types.json") 60 | context.result(versions) 61 | } 62 | 63 | private fun versions(context: Context) { 64 | val versions = readResource("curseforge_versions.json") 65 | context.result(versions) 66 | } 67 | 68 | private fun uploadFile(context: Context) { 69 | val metadata = context.formParam("metadata") 70 | val file = context.uploadedFile("file") 71 | 72 | if (metadata == null) { 73 | throw BadRequestResponse("No metadata") 74 | } 75 | 76 | if (file == null) { 77 | throw BadRequestResponse("No file") 78 | } 79 | 80 | lastMetadata = json.decodeFromString(metadata) 81 | allMetadata.add(lastMetadata!!) 82 | files.add(file.filename()) 83 | 84 | context.result("""{"id": "20402"}""") 85 | } 86 | 87 | private fun readResource(path: String): String { 88 | this::class.java.classLoader!!.getResourceAsStream(path).use { inputStream -> 89 | BufferedReader(inputStream!!.reader()).use { reader -> 90 | return reader.readText() 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/discord/BotTokenGenerator.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.discord 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.encodeToString 5 | import kotlinx.serialization.json.Json 6 | import me.modmuss50.mpp.HttpUtils 7 | import okhttp3.RequestBody.Companion.toRequestBody 8 | import java.io.File 9 | 10 | val json = Json { ignoreUnknownKeys = true } 11 | val httpUtils = HttpUtils() 12 | 13 | /* 14 | Use this to generate a bot created webhook URL for testing the discord support 15 | Get a bot token from https://discord.com/developers/applications and set it in options.json 16 | Run this with the channel id as the first argument 17 | */ 18 | fun main(args: Array) { 19 | if (args.size != 1) { 20 | println("Usage: BotTokenGenerator ") 21 | return 22 | } 23 | 24 | val options = json.decodeFromString(File("options.json").readText()) 25 | 26 | val channelId = args[0] 27 | val headers: Map = mapOf( 28 | "Content-Type" to "application/json", 29 | "Authorization" to "Bot ${options.discordBotToken}", 30 | ) 31 | 32 | val request = CreateWebhookRequest("test") 33 | val body = json.encodeToString(request).toRequestBody() 34 | val response = httpUtils.post("https://discord.com/api/v9/channels/$channelId/webhooks", body, headers) 35 | 36 | println(response) 37 | } 38 | 39 | @Serializable 40 | data class CreateWebhookRequest( 41 | val name: String, 42 | ) 43 | 44 | @Serializable 45 | data class CreateWebhookResponse( 46 | val url: String, 47 | ) 48 | 49 | @Serializable 50 | data class Options( 51 | val discordBotToken: String, 52 | ) 53 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/discord/DiscordIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.discord 2 | 3 | import kotlinx.serialization.json.Json 4 | import me.modmuss50.mpp.test.IntegrationTest 5 | import me.modmuss50.mpp.test.ProductionOptions 6 | import org.gradle.testkit.runner.TaskOutcome 7 | import org.intellij.lang.annotations.Language 8 | import java.io.File 9 | import kotlin.test.Ignore 10 | import kotlin.test.Test 11 | import kotlin.test.assertEquals 12 | 13 | /** 14 | * Enable this test to execute a dry run, this is more to test the discord integration. Create a options.json with all the tokens 15 | */ 16 | @Ignore 17 | class DiscordIntegrationTest : IntegrationTest { 18 | @Test 19 | fun run() { 20 | //region Classic message body 21 | var result = gradleTest() 22 | .buildScript( 23 | createScript(""), 24 | ) 25 | .run("publishMods") 26 | 27 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 28 | 29 | result = gradleTest() 30 | .buildScript( 31 | createScript( 32 | """ 33 | style { 34 | link = "BUTTON" 35 | } 36 | """.trimIndent(), 37 | ), 38 | ) 39 | .run("publishMods") 40 | 41 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 42 | 43 | result = gradleTest() 44 | .buildScript( 45 | createScript( 46 | """ 47 | style { 48 | link = "INLINE" 49 | } 50 | """.trimIndent(), 51 | ), 52 | ) 53 | .run("publishMods") 54 | 55 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 56 | //endregion 57 | 58 | //region Modern message body 59 | result = gradleTest() 60 | .buildScript( 61 | createScript( 62 | """ 63 | style { 64 | look = "MODERN" 65 | } 66 | """.trimIndent(), 67 | ), 68 | ) 69 | .run("publishMods") 70 | 71 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 72 | 73 | result = gradleTest() 74 | .buildScript( 75 | createScript( 76 | """ 77 | style { 78 | look = "MODERN" 79 | link = "BUTTON" 80 | } 81 | """.trimIndent(), 82 | ), 83 | ) 84 | .run("publishMods") 85 | 86 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 87 | 88 | result = gradleTest() 89 | .buildScript( 90 | createScript( 91 | """ 92 | style { 93 | look = "MODERN" 94 | link = "INLINE" 95 | } 96 | """.trimIndent(), 97 | ), 98 | ) 99 | .run("publishMods") 100 | 101 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 102 | //endregion 103 | } 104 | 105 | private fun createScript(@Language("gradle") style: String): String { 106 | val json = Json { ignoreUnknownKeys = true } 107 | val options = json.decodeFromString(File("options.json").readText()) 108 | 109 | @Language("gradle") 110 | val buildScript = """ 111 | publishMods { 112 | file = tasks.jar.flatMap { it.archiveFile } 113 | changelog = "- Changelog line 1\n- Changelog line 2" 114 | version = "1.0.0" 115 | type = BETA 116 | modLoaders.add("fabric") 117 | displayName = "Test Upload" 118 | dryRun = true 119 | 120 | curseforge { 121 | accessToken = "${options.curseforgeToken}" 122 | projectId = "${options.curseforgeProject}" 123 | projectSlug = "${options.curseforgeProjectSlug}" 124 | minecraftVersions.add("1.20.1") 125 | javaVersions.add(JavaVersion.VERSION_17) 126 | clientRequired = true 127 | serverRequired = true 128 | 129 | requires { 130 | slug = "fabric-api" 131 | } 132 | } 133 | 134 | // github { 135 | // accessToken = "${options.githubToken}" 136 | // repository = "${options.githubRepo}" 137 | // commitish = "main" 138 | // } 139 | 140 | modrinth { 141 | accessToken = "${options.modrinthToken}" 142 | projectId = "${options.modrinthProject}" 143 | minecraftVersions.add("1.20.1") 144 | 145 | requires { 146 | id = "P7dR8mSH" 147 | } 148 | } 149 | 150 | discord { 151 | username = "Great test mod" 152 | avatarUrl = "https://placekitten.com/500/500" 153 | content = changelog.map { "## A new version of my mod has been uploaded:\n" + it } 154 | webhookUrl = "${options.discordWebhook}" 155 | dryRunWebhookUrl = "${options.discordWebhook}" 156 | $style 157 | } 158 | } 159 | """ 160 | 161 | return buildScript.trimIndent() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/discord/MockDiscordApi.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.discord 2 | 3 | import io.javalin.apibuilder.ApiBuilder.get 4 | import io.javalin.apibuilder.ApiBuilder.path 5 | import io.javalin.apibuilder.ApiBuilder.post 6 | import io.javalin.apibuilder.EndpointGroup 7 | import io.javalin.http.Context 8 | import kotlinx.serialization.json.Json 9 | import me.modmuss50.mpp.platforms.discord.DiscordAPI 10 | import me.modmuss50.mpp.test.MockWebServer 11 | 12 | class MockDiscordApi : MockWebServer.MockApi { 13 | private val json = Json { classDiscriminator = "class"; encodeDefaults = true } 14 | var requests = arrayListOf() 15 | var requestedKeys = arrayListOf() 16 | 17 | override fun routes(): EndpointGroup { 18 | return EndpointGroup { 19 | path("api/webhooks/{key}/{token}") { 20 | post(this::postWebhook) 21 | get(this::getWebhook) 22 | } 23 | } 24 | } 25 | 26 | private fun postWebhook(context: Context) { 27 | requests.add(json.decodeFromString(context.body())) 28 | requestedKeys.add(context.pathParam("key")) 29 | context.result("") // Just returns an empty string 30 | } 31 | 32 | private fun getWebhook(context: Context) { 33 | // Just returns a simple response so the component check passes 34 | context.result("{\"application_id\": \"0\"}") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/github/GithubTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.github 2 | 3 | import me.modmuss50.mpp.test.IntegrationTest 4 | import me.modmuss50.mpp.test.MockWebServer 5 | import org.gradle.testkit.runner.TaskOutcome 6 | import kotlin.test.Test 7 | import kotlin.test.assertContains 8 | import kotlin.test.assertEquals 9 | 10 | class GithubTest : IntegrationTest { 11 | @Test 12 | fun uploadGithub() { 13 | val server = MockWebServer(MockGithubApi()) 14 | 15 | val result = gradleTest() 16 | .buildScript( 17 | """ 18 | publishMods { 19 | file = tasks.jar.flatMap { it.archiveFile } 20 | changelog = "Hello!" 21 | version = "1.0.0" 22 | type = STABLE 23 | github { 24 | accessToken = "123" 25 | repository = "test/example" 26 | commitish = "main" 27 | apiEndpoint = "${server.endpoint}" 28 | tagName = "release/1.0.0" 29 | } 30 | } 31 | """.trimIndent(), 32 | ) 33 | .run("publishGithub") 34 | server.close() 35 | 36 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishGithub")!!.outcome) 37 | } 38 | 39 | @Test 40 | fun noMainFile() { 41 | val server = MockWebServer(MockGithubApi()) 42 | 43 | val result = gradleTest() 44 | .buildScript( 45 | """ 46 | publishMods { 47 | changelog = "Hello!" 48 | version = "1.0.0" 49 | type = STABLE 50 | github { 51 | accessToken = "123" 52 | repository = "test/example" 53 | commitish = "main" 54 | apiEndpoint = "${server.endpoint}" 55 | tagName = "release/1.0.0" 56 | additionalFiles.from(tasks.jar.flatMap { it.archiveFile }) 57 | } 58 | } 59 | """.trimIndent(), 60 | ) 61 | .run("publishGithub") 62 | server.close() 63 | 64 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishGithub")!!.outcome) 65 | } 66 | 67 | @Test 68 | fun uploadGithubExistingRelease() { 69 | val server = MockWebServer(MockGithubApi()) 70 | 71 | val result = gradleTest() 72 | .buildScript( 73 | """ 74 | publishMods { 75 | file = tasks.jar.flatMap { it.archiveFile } 76 | changelog = "Hello!" 77 | version = "1.0.0" 78 | type = STABLE 79 | github { 80 | accessToken = "123" 81 | repository = "test/example" 82 | commitish = "main" 83 | apiEndpoint = "${server.endpoint}" 84 | tagName = "release/1.0.0" 85 | } 86 | github("githubOther") { 87 | accessToken = "123" 88 | apiEndpoint = "${server.endpoint}" 89 | parent(tasks.named("publishGithub")) 90 | } 91 | } 92 | """.trimIndent(), 93 | ) 94 | .run("publishGithubOther") 95 | server.close() 96 | 97 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishGithubOther")!!.outcome) 98 | } 99 | 100 | @Test 101 | fun allowEmptyFiles() { 102 | val server = MockWebServer(MockGithubApi()) 103 | 104 | val result = gradleTest() 105 | .buildScript( 106 | """ 107 | publishMods { 108 | changelog = "Hello!" 109 | version = "1.0.0" 110 | type = STABLE 111 | github { 112 | accessToken = "123" 113 | repository = "test/example" 114 | commitish = "main" 115 | apiEndpoint = "${server.endpoint}" 116 | tagName = "release/1.0.0" 117 | allowEmptyFiles = true 118 | } 119 | } 120 | """.trimIndent(), 121 | ) 122 | .subProject( 123 | "child", 124 | """ 125 | publishMods { 126 | github { 127 | accessToken = "123" 128 | apiEndpoint = "${server.endpoint}" 129 | parent(project(":").tasks.named("publishGithub")) 130 | file = tasks.jar.flatMap { it.archiveFile } 131 | } 132 | } 133 | """.trimIndent(), 134 | ) 135 | .run("publishMods") 136 | server.close() 137 | 138 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishGithub")!!.outcome) 139 | assertEquals(TaskOutcome.SUCCESS, result.task(":child:publishGithub")!!.outcome) 140 | } 141 | 142 | @Test 143 | fun allowEmptyFilesDryRun() { 144 | val server = MockWebServer(MockGithubApi()) 145 | 146 | val result = gradleTest() 147 | .buildScript( 148 | """ 149 | publishMods { 150 | changelog = "Hello!" 151 | version = "1.0.0" 152 | type = STABLE 153 | dryRun = true 154 | github { 155 | accessToken = "123" 156 | repository = "test/example" 157 | commitish = "main" 158 | apiEndpoint = "${server.endpoint}" 159 | tagName = "release/1.0.0" 160 | allowEmptyFiles = true 161 | } 162 | } 163 | """.trimIndent(), 164 | ) 165 | .run("publishGithub") 166 | server.close() 167 | 168 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishGithub")!!.outcome) 169 | } 170 | 171 | @Test 172 | fun failOnDuplicateNames() { 173 | val server = MockWebServer(MockGithubApi()) 174 | 175 | val result = gradleTest() 176 | .buildScript( 177 | """ 178 | publishMods { 179 | file = tasks.jar.flatMap { it.archiveFile } 180 | changelog = "Hello!" 181 | version = "1.0.0" 182 | type = STABLE 183 | github { 184 | accessToken = "123" 185 | repository = "test/example" 186 | commitish = "main" 187 | apiEndpoint = "${server.endpoint}" 188 | tagName = "release/1.0.0" 189 | additionalFiles.from(tasks.jar.flatMap { it.archiveFile }) 190 | } 191 | } 192 | """.trimIndent(), 193 | ) 194 | .run("publishGithub") 195 | server.close() 196 | 197 | assertEquals(TaskOutcome.FAILED, result.task(":publishGithub")!!.outcome) 198 | assertContains(result.output, "Github file names must be unique within a release, found duplicates: mpp-example.jar") 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/github/MockGithubApi.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.github 2 | 3 | import io.javalin.apibuilder.ApiBuilder.get 4 | import io.javalin.apibuilder.ApiBuilder.path 5 | import io.javalin.apibuilder.ApiBuilder.post 6 | import io.javalin.apibuilder.EndpointGroup 7 | import io.javalin.http.Context 8 | import me.modmuss50.mpp.test.MockWebServer 9 | 10 | // The very bare minimum to mock out the GitHub API. 11 | class MockGithubApi : MockWebServer.MockApi { 12 | override fun routes(): EndpointGroup { 13 | return EndpointGroup { 14 | path("repos") { 15 | path("{owner}/{name}") { 16 | get(this::getRepo) 17 | path("releases") { 18 | post(this::createRelease) 19 | path("{id}/assets") { 20 | post(this::uploadAsset) 21 | } 22 | get("{id}", this::getRelease) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | 29 | // https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository 30 | private fun getRepo(context: Context) { 31 | context.result( 32 | """ 33 | { 34 | "full_name": "${context.pathParam("owner")}/${context.pathParam("name")}" 35 | } 36 | """.trimIndent(), 37 | ) 38 | } 39 | 40 | // https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release 41 | private fun createRelease(context: Context) { 42 | context.result( 43 | """ 44 | { 45 | "upload_url": "http://localhost:${context.port()}/repos/${context.pathParam("owner")}/${context.pathParam("name")}/releases/1/assets{?name,label}", 46 | "html_url": "https://github.com" 47 | } 48 | """.trimIndent(), 49 | ) 50 | } 51 | 52 | // https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset 53 | private fun uploadAsset(context: Context) { 54 | context.result( 55 | """ 56 | { 57 | } 58 | """.trimIndent(), 59 | ) 60 | } 61 | 62 | // https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-a-release 63 | private fun getRelease(context: Context) { 64 | val id = context.pathParam("id") 65 | context.result( 66 | """ 67 | { 68 | "id": $id, 69 | "upload_url": "http://localhost:${context.port()}/repos/${context.pathParam("owner")}/${context.pathParam("name")}/releases/1/assets{?name,label}", 70 | "html_url": "https://github.com" 71 | } 72 | """.trimIndent(), 73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/misc/MinecraftApiTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.misc 2 | 3 | import me.modmuss50.mpp.MinecraftApi 4 | import me.modmuss50.mpp.test.MockWebServer 5 | import kotlin.test.Test 6 | import kotlin.test.assertContains 7 | import kotlin.test.assertFalse 8 | 9 | class MinecraftApiTest { 10 | @Test 11 | fun getVersions() { 12 | val server = MockWebServer(MockMinecraftApi()) 13 | val api = MinecraftApi(server.endpoint) 14 | 15 | val versions = api.getVersionsInRange("1.19.4", "1.20.1") 16 | assertContains(versions, "1.19.4") 17 | assertContains(versions, "1.20") 18 | assertContains(versions, "1.20.1") 19 | assertFalse(versions.contains("1.20-rc1")) 20 | 21 | server.close() 22 | } 23 | 24 | @Test 25 | fun getVersionsSnapshots() { 26 | val server = MockWebServer(MockMinecraftApi()) 27 | val api = MinecraftApi(server.endpoint) 28 | 29 | val versions = api.getVersionsInRange("1.19.4", "1.20.1", true) 30 | assertContains(versions, "1.19.4") 31 | assertContains(versions, "1.20") 32 | assertContains(versions, "1.20.1") 33 | assertContains(versions, "1.20-rc1") 34 | 35 | server.close() 36 | } 37 | 38 | @Test 39 | fun getVersionsLatest() { 40 | val server = MockWebServer(MockMinecraftApi()) 41 | val api = MinecraftApi(server.endpoint) 42 | 43 | val versions = api.getVersionsInRange("1.19.4", "latest") 44 | assertContains(versions, "1.19.4") 45 | assertContains(versions, "1.20") 46 | assertContains(versions, "1.20.2") 47 | assertFalse(versions.contains("23w44a")) 48 | 49 | server.close() 50 | } 51 | 52 | @Test 53 | fun getVersionsLatestSnapshot() { 54 | val server = MockWebServer(MockMinecraftApi()) 55 | val api = MinecraftApi(server.endpoint) 56 | 57 | val versions = api.getVersionsInRange("1.19.4", "latest", true) 58 | assertContains(versions, "1.19.4") 59 | assertContains(versions, "1.20") 60 | assertContains(versions, "1.20.2") 61 | assertContains(versions, "23w44a") 62 | 63 | server.close() 64 | } 65 | 66 | @Test 67 | fun getSingleVersionFromRange() { 68 | val server = MockWebServer(MockMinecraftApi()) 69 | val api = MinecraftApi(server.endpoint) 70 | 71 | val versions = api.getVersionsInRange("1.19.4", "1.19.4", true) 72 | assert(versions.size == 1) 73 | assertContains(versions, "1.19.4") 74 | 75 | server.close() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/misc/MockMinecraftApi.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.misc 2 | 3 | import io.javalin.apibuilder.ApiBuilder.get 4 | import io.javalin.apibuilder.ApiBuilder.path 5 | import io.javalin.apibuilder.EndpointGroup 6 | import io.javalin.http.Context 7 | import me.modmuss50.mpp.test.MockWebServer 8 | import java.io.BufferedReader 9 | 10 | class MockMinecraftApi : MockWebServer.MockApi { 11 | override fun routes(): EndpointGroup { 12 | return EndpointGroup { 13 | path("mc/game/version_manifest_v2.json") { 14 | get(this::versions) 15 | } 16 | } 17 | } 18 | 19 | private fun versions(context: Context) { 20 | val versions = readResource("version_manifest_v2.json") 21 | context.result(versions) 22 | } 23 | 24 | private fun readResource(path: String): String { 25 | this::class.java.classLoader!!.getResourceAsStream(path).use { inputStream -> 26 | BufferedReader(inputStream!!.reader()).use { reader -> 27 | return reader.readText() 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/misc/MultiPlatformTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.misc 2 | 3 | import me.modmuss50.mpp.test.IntegrationTest 4 | import me.modmuss50.mpp.test.MockWebServer 5 | import me.modmuss50.mpp.test.curseforge.MockCurseforgeApi 6 | import me.modmuss50.mpp.test.modrinth.MockModrinthApi 7 | import org.gradle.testkit.runner.TaskOutcome 8 | import org.junit.jupiter.api.Test 9 | import kotlin.test.assertEquals 10 | 11 | class MultiPlatformTest : IntegrationTest { 12 | @Test 13 | fun publishMultiplatform() { 14 | val server = MockWebServer(MockWebServer.CombinedApi(listOf(MockCurseforgeApi(), MockModrinthApi()))) 15 | 16 | val result = gradleTest() 17 | .buildScript( 18 | """ 19 | publishMods { 20 | changelog = "Changelog goes here" 21 | version = "1.0.0" 22 | type = STABLE 23 | 24 | // CurseForge options used by both Fabric and Forge 25 | val cfOptions = curseforgeOptions { 26 | accessToken = "123" 27 | projectId = "123456" 28 | minecraftVersions.add("1.20.1") 29 | apiEndpoint = "${server.endpoint}" 30 | } 31 | 32 | // Modrinth options used by both Fabric and Forge 33 | val mrOptions = modrinthOptions { 34 | accessToken = "123" 35 | projectId = "12345678" 36 | minecraftVersions.add("1.20.1") 37 | apiEndpoint = "${server.endpoint}" 38 | } 39 | 40 | // Fabric specific options for CurseForge 41 | curseforge("curseforgeFabric") { 42 | from(cfOptions) 43 | file(project(":fabric")) 44 | modLoaders.add("fabric") 45 | requires { 46 | slug = "fabric-api" 47 | } 48 | } 49 | 50 | // Forge specific options for CurseForge 51 | curseforge("curseforgeForge") { 52 | from(cfOptions) 53 | file(project(":forge")) 54 | modLoaders.add("forge") 55 | } 56 | 57 | // Fabric specific options for Modrinth 58 | modrinth("modrinthFabric") { 59 | from(mrOptions) 60 | file(project(":fabric")) 61 | modLoaders.add("fabric") 62 | requires { 63 | slug = "fabric-api" 64 | } 65 | } 66 | 67 | // Forge specific options for Modrinth 68 | modrinth("modrinthForge") { 69 | from(mrOptions) 70 | file(project(":forge")) 71 | modLoaders.add("forge") 72 | } 73 | } 74 | """.trimIndent(), 75 | ) 76 | .subProject("fabric") 77 | .subProject("forge") 78 | .run("publishMods") 79 | server.close() 80 | 81 | assertEquals(TaskOutcome.SUCCESS, result.task(":fabric:jar")!!.outcome) 82 | assertEquals(TaskOutcome.SUCCESS, result.task(":forge:jar")!!.outcome) 83 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/misc/OptionsTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.misc 2 | 3 | import me.modmuss50.mpp.test.IntegrationTest 4 | import me.modmuss50.mpp.test.MockWebServer 5 | import me.modmuss50.mpp.test.curseforge.MockCurseforgeApi 6 | import me.modmuss50.mpp.test.modrinth.MockModrinthApi 7 | import org.gradle.testkit.runner.TaskOutcome 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | 11 | class OptionsTest : IntegrationTest { 12 | @Test 13 | fun uploadWithPublishOptions() { 14 | val server = MockWebServer(MockWebServer.CombinedApi(listOf(MockModrinthApi(), MockCurseforgeApi()))) 15 | 16 | val result = gradleTest() 17 | .buildScript( 18 | """ 19 | publishMods { 20 | file = tasks.jar.flatMap { it.archiveFile } 21 | changelog = "Hello!" 22 | version = "1.0.0" 23 | type = BETA 24 | 25 | val fabricOptions = publishOptions { 26 | displayName = "Test Fabric" 27 | modLoaders.add("fabric") 28 | } 29 | 30 | val forgeOptions = publishOptions { 31 | displayName = "Test Forge" 32 | modLoaders.add("forge") 33 | } 34 | 35 | val curseForgeOptions = curseforgeOptions { 36 | accessToken = "123" 37 | projectId = "123456" 38 | minecraftVersions.add("1.20.1") 39 | apiEndpoint = "${server.endpoint}" 40 | } 41 | 42 | val modrinthOptions = modrinthOptions { 43 | accessToken = "123" 44 | projectId = "12345678" 45 | minecraftVersions.add("1.20.1") 46 | apiEndpoint = "${server.endpoint}" 47 | } 48 | 49 | curseforge("curseforgeFabric") { 50 | from(curseForgeOptions, fabricOptions) 51 | requires { 52 | slug = "fabric-api" 53 | } 54 | } 55 | 56 | curseforge("curseforgeForge") { 57 | from(curseForgeOptions, forgeOptions) 58 | } 59 | 60 | modrinth("modrinthFabric") { 61 | from(modrinthOptions, fabricOptions) 62 | requires { 63 | slug = "fabric-api" 64 | } 65 | } 66 | 67 | modrinth("modrinthForge") { 68 | from(modrinthOptions, forgeOptions) 69 | } 70 | } 71 | """.trimIndent(), 72 | ) 73 | .run("publishMods") 74 | server.close() 75 | 76 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishMods")!!.outcome) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/misc/PublishResultTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.misc 2 | 3 | import me.modmuss50.mpp.CurseForgePublishResult 4 | import me.modmuss50.mpp.GithubPublishResult 5 | import me.modmuss50.mpp.ModrinthPublishResult 6 | import me.modmuss50.mpp.PublishResult 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | 10 | class PublishResultTest { 11 | @Test 12 | fun decodeGithubJson() { 13 | val result = PublishResult.fromJson( 14 | """ 15 | { 16 | "type": "github", 17 | "repository": "test/test", 18 | "releaseId": 123, 19 | "url": "https://github.com", 20 | "title": "test" 21 | } 22 | """.trimIndent(), 23 | ) 24 | 25 | val github = result as GithubPublishResult 26 | assertEquals("test/test", github.repository) 27 | assertEquals(123, github.releaseId) 28 | assertEquals("https://github.com", github.url) 29 | assertEquals("test", github.title) 30 | } 31 | 32 | @Test 33 | fun decodeCurseforgeJson() { 34 | val result = PublishResult.fromJson( 35 | """ 36 | { 37 | "type": "curseforge", 38 | "projectId": "abc", 39 | "fileId": 123, 40 | "projectSlug": "example", 41 | "title": "test" 42 | } 43 | """.trimIndent(), 44 | ) 45 | 46 | val curseforge = result as CurseForgePublishResult 47 | assertEquals("abc", curseforge.projectId) 48 | assertEquals(123, curseforge.fileId) 49 | assertEquals("test", curseforge.title) 50 | } 51 | 52 | @Test 53 | fun decodeModrinthJson() { 54 | val result = PublishResult.fromJson( 55 | """ 56 | { 57 | "type": "modrinth", 58 | "id": "test", 59 | "projectId": "123", 60 | "title": "test" 61 | } 62 | """.trimIndent(), 63 | ) 64 | 65 | val modrinth = result as ModrinthPublishResult 66 | assertEquals("test", modrinth.id) 67 | assertEquals("123", modrinth.projectId) 68 | assertEquals("test", modrinth.title) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/modrinth/MockModrinthApi.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.modrinth 2 | 3 | import io.javalin.apibuilder.ApiBuilder.before 4 | import io.javalin.apibuilder.ApiBuilder.get 5 | import io.javalin.apibuilder.ApiBuilder.patch 6 | import io.javalin.apibuilder.ApiBuilder.path 7 | import io.javalin.apibuilder.ApiBuilder.post 8 | import io.javalin.apibuilder.EndpointGroup 9 | import io.javalin.http.BadRequestResponse 10 | import io.javalin.http.Context 11 | import io.javalin.http.UnauthorizedResponse 12 | import kotlinx.serialization.encodeToString 13 | import kotlinx.serialization.json.Json 14 | import me.modmuss50.mpp.platforms.modrinth.ModrinthApi 15 | import me.modmuss50.mpp.test.MockWebServer 16 | 17 | class MockModrinthApi : MockWebServer.MockApi { 18 | val json = Json { ignoreUnknownKeys = true } 19 | var lastCreateVersion: ModrinthApi.CreateVersion? = null 20 | var projectBody: String? = null 21 | 22 | override fun routes(): EndpointGroup { 23 | return EndpointGroup { 24 | path("/project/{slug}/version") { 25 | get(this::listVersions) 26 | } 27 | path("/version") { 28 | before(this::authHandler) 29 | post(this::createVersion) 30 | } 31 | path("project/{slug}") { 32 | patch(this::modifyProject) 33 | } 34 | path("/project/{slug}/check") { 35 | get(this::checkProject) 36 | } 37 | } 38 | } 39 | 40 | private fun listVersions(context: Context) { 41 | context.result( 42 | json.encodeToString( 43 | arrayOf( 44 | ModrinthApi.ListVersionsResponse( 45 | "0.92.2+1.20.1", 46 | "P7uGFii0", 47 | ), 48 | ModrinthApi.ListVersionsResponse( 49 | "0.92.1+1.20.1", 50 | "ba99D9Qf", 51 | ), 52 | ), 53 | ), 54 | ) 55 | } 56 | 57 | private fun authHandler(context: Context) { 58 | val apiToken = context.header("Authorization") 59 | 60 | if (apiToken != "123") { 61 | throw UnauthorizedResponse("Invalid access token") 62 | } 63 | } 64 | 65 | private fun createVersion(context: Context) { 66 | val data = context.formParam("data") 67 | ?: throw BadRequestResponse("No metadata") 68 | 69 | val createVersion = json.decodeFromString(data) 70 | lastCreateVersion = createVersion 71 | 72 | for (filePart in createVersion.fileParts) { 73 | context.uploadedFile(filePart) 74 | ?: throw BadRequestResponse("No file") 75 | } 76 | 77 | context.result( 78 | json.encodeToString( 79 | ModrinthApi.CreateVersionResponse( 80 | id = "hFdJG9fY", 81 | projectId = createVersion.projectId, 82 | authorId = "JZA4dW8o", 83 | ), 84 | ), 85 | ) 86 | } 87 | 88 | private fun modifyProject(context: Context) { 89 | val modifyProject = json.decodeFromString(context.body()) 90 | projectBody = modifyProject.body 91 | context.result() 92 | } 93 | 94 | private fun checkProject(context: Context) { 95 | context.result( 96 | """ 97 | { 98 | "id": "AABBCCDD" 99 | } 100 | """.trimIndent(), 101 | ) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/test/kotlin/me/modmuss50/mpp/test/modrinth/ModrinthTest.kt: -------------------------------------------------------------------------------- 1 | package me.modmuss50.mpp.test.modrinth 2 | 3 | import me.modmuss50.mpp.test.IntegrationTest 4 | import me.modmuss50.mpp.test.MockWebServer 5 | import org.gradle.testkit.runner.TaskOutcome 6 | import kotlin.test.Test 7 | import kotlin.test.assertContains 8 | import kotlin.test.assertEquals 9 | import kotlin.test.assertFalse 10 | 11 | class ModrinthTest : IntegrationTest { 12 | @Test 13 | fun uploadModrinth() { 14 | val server = MockWebServer(MockModrinthApi()) 15 | 16 | val result = gradleTest() 17 | .buildScript( 18 | """ 19 | publishMods { 20 | file = tasks.jar.flatMap { it.archiveFile } 21 | changelog = "Hello!" 22 | version = "1.0.0" 23 | type = STABLE 24 | modLoaders.add("fabric") 25 | 26 | modrinth { 27 | accessToken = "123" 28 | projectId = "12345678" 29 | minecraftVersions.add("1.20.1") 30 | 31 | requires { 32 | id = "P7dR8mSH" 33 | } 34 | 35 | apiEndpoint = "${server.endpoint}" 36 | } 37 | } 38 | """.trimIndent(), 39 | ) 40 | .run("publishModrinth") 41 | server.close() 42 | 43 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 44 | } 45 | 46 | @Test 47 | fun uploadModrinthWithOptions() { 48 | val server = MockWebServer(MockModrinthApi()) 49 | 50 | val result = gradleTest() 51 | .buildScript( 52 | """ 53 | version = "1.0.0" 54 | publishMods { 55 | changelog = "Hello!" 56 | type = BETA 57 | 58 | // Common options that can be re-used between diffrent modrinth tasks 59 | val modrinthOptions = modrinthOptions { 60 | accessToken = "123" 61 | minecraftVersions.add("1.20.1") 62 | apiEndpoint = "${server.endpoint}" 63 | } 64 | 65 | modrinth("modrinthFabric") { 66 | from(modrinthOptions) 67 | file = tasks.jar.flatMap { it.archiveFile } 68 | projectId = "12345678" 69 | modLoaders.add("fabric") 70 | requires { 71 | id = "P7dR8mSH" // fabric-api 72 | } 73 | } 74 | 75 | modrinth("modrinthForge") { 76 | from(modrinthOptions) 77 | file = tasks.jar.flatMap { it.archiveFile } 78 | projectId = "67896545" 79 | modLoaders.add("forge") 80 | } 81 | } 82 | """.trimIndent(), 83 | ) 84 | .run("publishMods") 85 | server.close() 86 | 87 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinthFabric")!!.outcome) 88 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinthForge")!!.outcome) 89 | } 90 | 91 | @Test 92 | fun dryRunModrinth() { 93 | val result = gradleTest() 94 | .buildScript( 95 | """ 96 | publishMods { 97 | file = tasks.jar.flatMap { it.archiveFile } 98 | changelog = "Hello!" 99 | version = "1.0.0" 100 | type = STABLE 101 | modLoaders.add("fabric") 102 | dryRun = true 103 | 104 | modrinth { 105 | accessToken = providers.environmentVariable("TEST_TOKEN_THAT_DOES_NOT_EXISTS") 106 | projectId = "12345678" 107 | minecraftVersions.add("1.20.1") 108 | requires { 109 | id = "P7dR8mSH" // fabric-api 110 | } 111 | } 112 | } 113 | """.trimIndent(), 114 | ) 115 | .run("publishModrinth") 116 | 117 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 118 | } 119 | 120 | @Test 121 | fun uploadModrinthNoDeps() { 122 | val server = MockWebServer(MockModrinthApi()) 123 | 124 | val result = gradleTest() 125 | .buildScript( 126 | """ 127 | publishMods { 128 | file = tasks.jar.flatMap { it.archiveFile } 129 | changelog = "Hello!" 130 | version = "1.0.0" 131 | type = STABLE 132 | modLoaders.add("fabric") 133 | 134 | modrinth { 135 | accessToken = "123" 136 | projectId = "12345678" 137 | minecraftVersions.add("1.20.1") 138 | 139 | apiEndpoint = "${server.endpoint}" 140 | } 141 | } 142 | """.trimIndent(), 143 | ) 144 | .run("publishModrinth") 145 | server.close() 146 | 147 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 148 | } 149 | 150 | @Test 151 | fun invalidId() { 152 | val server = MockWebServer(MockModrinthApi()) 153 | 154 | val result = gradleTest() 155 | .buildScript( 156 | """ 157 | publishMods { 158 | file = tasks.jar.flatMap { it.archiveFile } 159 | changelog = "Hello!" 160 | version = "1.0.0" 161 | type = STABLE 162 | modLoaders.add("fabric") 163 | 164 | modrinth { 165 | accessToken = "123" 166 | projectId = "invalid-id" 167 | minecraftVersions.add("1.20.1") 168 | 169 | apiEndpoint = "${server.endpoint}" 170 | } 171 | } 172 | """.trimIndent(), 173 | ) 174 | .run("publishModrinth") 175 | server.close() 176 | 177 | assertEquals(TaskOutcome.FAILED, result.task(":publishModrinth")!!.outcome) 178 | result.output.contains("invalid-id is not a valid Modrinth ID") 179 | } 180 | 181 | @Test 182 | fun uploadModrinthSlugLookup() { 183 | val server = MockWebServer(MockModrinthApi()) 184 | 185 | val result = gradleTest() 186 | .buildScript( 187 | """ 188 | publishMods { 189 | file = tasks.jar.flatMap { it.archiveFile } 190 | changelog = "Hello!" 191 | version = "1.0.0" 192 | type = STABLE 193 | modLoaders.add("fabric") 194 | 195 | modrinth { 196 | accessToken = "123" 197 | projectId = "12345678" 198 | minecraftVersions.add("1.20.1") 199 | 200 | requires { 201 | slug = "fabric-api" 202 | } 203 | 204 | apiEndpoint = "${server.endpoint}" 205 | } 206 | } 207 | """.trimIndent(), 208 | ) 209 | .run("publishModrinth") 210 | server.close() 211 | 212 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 213 | } 214 | 215 | @Test 216 | fun uploadModrinthMinecraftVersionRange() { 217 | val mockModrinthApi = MockModrinthApi() 218 | val server = MockWebServer(mockModrinthApi) 219 | 220 | val result = gradleTest() 221 | .buildScript( 222 | """ 223 | publishMods { 224 | file = tasks.jar.flatMap { it.archiveFile } 225 | changelog = "Hello!" 226 | version = "1.0.0" 227 | type = STABLE 228 | modLoaders.add("fabric") 229 | 230 | modrinth { 231 | accessToken = "123" 232 | projectId = "12345678" 233 | 234 | minecraftVersionRange { 235 | start = "1.13.1" // test WALL_OF_SHAME 236 | end = "1.20.2" 237 | includeSnapshots = true 238 | } 239 | 240 | apiEndpoint = "${server.endpoint}" 241 | } 242 | } 243 | """.trimIndent(), 244 | ) 245 | .run("publishModrinth") 246 | server.close() 247 | 248 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 249 | val gameVersions = mockModrinthApi.lastCreateVersion!!.gameVersions 250 | assertContains(gameVersions, "1.13.1") 251 | assertContains(gameVersions, "1.14.2-pre4") 252 | assertContains(gameVersions, "1.20-pre1") 253 | assertContains(gameVersions, "1.20.1") 254 | assertContains(gameVersions, "1.20.2") 255 | } 256 | 257 | @Test 258 | fun uploadModrinthMinecraftVersionRangeNoSnapshots() { 259 | val mockModrinthApi = MockModrinthApi() 260 | val server = MockWebServer(mockModrinthApi) 261 | 262 | val result = gradleTest() 263 | .buildScript( 264 | """ 265 | publishMods { 266 | file = tasks.jar.flatMap { it.archiveFile } 267 | changelog = "Hello!" 268 | version = "1.0.0" 269 | type = STABLE 270 | modLoaders.add("fabric") 271 | 272 | modrinth { 273 | accessToken = "123" 274 | projectId = "12345678" 275 | 276 | minecraftVersionRange { 277 | start = "1.19.4" 278 | end = "1.20.2" 279 | } 280 | 281 | apiEndpoint = "${server.endpoint}" 282 | } 283 | } 284 | """.trimIndent(), 285 | ) 286 | .run("publishModrinth") 287 | server.close() 288 | 289 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 290 | val gameVersions = mockModrinthApi.lastCreateVersion!!.gameVersions 291 | assertContains(gameVersions, "1.19.4") 292 | assertFalse(gameVersions.contains("1.20-pre1")) 293 | assertContains(gameVersions, "1.20.1") 294 | assertContains(gameVersions, "1.20.2") 295 | } 296 | 297 | @Test 298 | fun updateProjectDescription() { 299 | val api = MockModrinthApi() 300 | val server = MockWebServer(api) 301 | 302 | val result = gradleTest() 303 | .buildScript( 304 | """ 305 | publishMods { 306 | file = tasks.jar.flatMap { it.archiveFile } 307 | changelog = "Hello!" 308 | version = "1.0.0" 309 | type = STABLE 310 | modLoaders.add("fabric") 311 | 312 | modrinth { 313 | accessToken = "123" 314 | projectId = "12345678" 315 | minecraftVersions.add("1.20.1") 316 | projectDescription = providers.fileContents(layout.projectDirectory.file("readme.md")).asText 317 | 318 | apiEndpoint = "${server.endpoint}" 319 | } 320 | } 321 | """.trimIndent(), 322 | ) 323 | .file("readme.md", "Hello World") 324 | .run("publishModrinth") 325 | server.close() 326 | 327 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 328 | assertEquals("Hello World", api.projectBody) 329 | } 330 | 331 | @Test 332 | fun uploadModrinthSlugDependency() { 333 | val mockModrinthApi = MockModrinthApi() 334 | val server = MockWebServer(mockModrinthApi) 335 | 336 | val result = gradleTest() 337 | .buildScript( 338 | """ 339 | publishMods { 340 | file = tasks.jar.flatMap { it.archiveFile } 341 | changelog = "Hello!" 342 | version = "1.0.0" 343 | type = STABLE 344 | 345 | modrinth { 346 | accessToken = "123" 347 | minecraftVersions.add("1.20.1") 348 | apiEndpoint = "${server.endpoint}" 349 | projectId = "67896545" 350 | modLoaders.add("fabric") 351 | requires { 352 | id = "P7dR8mSH" 353 | version = "P7uGFii0" 354 | } 355 | requires { 356 | slug = "fabric-api" 357 | version = "0.92.1+1.20.1" 358 | } 359 | } 360 | } 361 | """.trimIndent(), 362 | ) 363 | .run("publishMods") 364 | server.close() 365 | 366 | assertEquals(TaskOutcome.SUCCESS, result.task(":publishModrinth")!!.outcome) 367 | 368 | var dependencies = mockModrinthApi.lastCreateVersion!!.dependencies.map { it.versionId } 369 | assertEquals(2, dependencies.size) 370 | assertContains(dependencies, "P7uGFii0") 371 | assertContains(dependencies, "ba99D9Qf") 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/test/resources/curseforge_version_types.json: -------------------------------------------------------------------------------- 1 | [{"id":2,"name":"Java","slug":"java"},{"id":4,"name":"Minecraft 1.8","slug":"minecraft-1-8"},{"id":5,"name":"Minecraft 1.7","slug":"minecraft-1-7"},{"id":6,"name":"Minecraft 1.6","slug":"minecraft-1-6"},{"id":11,"name":"Minecraft 1.5","slug":"minecraft-1-5"},{"id":12,"name":"Minecraft 1.4","slug":"minecraft-1-4"},{"id":13,"name":"Minecraft 1.3","slug":"minecraft-1-3"},{"id":14,"name":"Minecraft 1.2","slug":"minecraft-1-2"},{"id":15,"name":"Minecraft 1.1","slug":"minecraft-1-1"},{"id":16,"name":"Minecraft 1.0","slug":"minecraft-1-0"},{"id":17,"name":"Minecraft Beta","slug":"minecraft-beta"},{"id":552,"name":"Minecraft 1.9","slug":"minecraft-1-9"},{"id":572,"name":"Minecraft 1.10","slug":"minecraft-1-10"},{"id":599,"name":"Minecraft 1.11","slug":"minecraft-1-11"},{"id":615,"name":"Addons","slug":"addons"},{"id":628,"name":"Minecraft 1.12","slug":"minecraft-1-12"},{"id":55023,"name":"Minecraft 1.13","slug":"minecraft-1-13"},{"id":64806,"name":"Minecraft 1.14","slug":"minecraft-1-14"},{"id":68441,"name":"Modloader","slug":"modloader"},{"id":68722,"name":"Minecraft 1.15","slug":"minecraft-1-15"},{"id":70886,"name":"Minecraft 1.16","slug":"minecraft-1-16"},{"id":73242,"name":"Minecraft 1.17","slug":"minecraft-1-17"},{"id":73250,"name":"Minecraft 1.18","slug":"minecraft-1-18"},{"id":73407,"name":"Minecraft 1.19","slug":"minecraft-1-19"},{"id":75125,"name":"Minecraft 1.20","slug":"minecraft-1-20"},{"id":75208,"name":"Environment","slug":"environment"}] -------------------------------------------------------------------------------- /src/test/resources/version_manifest_v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "latest": { 3 | "release": "1.20.2", 4 | "snapshot": "23w44a" 5 | }, 6 | "versions": [ 7 | { 8 | "id": "23w44a", 9 | "type": "snapshot", 10 | "url": "https://piston-meta.mojang.com/v1/packages/c18d3537d1c262a5527126b317dcf6056f7d8e4d/23w44a.json", 11 | "time": "2023-11-01T12:39:19+00:00", 12 | "releaseTime": "2023-11-01T12:30:52+00:00", 13 | "sha1": "c18d3537d1c262a5527126b317dcf6056f7d8e4d", 14 | "complianceLevel": 1 15 | }, 16 | { 17 | "id": "1.20.2", 18 | "type": "release", 19 | "url": "https://piston-meta.mojang.com/v1/packages/9e1a5201ca011f539b8c713d01e02805fbdb6b59/1.20.2.json", 20 | "time": "2023-11-01T11:19:11+00:00", 21 | "releaseTime": "2023-09-20T09:02:57+00:00", 22 | "sha1": "9e1a5201ca011f539b8c713d01e02805fbdb6b59", 23 | "complianceLevel": 1 24 | }, 25 | { 26 | "id": "1.20.2-rc2", 27 | "type": "snapshot", 28 | "url": "https://piston-meta.mojang.com/v1/packages/3f2a700470a0b3af144f2a0934e8b1f118d596da/1.20.2-rc2.json", 29 | "time": "2023-11-01T11:19:11+00:00", 30 | "releaseTime": "2023-09-18T12:34:57+00:00", 31 | "sha1": "3f2a700470a0b3af144f2a0934e8b1f118d596da", 32 | "complianceLevel": 1 33 | }, 34 | { 35 | "id": "1.20.1", 36 | "type": "release", 37 | "url": "https://piston-meta.mojang.com/v1/packages/e5e16c872ce05032dcf702a6a95d9f70f4876b13/1.20.1.json", 38 | "time": "2023-11-01T11:17:42+00:00", 39 | "releaseTime": "2023-06-12T13:25:51+00:00", 40 | "sha1": "e5e16c872ce05032dcf702a6a95d9f70f4876b13", 41 | "complianceLevel": 1 42 | }, 43 | { 44 | "id": "1.20", 45 | "type": "release", 46 | "url": "https://piston-meta.mojang.com/v1/packages/b66b89b8595e12d29bee6390b3098e630f527c69/1.20.json", 47 | "time": "2023-11-01T11:17:42+00:00", 48 | "releaseTime": "2023-06-02T08:36:17+00:00", 49 | "sha1": "b66b89b8595e12d29bee6390b3098e630f527c69", 50 | "complianceLevel": 1 51 | }, 52 | { 53 | "id": "1.20-rc1", 54 | "type": "snapshot", 55 | "url": "https://piston-meta.mojang.com/v1/packages/7f0e821864d81d0a5a29dc379cf372212c55e8e2/1.20-rc1.json", 56 | "time": "2023-11-01T11:17:42+00:00", 57 | "releaseTime": "2023-05-31T12:33:33+00:00", 58 | "sha1": "7f0e821864d81d0a5a29dc379cf372212c55e8e2", 59 | "complianceLevel": 1 60 | }, 61 | { 62 | "id": "1.19.4", 63 | "type": "release", 64 | "url": "https://piston-meta.mojang.com/v1/packages/7177b926557a2cbbbdeaaad46a86ff3deadc26b4/1.19.4.json", 65 | "time": "2023-11-01T11:16:42+00:00", 66 | "releaseTime": "2023-03-14T12:56:18+00:00", 67 | "sha1": "7177b926557a2cbbbdeaaad46a86ff3deadc26b4", 68 | "complianceLevel": 1 69 | } 70 | ] 71 | } --------------------------------------------------------------------------------