diff --git a/src/platforms/generic-platform-uploader.ts b/src/platforms/generic-platform-uploader.ts
new file mode 100644
index 0000000..e5ee383
--- /dev/null
+++ b/src/platforms/generic-platform-uploader.ts
@@ -0,0 +1,197 @@
+import { Dependency, DependencyType } from "@/dependencies";
+import { retry } from "@/utils/async-utils";
+import { ArgumentNullError, isSoftError } from "@/utils/errors";
+import { FileInfo } from "@/utils/io";
+import { JavaVersion } from "@/utils/java";
+import { Logger, LoggingStopwatch, NULL_LOGGER } from "@/utils/logging";
+import { SecureString } from "@/utils/security";
+import { VersionType } from "@/utils/versioning";
+import { CurseForgeUploaderOptions } from "./curseforge/curseforge-uploader";
+import { GitHubUploaderOptions } from "./github/github-uploader";
+import { ModrinthUploaderOptions } from "./modrinth/modrinth-uploader";
+import { PlatformType } from "./platform-type";
+import { PlatformUploader } from "./platform-uploader";
+
+/**
+ * Options for configuring a generic platform uploader.
+ */
+export interface GenericPlatformUploaderOptions {
+    /**
+     * An optional logger that can be used for recording log messages.
+     */
+    logger?: Logger;
+}
+
+/**
+ * Represents all known options for a generic platform uploader.
+ */
+export type KnownPlatformUploaderOptions =
+    & ModrinthUploaderOptions
+    & GitHubUploaderOptions
+    & CurseForgeUploaderOptions;
+
+/**
+ * Represents a request for uploading to a platform.
+ */
+export interface GenericPlatformUploadRequest {
+    /**
+     * The unique identifier of the project on the target platform.
+     */
+    id?: string;
+
+    /**
+     * A secure token used for authenticating with the platform's API.
+     */
+    token?: SecureString;
+
+    /**
+     * An array of files to be uploaded to the platform.
+     */
+    files?: FileInfo[];
+
+    /**
+     * The name for the new version of the project to be uploaded.
+     */
+    name?: string;
+
+    /**
+     * The new version identifier for the project.
+     */
+    version?: string;
+
+    /**
+     * The specified type of the version (e.g., 'release', 'beta', 'alpha').
+     */
+    versionType?: VersionType;
+
+    /**
+     * The changelog detailing the updates in the new version.
+     */
+    changelog?: string;
+
+    /**
+     * An array of loaders that the project is compatible with.
+     */
+    loaders?: string[];
+
+    /**
+     * An array of game versions that the project is compatible with.
+     */
+    gameVersions?: string[];
+
+    /**
+     * An array of dependencies required by this version of the project.
+     */
+    dependencies?: Dependency[];
+
+    /**
+     * An array of Java versions that the project supports.
+     */
+    java?: JavaVersion[];
+
+    /**
+     * The maximum number of attempts to publish assets to the platform.
+     */
+    retryAttempts?: number;
+
+    /**
+     * Time delay (in milliseconds) between each attempt to publish assets.
+     */
+    retryDelay?: number;
+}
+
+/**
+ * The default number of retry attempts for a failed upload.
+ */
+const DEFAULT_RETRY_ATTEMPTS = 2;
+
+/**
+ * The default delay time (in milliseconds) between retry attempts for a failed upload.
+ */
+const DEFAULT_RETRY_DELAY = 1000;
+
+/**
+ * Base class for platform uploaders.
+ *
+ * @template TOptions - The type of options that the uploader can utilize.
+ * @template TRequest - The type of content that can be uploaded using the uploader.
+ * @template TReport - The type of report that is returned after the upload process.
+ */
+export abstract class GenericPlatformUploader<TOptions extends GenericPlatformUploaderOptions, TRequest extends GenericPlatformUploadRequest, TReport> implements PlatformUploader<TRequest, TReport> {
+    /**
+     * The logger used by the uploader.
+     */
+    protected readonly _logger: Logger;
+
+    /**
+     * Constructs a new {@link PlatformUploader} instance.
+     *
+     * @param options - The options to use for the uploader.
+     */
+    protected constructor(options?: TOptions) {
+        this._logger = options?.logger || NULL_LOGGER;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    abstract get platform(): PlatformType;
+
+    /**
+     * @inheritdoc
+     */
+    async upload(request: TRequest): Promise<TReport> {
+        ArgumentNullError.throwIfNull(request, "request");
+        ArgumentNullError.throwIfNull(request.token, "request.token");
+        ArgumentNullError.throwIfNullOrEmpty(request.files, "request.files");
+
+        const platformName = PlatformType.friendlyNameOf(this.platform);
+        const maxAttempts = request.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
+        const delay = request.retryDelay ?? DEFAULT_RETRY_DELAY;
+
+        const stopwatch = LoggingStopwatch.startNew(this._logger,
+            () => `📤 Uploading assets to ${platformName}`,
+            ms => `✅ Successfully published assets to ${platformName} in ${ms} ms`
+        );
+        const onError = (error: Error) => {
+            if (isSoftError(error)) {
+                this._logger.error(error);
+                this._logger.info(`🔂 Facing difficulties, republishing assets to ${platformName} in ${delay} ms`);
+                return true;
+            }
+            return false;
+        };
+
+        const report = await retry(
+            () => this.uploadCore(request),
+            { maxAttempts, delay, onError }
+        );
+
+        stopwatch.stop();
+        return report;
+    }
+
+    /**
+     * Processes the specified upload request.
+     *
+     * @param request - The request to process.
+     *
+     * @returns A report generated after the upload.
+     */
+    protected abstract uploadCore(request: TRequest): Promise<TReport>;
+
+    /**
+     * Converts the specified dependencies to a simpler format.
+     *
+     * @param dependencies - The list of dependencies to convert.
+     * @param typeConverter - The function to use for converting dependency types.
+     *
+     * @returns An array of dependencies in a simplified format.
+     */
+    protected convertToSimpleDependencies<T>(dependencies: Dependency[], typeConverter: (type: DependencyType) => T): [id: string, type: T][] {
+        return (dependencies || [])
+            .filter(x => x && !x.isIgnored(this.platform))
+            .map(x => [x.getProjectId(this.platform), typeConverter(x.type)] as [string, T])
+            .filter(([id, type]) => id && type);
+    }
+}
diff --git a/src/publishing/mod-publisher.ts b/src/publishing/mod-publisher.ts
deleted file mode 100644
index b2b895a..0000000
--- a/src/publishing/mod-publisher.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { context } from "@actions/github";
-import { parseVersionName, parseVersionNameFromFileVersion } from "../utils/minecraft";
-import File from "../utils/io/file";
-import Publisher from "./publisher";
-import PublisherTarget from "./publisher-target";
-import MinecraftVersionResolver from "../utils/minecraft/minecraft-version-resolver";
-import ModMetadataReader from "../metadata/mod-metadata-reader";
-import Dependency from "../metadata/dependency";
-import Version from "../utils/versioning/version";
-import VersionType from "../utils/versioning/version-type";
-import DependencyKind from "../metadata/dependency-kind";
-import path from "path";
-
-interface ModPublisherOptions {
-    id: string;
-    token: string;
-    versionType?: "alpha" | "beta" | "release";
-    loaders?: string | string[];
-    name?: string;
-    version?: string;
-    changelog?: string;
-    changelogFile?: string;
-    versionResolver?: string;
-    gameVersions?: string | string[];
-    java?: string | string[];
-    dependencies?: string | string[];
-}
-
-function processMultilineInput(input: string | string[], splitter?: RegExp): string[] {
-    if (!input) {
-        return [];
-    }
-    return (typeof input === "string" ? input.split(splitter || /(\r?\n)+/) : input).map(x => x.trim()).filter(x => x);
-}
-
-function processDependenciesInput(input: string | string[], inputSplitter?: RegExp, entrySplitter?: RegExp): Dependency[] {
-    return processMultilineInput(input, inputSplitter).map(x => {
-        const parts = x.split(entrySplitter || /\|/);
-        const id = parts[0].trim();
-        return Dependency.create({
-            id,
-            kind: parts[1] && DependencyKind.parse(parts[1].trim()) || DependencyKind.Depends,
-            version: parts[2]?.trim() || "*"
-        });
-    });
-}
-
-async function readChangelog(changelogPath: string): Promise<string | never> {
-    const file = (await File.getFiles(changelogPath))[0];
-    if (!file) {
-        throw new Error("Changelog file was not found");
-    }
-    return (await file.getBuffer()).toString("utf8");
-}
-
-export default abstract class ModPublisher extends Publisher<ModPublisherOptions> {
-    protected get requiresId(): boolean {
-        return true;
-    }
-
-    protected get requiresModLoaders(): boolean {
-        return true;
-    }
-
-    protected get requiresGameVersions(): boolean {
-        return true;
-    }
-
-    public async publish(files: File[], options: ModPublisherOptions): Promise<void> {
-        this.validateOptions(options);
-        const releaseInfo = <any>context.payload.release;
-
-        if (!Array.isArray(files) || !files.length) {
-            throw new Error("No upload files were specified");
-        }
-
-        const token = options.token;
-        if (!token) {
-            throw new Error(`Token is required to publish your assets to ${PublisherTarget.toString(this.target)}`);
-        }
-
-        const metadata = await ModMetadataReader.readMetadata(files[0].path);
-
-        const id = options.id || metadata?.getProjectId(this.target);
-        if (!id && this.requiresId) {
-            throw new Error(`Project id is required to publish your assets to ${PublisherTarget.toString(this.target)}`);
-        }
-
-        const filename = path.parse(files[0].path).name;
-        const version = (typeof options.version === "string" && options.version) || <string>releaseInfo?.tag_name || metadata?.version || Version.fromName(filename);
-        const versionType = options.versionType?.toLowerCase() || VersionType.fromName(metadata?.version || filename);
-        const name = typeof options.name === "string" ? options.name : (<string>releaseInfo?.name || version);
-        const changelog = typeof options.changelog === "string"
-            ? options.changelog
-            : typeof options.changelogFile === "string"
-                ? await readChangelog(options.changelogFile)
-                : <string>releaseInfo?.body || "";
-
-        const loaders = processMultilineInput(options.loaders, /\s+/);
-        if (!loaders.length && this.requiresModLoaders) {
-            if (metadata) {
-                loaders.push(...metadata.loaders);
-            }
-            if (!loaders.length) {
-                throw new Error("At least one mod loader should be specified");
-            }
-        }
-
-        const gameVersions = processMultilineInput(options.gameVersions);
-        if (!gameVersions.length && this.requiresGameVersions) {
-            const minecraftVersion =
-                metadata?.dependencies.filter(x => x.id === "minecraft").map(x => parseVersionName(x.version))[0] ||
-                parseVersionNameFromFileVersion(version);
-
-            if (minecraftVersion) {
-                const resolver = options.versionResolver && MinecraftVersionResolver.byName(options.versionResolver) || MinecraftVersionResolver.releasesIfAny;
-                gameVersions.push(...(await resolver.resolve(minecraftVersion)).map(x => x.id));
-            }
-            if (!gameVersions.length) {
-                throw new Error("At least one game version should be specified");
-            }
-        }
-
-        const java = processMultilineInput(options.java);
-        const dependencies = typeof options.dependencies === "string"
-            ? processDependenciesInput(options.dependencies)
-            : metadata?.dependencies || [];
-        const uniqueDependencies = dependencies.filter((x, i, self) => !x.ignore && self.findIndex(y => y.id === x.id && y.kind === x.kind) === i);
-
-        await this.publishMod(id, token, name, version, versionType, loaders, gameVersions, java, changelog, files, uniqueDependencies, <Record<string, unknown>><unknown>options);
-    }
-
-    protected abstract publishMod(id: string, token: string, name: string, version: string, versionType: string, loaders: string[], gameVersions: string[], java: string[], changelog: string, files: File[], dependencies: Dependency[], options: Record<string, unknown>): Promise<void>;
-}
\ No newline at end of file