diff --git a/README.md b/README.md
index 9e71439..65876ce 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ jobs:
- uses: Kir-Antipov/mc-publish@v3.0
with:
modrinth-id: AANobbMI
+ modrinth-featured: true
+ modrinth-unfeature-mode: subset
modrinth-token: ${{ secrets.MODRINTH_TOKEN }}
curseforge-id: 394468
@@ -91,6 +93,7 @@ jobs:
| [modrinth-id](#user-content-modrinth-id) | The ID of the Modrinth project to upload to | A value specified in the config file | `AANobbMI` |
| [modrinth-token](#user-content-modrinth-token) | A valid token for the Modrinth API | ❌ | `${{ secrets.MODRINTH_TOKEN }}` |
| [modrinth-featured](#user-content-modrinth-featured) | Indicates whether the version should be featured on Modrinth or not | `true` | `true`
`false` |
+| [modrinth-unfeature-mode](#user-content-modrinth-unfeature-mode) | Determines the way automatic unfeaturing of older Modrinth versions works | If [`modrinth-featured`](#user-content-modrinth-featured) is set to true, `subset`; otherwise, `none` | `none`
`subset`
`intersection`
`any` |
| [curseforge-id](#user-content-curseforge-id) | The ID of the CurseForge project to upload to | A value specified in the config file | `394468` |
| [curseforge-token](#user-content-curseforge-token) | A valid token for the CurseForge API | ❌ | `${{ secrets.CURSEFORGE_TOKEN }}` |
| [github-tag](#user-content-github-tag) | The tag name of the release to upload assets to | A tag of the release that triggered the action | `mc1.17.1-0.3.2` |
@@ -227,6 +230,30 @@ Indicates whether the version should be featured on Modrinth or not.
modrinth-featured: true
```
+#### modrinth-unfeature-mode
+
+Determines the way automatic unfeaturing of older Modrinth versions works. Default value is `subset`, if [`modrinth-featured`](#user-content-modrinth-featured) is set to true; otherwise, `none`.
+
+```yaml
+modrinth-unfeature-mode: version-intersection | loader-subset
+```
+
+Available presets:
+
+ - `none` - no Modrinth versions will be unfeatured
+ - `subset` - only those Modrinth versions which are considered a subset of the new one *(i.e., new release suports all of the version's mod loaders **and** game versions)* will be unfeatured
+ - `intersection` - only those Modrinth versions which intersects with the new one *(i.e., support at least one of the mod loaders and one of the game versions supported by the new release)* will be unfeatured
+ - `any` - all Modrinth versions will be unfeatured
+
+ If none of the given presets suits your needs, you can construct a new one from the following values via bitwise `OR`, like so - `version-intersection | loaders-subset`:
+
+ - `version-subset`
+ - `version-intersection`
+ - `version-any`
+ - `loader-subset`
+ - `loader-intersection`
+ - `loader-any`
+
#### curseforge-id
The ID of the CurseForge project to upload to.
diff --git a/action.yml b/action.yml
index 11ce982..f4ced27 100644
--- a/action.yml
+++ b/action.yml
@@ -17,6 +17,10 @@ inputs:
description: Indicates whether the version should be featured on Modrinth or not
required: false
default: ${undefined}
+ modrinth-unfeature-mode: Determines the way automatic unfeaturing of older Modrinth versions works
+ description:
+ required: false
+ default: ${undefined}
curseforge-id:
description: The ID of the CurseForge project to upload to
diff --git a/src/publishing/modrinth/modrinth-publisher.ts b/src/publishing/modrinth/modrinth-publisher.ts
index 75ab175..946b57f 100644
--- a/src/publishing/modrinth/modrinth-publisher.ts
+++ b/src/publishing/modrinth/modrinth-publisher.ts
@@ -1,10 +1,30 @@
-import { createVersion, getProject } from "../../utils/modrinth-utils";
+import { createVersion, getProject, getVersions, modifyVersion } from "../../utils/modrinth-utils";
import { File } from "../../utils/file";
import ModPublisher from "../mod-publisher";
import PublisherTarget from "../publisher-target";
import Dependency from "../../metadata/dependency";
import DependencyKind from "../../metadata/dependency-kind";
-import { mapBooleanInput } from "../../utils/input-utils";
+import { mapBooleanInput, mapEnumInput } from "../../utils/input-utils";
+
+enum UnfeatureMode {
+ None = 0,
+
+ VersionSubset = 1,
+ VersionIntersection = 2,
+ VersionAny = 4,
+
+ LoaderSubset = 8,
+ LoaderIntersection = 16,
+ LoaderAny = 32,
+
+ Subset = VersionSubset | LoaderSubset,
+ Intersection = VersionIntersection | LoaderIntersection,
+ Any = VersionAny | LoaderAny,
+}
+
+function hasFlag(unfeatureMode: UnfeatureMode, flag: UnfeatureMode): boolean {
+ return (unfeatureMode & flag) === flag;
+}
const modrinthDependencyKinds = new Map([
[DependencyKind.Depends, "required"],
@@ -21,6 +41,7 @@ export default class ModrinthPublisher extends ModPublisher {
protected async publishMod(id: string, token: string, name: string, version: string, channel: string, loaders: string[], gameVersions: string[], _java: string[], changelog: string, files: File[], dependencies: Dependency[], options: Record): Promise {
const featured = mapBooleanInput(options.featured, true);
+ const unfeatureMode = mapEnumInput(options.unfeatureMode, UnfeatureMode, featured ? UnfeatureMode.Subset : UnfeatureMode.None);
const projects = (await Promise.all(dependencies
.filter((x, _, self) => (x.kind !== DependencyKind.Suggests && x.kind !== DependencyKind.Includes) || !self.find(y => y.id === x.id && y.kind !== DependencyKind.Suggests && y.kind !== DependencyKind.Includes))
.map(async x => ({
@@ -29,6 +50,10 @@ export default class ModrinthPublisher extends ModPublisher {
}))))
.filter(x => x.project_id && x.dependency_type);
+ if (unfeatureMode !== UnfeatureMode.None) {
+ await this.unfeatureOlderVersions(id, token, unfeatureMode, loaders, gameVersions);
+ }
+
const data = {
name: name || version,
version_number: version,
@@ -41,4 +66,36 @@ export default class ModrinthPublisher extends ModPublisher {
};
await createVersion(id, data, files, token);
}
+
+ private async unfeatureOlderVersions(id: string, token: string, unfeatureMode: UnfeatureMode, loaders: string[], gameVersions: string[]): Promise {
+ this.logger.info("Unfeaturing older Modrinth versions...");
+ const start = new Date();
+ const unfeaturedVersions = [];
+
+ const versionSubset = hasFlag(unfeatureMode, UnfeatureMode.VersionSubset);
+ const loaderSubset = hasFlag(unfeatureMode, UnfeatureMode.LoaderSubset);
+ const olderVersions = await getVersions(id, hasFlag(unfeatureMode, UnfeatureMode.LoaderAny) ? null : loaders, hasFlag(unfeatureMode, UnfeatureMode.VersionAny) ? null : gameVersions, true, token);
+ for (const olderVersion of olderVersions) {
+ if (loaderSubset && !olderVersion.loaders.every(x => loaders.includes(x))) {
+ continue;
+ }
+
+ if (versionSubset && !olderVersion.game_versions.every(x => gameVersions.includes(x))) {
+ continue;
+ }
+
+ if (await modifyVersion(olderVersion.id, { featured: false }, token)) {
+ unfeaturedVersions.push(olderVersion.id);
+ } else {
+ this.logger.warn(`Cannot unfeature version ${olderVersion.id}`);
+ }
+ }
+
+ if (unfeaturedVersions.length) {
+ const end = new Date();
+ this.logger.info(`Successfully unfeatured versions ${unfeaturedVersions.join(", ")} (in ${end.getTime() - start.getTime()} ms)`);
+ } else {
+ this.logger.info("No versions to unfeature were found");
+ }
+ }
}
\ No newline at end of file
diff --git a/src/utils/modrinth-utils.ts b/src/utils/modrinth-utils.ts
index c1930d6..48ddb05 100644
--- a/src/utils/modrinth-utils.ts
+++ b/src/utils/modrinth-utils.ts
@@ -1,5 +1,6 @@
import FormData from "form-data";
import fetch, { Response } from "node-fetch";
+import { URLSearchParams } from "url";
import { File } from "./file";
import SoftError from "./soft-error";
@@ -12,6 +13,9 @@ interface ModrinthProject {
interface ModrinthVersion {
id: string;
+ loaders: string[];
+ game_versions: string[];
+ featured: boolean;
}
export function createVersion(modId: string, data: Record, files: File[], token: string): Promise {
@@ -44,7 +48,37 @@ export function createVersion(modId: string, data: Record, files: F
export function getProject(idOrSlug: string): Promise {
return processResponse(fetch(`${baseUrl}/project/${idOrSlug}`), { 404: () => null });
- return await response.json();
+}
+
+export function getVersions(idOrSlug: string, loaders?: string[], gameVersions?: string[], featured?: boolean, token?: string): Promise {
+ const urlParams = new URLSearchParams();
+ if (loaders) {
+ urlParams.append("loaders", JSON.stringify(loaders));
+ }
+ if (gameVersions) {
+ urlParams.append("game_versions", JSON.stringify(gameVersions));
+ }
+ if (typeof featured === "boolean") {
+ urlParams.append("featured", String(featured));
+ }
+
+ const response = fetch(`${baseUrl}/project/${idOrSlug}/version?${urlParams}`, token ? {
+ headers: { "Authorization": token }
+ } : undefined);
+ return processResponse(response, { 404: () => [] });
+}
+
+export async function modifyVersion(id: string, version: Partial, token: string): Promise {
+ const response = await fetch(`${baseUrl}/version/${id}`, {
+ method: "PATCH",
+ headers: {
+ "Authorization": token,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(version)
+ });
+
+ return response.ok;
}
async function processResponse(response: Response | Promise, mappers?: Record T | Promise>, errorFactory?: (isServerError: boolean, message: string, response: Response) => Error | Promise): Promise {
diff --git a/test/modrinth-utils.test.ts b/test/modrinth-utils.test.ts
index bc8e896..70c6f9f 100644
--- a/test/modrinth-utils.test.ts
+++ b/test/modrinth-utils.test.ts
@@ -1,5 +1,7 @@
import { describe, test, expect } from "@jest/globals";
-import { getProject } from "../src/utils/modrinth-utils";
+import { getProject, getVersions } from "../src/utils/modrinth-utils";
+
+const timeout = 15000;
describe("getProject", () => {
test("returned versions have expected ids", async () => {
@@ -15,7 +17,7 @@ describe("getProject", () => {
const project = await getProject(slug);
expect(project).toHaveProperty("id", id);
}
- }, 15000);
+ }, timeout);
test("the method returns null if project with the given slug does not exist", async () => {
const nonExistentProjects = [
@@ -31,5 +33,43 @@ describe("getProject", () => {
const project = await getProject(slug);
expect(project).toBeNull();
}
- }, 15000);
+ }, timeout);
+});
+
+describe("getVersions", () => {
+ test("returns unfiltered versions if no parameters were passed", async () => {
+ const versions = await getVersions("terra");
+ expect(versions.find(x => x.featured)).toBeTruthy();
+ expect(versions.find(x => !x.featured)).toBeTruthy();
+ expect(versions.find(x => x.loaders.includes("fabric"))).toBeTruthy();
+ expect(versions.find(x => x.loaders.includes("forge"))).toBeTruthy();
+ expect(versions.find(x => x.game_versions.includes("1.18.2"))).toBeTruthy();
+ expect(versions.find(x => x.game_versions.includes("1.16.5"))).toBeTruthy();
+ }, timeout);
+
+ test("returns only featured versions with featured === true", async () => {
+ const versions = await getVersions("terra", null, null, true);
+ expect(versions.every(x => x.featured)).toBe(true);
+ }, timeout);
+
+ test("returns only unfeatured versions with featured === false", async () => {
+ const versions = await getVersions("terra", null, null, false);
+ expect(versions.every(x => !x.featured)).toBe(true);
+ }, timeout);
+
+ test("returns only versions that support given modloaders", async () => {
+ const fabricVersions = await getVersions("terra", ["fabric"]);
+ expect(fabricVersions.every(x => x.loaders.includes("fabric"))).toBe(true);
+
+ const forgeVersions = await getVersions("terra", ["forge"]);
+ expect(forgeVersions.every(x => x.loaders.includes("forge"))).toBe(true);
+ }, timeout);
+
+ test("returns only versions that support given mc versions", async () => {
+ const versions_1_18_2 = await getVersions("terra", null, ["1.18.2"]);
+ expect(versions_1_18_2.every(x => x.game_versions.includes("1.18.2"))).toBe(true);
+
+ const versions_1_16_5 = await getVersions("terra", null, ["1.16.5"]);
+ expect(versions_1_16_5.every(x => x.game_versions.includes("1.16.5"))).toBe(true);
+ }, timeout);
});