Kir-Antipov / mc-publish

🚀 Your one-stop GitHub Action for seamless Minecraft project publication across various platforms.
MIT License
216 stars 19 forks source link

Add support for publishing to Hangar #63

Closed WiIIiam278 closed 1 year ago

WiIIiam278 commented 1 year ago

Closes #62

Adds support for publishing to Hangar: https://hangar.papermc.io/, which is now in open beta.

How-to use

Using this action (This assumes you've already got a workflow using mc-publish. If not, go read the README) * Go to https://hangar.papermc.io/auth/settings/api-keys and log in * Create a new API key, name it "publish" and grant it at least the "view public info" and "create version" permissions * Go to your repository, add it as an environment secret called "HANGAR_API_KEY" * (For testing) Replace `uses: Kir-Antipov/mc-publish@v3.2` with `uses: WiIIiam278/mc-publish@hangar` * Edit your workflow to include your hangar token, project ID, channel and if you're publishing to Waterfall/Velocity, game (targeting API) versions. ```yaml # This is a velocity plugin hangar-id: William278/Velocitab hangar-token: ${{ secrets.HANGAR_API_KEY }} hangar-version-type: Alpha # Make sure the channel exists you are publishing to (you will need to create it) hangar-game-versions: | # Note that hangar expects a set of Velocity/Waterfall API versions 3.2 files: target/Velocitab-*.jar version: 1.0 changelog: Removed herobrine loaders: | # Hangar only supports publishing to velocity, paper and waterfall velocity ``` See also: My Velocitab publish workflow [here](https://github.com/WiIIiam278/Velocitab/blob/c5b09590c71b1c64291980bd769f97d567307f7a/.github/workflows/java_ci.yml#L36-L63)

Todo list

Maybes:

Edit: I've closed this PR and aim to rewrite on the new v3.3 branch soon. This will still work for now :)

Kir-Antipov commented 1 year ago

I greatly appreciate your time and willingness to help! I'd like to offer some guidance on what I'd prefer to see (or not see) in this PR:

1) Please refrain from including automatically generated files in your PRs. This general advice applies not just to mc-publish. Kindly remove action.yml, dist/*, and package.lock.json (as you didn't add any new dependencies). 2) The Publisher has been essentially eliminated, as it was dreadfully unsightly. The new version serves as a simple facade for making API calls and logging the results. I won't mind adding it myself when I eventually push the new changes. Thus, for simplicity's sake, please exclude publishing/*, as it will necessitate a rework on my end regardless. 3) Ideally, this PR should be localized to utils/hangar/* to minimize dependencies on external elements. 4) The code quality standards for this project have shifted significantly, and I'd appreciate it if your PR could adhere to these changes. This is one reason why the release of v4.0 takes me so long to finish – technical debt is never enjoyable. I had been treating the codebase too casually, using any, functions with 10+ arguments and the like. However, the project has grown beyond my initial expectations, and many people now rely on it. As a result, maintaining clean code is crucial for its robustness, ease of maintenance, and encouraging more PRs for mc-publish.

To give you a sneak peek, here's the structure of utils/modrinth/*, which contains Modrinth API-specific content:

modrinth/
  index.ts
  modrinth-api-client.ts
  modrinth-dependency-type.ts
  modrinth-dependency.ts
  modrinth-project.ts
  modrinth-unfeature-mode.ts
  modrinth-version.ts

Each component is separate and well-documented. For instance, the previously chaotic Modrinth API calls are now organized within a concise API client:

// modrinth-api-client.ts

import { Fetch, HttpRequest, HttpResponse, createFetch, defaultResponse, throwOnError } from "@/utils/net";
import { asArray } from "@/utils/collections";
import { ModrinthProject } from "./modrinth-project";
import { ModrinthVersion, ModrinthVersionInit, ModrinthVersionPatch, ModrinthVersionSearchTemplate, UnfeaturableModrinthVersion, packModrinthVersionInit, packModrinthVersionSearchTemplate } from "./modrinth-version";
import { ModrinthUnfeatureMode } from "./modrinth-unfeature-mode";

/**
 * The API version used for making requests to the Modrinth API.
 */
const MODRINTH_API_VERSION = 2;

/**
 * The base URL for the Modrinth API.
 */
export const MODRINTH_API_URL = `https://api.modrinth.com/v${MODRINTH_API_VERSION}` as const;

/**
 * The base URL for the staging Modrinth API.
 */
export const MODRINTH_STAGING_API_URL = `https://staging-api.modrinth.com/v${MODRINTH_API_VERSION}` as const;

/**
 * Describes the configuration options for the Modrinth API client.
 */
export interface ModrinthApiOptions {
    /**
     * The Fetch implementation used for making HTTP requests.
     */
    fetch?: Fetch;

    /**
     * The base URL for the Modrinth API.
     *
     * Defaults to {@link MODRINTH_API_URL}.
     */
    baseUrl?: string | URL;

    /**
     * The API token to be used for authentication with the Modrinth API.
     */
    token?: string;
}

/**
 * A client for interacting with the Modrinth API.
 */
export class ModrinthApiClient {
    /**
     * The Fetch implementation used for making HTTP requests.
     */
    private readonly _fetch: Fetch;

    /**
     * Creates a new {@link ModrinthApiClient} instance.
     *
     * @param options - The configuration options for the client.
     */
    constructor(options?: ModrinthApiOptions) {
        this._fetch = createFetch({
            handler: options?.fetch,
            baseUrl: options?.baseUrl || options?.fetch?.["baseUrl"] || MODRINTH_API_URL,
            defaultHeaders: {
                Authorization: options?.token,
            },
        })
        .use(defaultResponse({ response: r => HttpResponse.json(null, r) }))
        .use(throwOnError({ filter: x => !x.ok && x.status !== 404 }));
    }

    /**
     * Fetches a project by its id or slug.
     *
     * @param idOrSlug - The project id or slug.
     *
     * @returns The project, or `undefined` if not found.
     */
    async getProject(idOrSlug: string): Promise<ModrinthProject | undefined> {
        const response = await this._fetch(`/project/${idOrSlug}`);
        return (await response.json()) ?? undefined;
    }

    /**
     * Fetches multiple projects by their IDs.
     *
     * @param ids - The project IDs.
     *
     * @returns An array of projects.
     *
     * @remarks
     *
     * This method **DOES NOT** support slugs (for some reason).
     */
    async getProjects(ids: Iterable<string>): Promise<ModrinthProject[]> {
        const response = await this._fetch(`/projects`, HttpRequest.get().with({ ids: JSON.stringify(asArray(ids)) }));
        return (await response.json()) ?? [];
    }

    /**
     * Fetches a version by its id.
     *
     * @param id - The version id.
     *
     * @returns The version, or `undefined` if not found.
     */
    async getVersion(id: string): Promise<ModrinthVersion | undefined> {
        const response = await this._fetch(`/version/${id}`);
        return (await response.json()) ?? undefined;
    }

    /**
     * Fetches multiple versions by their IDs.
     *
     * @param ids - The version IDs.
     *
     * @returns An array of versions.
     */
    async getVersions(ids: Iterable<string>): Promise<ModrinthVersion[]> {
        const response = await this._fetch(`/versions`, HttpRequest.get().with({ ids: JSON.stringify(asArray(ids)) }));
        return (await response.json()) ?? [];
    }

    /**
     * Creates a new version.
     *
     * @param version - The version data.
     *
     * @returns The created version.
     */
    async createVersion(version: ModrinthVersionInit): Promise<ModrinthVersion> {
        const form = packModrinthVersionInit(version);
        const response = await this._fetch("/version", HttpRequest.post().with(form));
        return await response.json();
    }

    /**
     * Updates an existing version.
     *
     * @param version - The version data to update.
     *
     * @returns `true` if the update was successful; otherwise, `false`.
     */
    async updateVersion(version: ModrinthVersionPatch): Promise<boolean> {
        const response = await this._fetch(`/version/${version.id}`, HttpRequest.patch().json(version));
        return response.ok;
    }

    /**
     * Fetches the versions of a project based on the provided search template.
     *
     * @param idOrSlug - The project id or slug.
     * @param template - The search template to filter versions.
     *
     * @returns An array of versions matching the search criteria.
     */
    async getProjectVersions(idOrSlug: string, template?: ModrinthVersionSearchTemplate): Promise<ModrinthVersion[]> {
        const params = packModrinthVersionSearchTemplate(template);
        const response = await this._fetch(`/project/${idOrSlug}/version`, HttpRequest.get().with(params));
        return (await response.json()) ?? [];
    }

    /**
     * Unfeatures previous project versions based on the provided mode.
     *
     * @param currentVersion - The current version to use as an anchor point.
     * @param mode - The unfeaturing mode (default: `ModrinthUnfeatureMode.SUBSET`).
     *
     * @returns A record containing version IDs as keys and a boolean indicating whether the unfeaturing operation was successful for each version.
     */
    async unfeaturePreviousProjectVersions(currentVersion: UnfeaturableModrinthVersion, mode?: ModrinthUnfeatureMode): Promise<Record<string, boolean>> {
        mode ??= ModrinthUnfeatureMode.SUBSET;

        const previousVersions = await this.getProjectVersions(currentVersion.project_id, { featured: true });
        const unfeaturedVersions = { } as Record<string, boolean>;

        for (const previousVersion of previousVersions) {
            if (!ModrinthUnfeatureMode.shouldUnfeature(previousVersion, currentVersion, mode)) {
                continue;
            }

            unfeaturedVersions[previousVersion.id] = await this.updateVersion({ id: previousVersion.id, featured: false });
        }

        return unfeaturedVersions;
    }
}
WiIIiam278 commented 1 year ago

Thanks for the detailed feedback :)

A lot of what you're saying confused me at first, since I didn't realize you'd started on 4.0 on the dev branch, and I wanted to get this working for my own workflow files, hence why I included dist (I assume that's built automatically by a workflow); I'm using WiIIiam278@hangar for my own project's workflow files and didn't really know how else to test the action otherwise! Whoops, looks like I sort of wasted a fair bit of time.

Are you ready to accept PRs to dev? I notice it hasn't been touched since November, so I'm guessing it's still got quite a bit to go before it works. Would you like me to hold off until it's a bit further ahead? I agree re:code quality, I'm definitely breaking the eslint checks atm. But I don't really want to PR this to a branch that's not working yet, as I do depend on this myself for my own workflows 😅 -- my main (selfish) goal here was frankly just to get this working so I could avoid the annoyance of having to publish to yet another marketplace.

Nonetheless, I really do want to help (this is an ambitious but important bit of infrastructure now across the mcdev scene!), so if you are committed to spending some time to get dev going as a stable base for PRs, I'm equally committed to helping. I'd love to work on getting Polymart working, too, for instance, down the line :)

Kir-Antipov commented 1 year ago

Yeah, there are approximately 200 commits waiting to be pushed to the dev branch, so you may guess that the project has undergone significant changes compared to its previous state. I am still refining certain aspects and am not yet ready to synchronize my local dev with the origin. Once synchronization is complete, I'll be able to accept PRs.

As for your PR, I may not have time to include it in v4.0 due to the hard deadline stemming from Node12 deprecation. Before incorporating any substantial new features, I need to complete writing tests and comprehensive documentation for the project. Therefore, it's likely that your PR will have to wait for v4.1 (or whatever the next version will be called).

WiIIiam278 commented 1 year ago

Fine by me, no rush :) When you're ready to accept PRs for v4, I'll update/rebase this. Looking forward to it.

Kir-Antipov commented 1 year ago

v3.3 is up and running now. I'm still finalizing it tho (for example, I haven't had time to achieve 100% test coverage before the deadline, sadly), but at least you can see how things are done in this project nowadays.

Considering the amount and the nature of changes, it may be simpler for you to open a new, clean PR that doesn't require merging 250+ commits into it :D

WiIIiam278 commented 1 year ago

v3.3 is up and running now. I'm still finalizing it tho (for example, I haven't had time to achieve 100% test coverage before the deadline, sadly), but at least you can see how things are done in this project nowadays.

Considering the amount and the nature of changes, it may be simpler for you to open a new, clean PR that doesn't require merging 250+ commits into it :D

That's exactly what I shall do! Many thanks for your hard work. I look forward to having a crack at this when I get a chance.