diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 698898197f..44f96c197c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,6 @@ jobs: - uses: actions/checkout@v7 - name: Checkout release branch - if: github.event_name != 'pull_request' uses: actions/checkout@v7 with: ref: release @@ -78,6 +77,24 @@ jobs: directory: release branch: release + - name: Upload .beet_cache as artifact + uses: actions/upload-artifact@v7 + with: + name: .beet_cache + path: ${{ github.workspace }}/.beet_cache/ + include-hidden-files: true + retention-days: 2 + + - name: Rename log file + run: mv ${{ github.workspace }}/logs/summary_logs.pkl ${{ github.workspace }}/logs/build_log.pkl + + - name: Upload logs as artifact + uses: actions/upload-artifact@v7 + with: + path: ${{ github.workspace }}/logs/build_log.pkl + archive: false + retention-days: 2 + test: strategy: fail-fast: false @@ -135,3 +152,75 @@ jobs: with: name: 'Test world in ${{ matrix.version }} for ${{ github.sha }}' path: ${{ github.workspace }}/world/ + + publish: + runs-on: ubuntu-24.04 + if: github.event_name != 'pull_request' + needs: build + strategy: + matrix: + platform: [modrinth, smithed] + # concurrency: + # group: release + name: 'publish-${{ matrix.platform }}' + steps: + - uses: actions/checkout@v7 + + - name: Checkout release branch + uses: actions/checkout@v7 + with: + ref: release + path: release + + - name: Restore .beet_cache from artifact + uses: actions/download-artifact@v7 + with: + name: .beet_cache + path: ${{ github.workspace }}/.beet_cache + + # TODO use cache from build? + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Publish all modules + run: uv run beet -p beet-publish.yaml -l ${{ env.LOG_LEVEL }} -s meta.gm4.publish_to=${{ matrix.platform }} build + env: + BEET_MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} + BEET_SMITHED_TOKEN: ${{ secrets.SMITHED_TOKEN }} + LOG_LEVEL: ${{ runner.debug == 1 && 'DEBUG' || 'INFO'}} + + - name: Rename log file + run: mv ${{ github.workspace }}/logs/summary_logs.pkl ${{ github.workspace }}/logs/publish_${{ matrix.platform }}_log.pkl + + - name: Upload logs as artifact + uses: actions/upload-artifact@v7 + with: + path: ${{ github.workspace }}/logs/publish_${{ matrix.platform }}_log.pkl + archive: false + retention-days: 2 + + summarize: + runs-on: ubuntu-24.04 + needs: [build, publish] + if: ${{ always() }} + steps: + - uses: actions/checkout@v7 + + - name: Restore .beet_cache from artifact + uses: actions/download-artifact@v7 + with: + name: .beet_cache + path: ${{ github.workspace }}/.beet_cache + + - name: Restore log files from artifacts + uses: actions/download-artifact@v8 + with: + pattern: '*_log.pkl' + path: ${{ github.workspace }}/logs + merge-multiple: true + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Summarize build logs + run: uv run beet -p beet-summarize.yaml -s meta.gm4.summarize_type=${{ github.event_name != 'pull_request' && 'release' || 'pull_request' }} build diff --git a/.gitignore b/.gitignore index f73c2cb7b3..443250364a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ __pycache__/ ### Beet ### out/ release/ +logs/ .beet_cache/ diff --git a/beet-publish.yaml b/beet-publish.yaml new file mode 100644 index 0000000000..21632fa28e --- /dev/null +++ b/beet-publish.yaml @@ -0,0 +1,12 @@ +pipeline: + - gm4.plugins.publish.check_gh_action_failed + - gm4.plugins.annotations + - gm4.plugins.publish.switch_platform + + - broadcast: [gm4_*, lib_*] + pipeline: + - gm4.plugins.publish.load_config + meta: + plugins: + - gm4.plugins.readme_generator + - gm4.plugins.publish diff --git a/beet-summarize.yaml b/beet-summarize.yaml new file mode 100644 index 0000000000..6fa45f5106 --- /dev/null +++ b/beet-summarize.yaml @@ -0,0 +1,2 @@ +pipeline: + - gm4.plugins.annotations.load_and_summarize diff --git a/gm4/plugins/annotations.py b/gm4/plugins/annotations.py index 73bf568da4..d9ae07209d 100644 --- a/gm4/plugins/annotations.py +++ b/gm4/plugins/annotations.py @@ -5,6 +5,7 @@ from functools import partial from pathlib import Path from typing import Any +import pickle from beet import Context, ProjectCache @@ -31,12 +32,32 @@ def filter(record: logging.LogRecord): # summary handler holds onto certain records until the exit phase when it emits to a markdown summary sum_handler = SummaryHandler(1000, ctx.cache) - logging.getLogger("gm4.output").addHandler(sum_handler) + logging.getLogger("gm4.publish").addHandler(sum_handler) logging.getLogger("gm4.manifest.update_patch").addHandler(sum_handler) - # after the whole build, flush the stored records and form the markdown summary + # after the whole build, flush the stored records to file for the later markdown summary action job yield - sum_handler.flush() + sum_handler.flush_to_pickle() + +def load_and_summarize(ctx: Context): + """Loads log entries from previous gh step/job to aggregate""" + sum_handler = SummaryHandler(1000, ctx.cache) + + log_dir = Path("logs") + for log_file in log_dir.iterdir(): + with open(log_file, 'rb') as f: + log_buffer: list[logging.LogRecord] = pickle.load(f) + for log_entry in log_buffer: + sum_handler.emit(log_entry) + + summary_title = "" + if (sum_type:=ctx.meta.get('gm4', {}).get('summarize_type')) == 'release': + summary_title = "Build Deployment Summary" + elif sum_type == 'pull_request': + summary_title = "Pull Request Deployment Preview" + + sum_handler.flush_to_summary(summary_title) + LEVEL_CONVERSION = { logging.DEBUG: "debug", @@ -85,7 +106,7 @@ def __init__(self, capacity: int, beet_cache: ProjectCache): self.beet_cache = beet_cache self.summary_created = False - def flush(self): + def flush_to_summary(self, summary_title: str): summary_entries: dict[str, Any] = {} this_manifest = ManifestCacheModel.model_validate(self.beet_cache["gm4_manifest"].json) @@ -131,7 +152,7 @@ def flush(self): table += f"\n {entry['name']} | {entry['ver_update']} | {nested_table}" - summary = "# :rocket: Build Deployment Summary :rocket:\n"+table + summary = f"# :rocket: {summary_title} :rocket:\n"+table if not self.summary_created: env_file = os.getenv("GITHUB_STEP_SUMMARY") @@ -141,6 +162,15 @@ def flush(self): self.summary_created = True self.buffer.clear() + def flush_to_pickle(self): + """Writes buffer of log entries to file, for a later gh action step to aggregate into the summary file""" + log_dir = Path("logs") + log_file = log_dir/"summary_logs.pkl" + os.makedirs(log_dir, exist_ok=True) + with open(log_file, "wb") as f: + pickle.dump(self.buffer, f) + self.buffer.clear() + def add_module_dir_to_diagnostics(ctx: Context): diff --git a/gm4/plugins/output.py b/gm4/plugins/output.py index 4589c159a3..33de9311a3 100644 --- a/gm4/plugins/output.py +++ b/gm4/plugins/output.py @@ -1,21 +1,11 @@ from beet import Context from pathlib import Path import os -import json -import re -import requests import shutil import logging -from gm4.utils import run, Version, NoneAttribute -from gm4.plugins.manifest import ManifestConfig, ManifestCacheModel -parent_logger = logging.getLogger("gm4.output") -MODRINTH_API = "https://api.modrinth.com/v2" -MODRINTH_AUTH_KEY = "BEET_MODRINTH_TOKEN" -SMITHED_API = "https://api.smithed.dev/v2" -SMITHED_AUTH_KEY = "BEET_SMITHED_TOKEN" -USER_AGENT = "Gamemode4Dev/GM4_Datapacks/release-pipeline (gamemode4official@gmail.com)" +parent_logger = logging.getLogger("gm4.output") def beet_default(ctx: Context): @@ -73,14 +63,6 @@ def release(ctx: Context): """ Saves the zipped datapack and metadata to the ./release/{version} folder. Should be first in pipeline to properly wrap all other plugins cleanup phases - - If the module has the `version` and `meta.modrinth.project_id` fields, and - `BEET_MODRINTH_TOKEN` environment variable is set, will try to publish a - new version to Modrinth if it doesn't already exist. - - Similarly, if the module has the `version` and `meta.smithed.pack_id` fields, and - `BEET_SMITHED_TOKEN` environment variable is set, will try to publish a - new version to Smithed if it doesn't already exist. """ version_dir = os.getenv("VERSION", "26.2") release_dir = Path("release") / version_dir @@ -109,189 +91,6 @@ def release(ctx: Context): if "smithed_readme" in ctx.meta: ctx.meta['smithed_readme'].dump(smithed_readme_dir, f"{corrected_project_id}.md") - config = ctx.validate("gm4", ManifestConfig) - - # publish to download platforms - publish_modrinth(ctx, config, release_dir, file_name) - publish_smithed(ctx, config, file_name) - - -def publish_modrinth(ctx: Context, config: ManifestConfig, release_dir: Path, file_name: str): - '''Attempts to publish pack to modrinth''' - auth_token = os.getenv(MODRINTH_AUTH_KEY, None) - logger = parent_logger.getChild(f"modrinth.{ctx.project_id}") - if config.modrinth and auth_token: - # update page description - res = requests.get(f"{MODRINTH_API}/project/{config.modrinth.project_id}", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}) - if not (200 <= res.status_code < 300): - if res.status_code == 404: - logger.warning(f"Cannot edit description of modrinth project {config.modrinth.project_id} as it doesn't exist.") - else: - logger.warning(f"Failed to get project: {res.status_code} {res.text}") - return - existing_readme = res.json()["body"] - if existing_readme != (d:=ctx.meta['modrinth_readme'].text): - logger.debug("Readme and modrinth-page content differ. Updating webpage body") - res = requests.patch(f"{MODRINTH_API}/project/{config.modrinth.project_id}", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}, json={"body": d}) - if not (200 <= res.status_code < 300): - logger.warning(f"Failed to update description: {res.status_code} {res.text}") - logger.info(f"Successfully updated description of {ctx.project_name}", extra={"gh_annotate_skip": True}) - - # upload datapack zip - if ctx.project_version: - version = ctx.cache["gm4_manifest"].json["modules"].get(ctx.project_id, {}).get("version", None) - if version is None: - logger.warning("Full version number not available in ctx.meta. Skipping publishing") - return - - res = requests.get(f"{MODRINTH_API}/project/{config.modrinth.project_id}/version", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}) - if not (200 <= res.status_code < 300): - if res.status_code == 404: - logger.warning(f"Cannot publish to modrinth project {config.modrinth.project_id} as it doesn't exist.") - else: - logger.warning(f"Failed to get project versions: {res.status_code} {res.text}") - return - project_data = res.json() - - matching_version = next((v for v in project_data if v["version_number"] == str(version)), None) - if matching_version is not None: # patch version already exists - # update mc versions if necessary - if len(config.minecraft) > 0 and not set(matching_version["game_versions"]) == set(config.minecraft): - # supported versions has changed and is not empty - logger.debug("Additional MC version support has been added to an existing patch version. Updating existing modrinth version data") - res = requests.patch(f"{MODRINTH_API}/version/{matching_version['id']}", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}, json={ - "game_versions": config.minecraft - }) - if not (200 <= res.status_code < 300): - logger.warning(f"Failed to patch project versions: {res.status_code} {res.text}") - return - - if len(config.minecraft) > 0: - # supported versions is not empty, post new version - with open(release_dir / file_name, "rb") as f: - file_bytes = f.read() - - changelog = run(["git", "log", "-1", "--format=%s"]) - changelog = re.sub(r"\(#(\d+)\)", "([#\\1](https://github.com/Gamemode4Dev/GM4_Datapacks/pull/\\1))", changelog) - - res = requests.post(f"{MODRINTH_API}/version", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}, files={ - "data": json.dumps({ - "name": f"{ctx.project_name} v{version}", - "version_number": version, - "changelog": changelog, - "dependencies": [], - "game_versions": config.minecraft, - "version_type": "release", - "loaders": ["datapack"], - "featured": False, - "project_id": config.modrinth.project_id, - "file_parts": [file_name], - }), - file_name: file_bytes, - }) - if not (200 <= res.status_code < 300): - logger.warning(f"Failed to publish new version version: {res.status_code} {res.text}") - return - logger.info(f"Successfully published {res.json()['name']}", extra={"gh_annotate_skip": True}) - - -def publish_smithed(ctx: Context, config: ManifestConfig, file_name: str): - """Attempts to publish pack to smithed""" - auth_token = os.getenv(SMITHED_AUTH_KEY, None) - logger = parent_logger.getChild(f"smithed.{ctx.project_id}") - mc_version_dir = os.getenv("VERSION", "26.2") - manifest = ManifestCacheModel.model_validate(ctx.cache["gm4_manifest"].json) - project_id = stem if (stem:=ctx.directory.stem).startswith("lib") else ctx.project_id - - if config.smithed and auth_token: - version = (manifest.modules|manifest.libraries).get(project_id, NoneAttribute()).version or "" - - # get project data and existing versions - res = requests.get(f"{SMITHED_API}/packs/{config.smithed.pack_id}") - if not (200 <= res.status_code < 300): - if res.status_code == 404: - logger.warning(f"Cannot publish to smithed project {config.smithed.pack_id} as it doesn't exist.") - else: - logger.warning(f"Failed to get project: {res.status_code} {res.text}") - return - - project_data = res.json() - - # update description and pack image - # ensures they point to the most up-to-date mc version branch - project_versions = project_data["versions"] - newest_version = sorted([Version(v["name"]) for v in project_versions])[-1] - if Version(version) > newest_version: # only update the description if we're not patching an old version - project_display = project_data["display"] - current_icon = f"https://raw.githubusercontent.com/Gamemode4Dev/GM4_Datapacks/release/{mc_version_dir}/generated/pack_icons/{project_id}.png" - current_readme = f"https://raw.githubusercontent.com/Gamemode4Dev/GM4_Datapacks/release/{mc_version_dir}/generated/smithed_readmes/{project_id}.md" - - if project_display["icon"] != current_icon or project_display["webPage"] != current_readme: - logger.debug("Pack Icon or Readme hyperlink is incorrect. Updating project") - res = requests.patch(f"{SMITHED_API}/packs/{config.smithed.pack_id}", params={'token': auth_token}, - json={"data": { - "display": { - "icon": current_icon, - "webPage": current_readme, - }, - }}) - if not (200 <= res.status_code < 300): - logger.warning(f"Failed to update descripion: {res.status_code} {res.text}") - logger.info(f"{ctx.project_name} {res.text}", extra={"gh_annotate_skip": True}) - - matching_version = next((v for v in project_versions if v["name"] == str(version)), None) - if matching_version is not None: # patch version already exists - # update MC version if necessary - if len(config.minecraft) > 0 and not set(matching_version["supports"]) == set(config.minecraft): - # supported versions has changed and is not empty - logger.debug("Additional MC version support has been added to an existing patch version. Updating existing smithed version data") - res = requests.patch(f"{SMITHED_API}/packs/{config.smithed.pack_id}/versions/{matching_version['name']}", params={'token': auth_token}, json={ - "data": { - "supports": config.minecraft - } - }) - if not (200 <= res.status_code < 300): - logger.warning(f"Failed to patch project versions: {res.status_code} {res.text}") - return - - # permalink previous version (in that MC version) to the git history - commit_hash = run("cd release && git log -1 --format=%H") - matching_mc_versions = sorted((Version(v["name"]) for v in project_versions if set(v['supports']) & set(config.minecraft))) - prior_version_in_mc_version = matching_mc_versions[-1] if len(matching_mc_versions) > 0 else None # newest version number, with any MC overlap - prior_url: str = next((v["downloads"]["datapack"] for v in project_versions if Version(v["name"]) == prior_version_in_mc_version), "") - if "https://github.com/Gamemode4Dev/GM4_Datapacks/blob/" not in prior_url and prior_version_in_mc_version: - res = requests.patch(f"{SMITHED_API}/packs/{config.smithed.pack_id}/versions/{prior_version_in_mc_version}", params={'token': auth_token}, json={ - "data":{ - "downloads": { - "datapack": f"https://github.com/Gamemode4Dev/GM4_Datapacks/blob/{commit_hash}/{mc_version_dir}/{file_name}?raw=true", - "resourcepack": "" - } - } - }) - if not (200 <= res.status_code < 300): - logger.warning(f"Failed to permalink {project_id} version {prior_version_in_mc_version}: {res.status_code} {res.text}") - else: - logger.info(f"Permalinked {project_id} {prior_version_in_mc_version} to git history: {res.text}", extra={"gh_annotate_skip": True}) - - if len(config.minecraft) > 0: - # supported versions is not empty, post new version - res = requests.post(f"{SMITHED_API}/packs/{config.smithed.pack_id}/versions", - params={'token': auth_token, 'version': version}, - json={"data":{ - "downloads":{ - "datapack": f"https://raw.githubusercontent.com/Gamemode4Dev/GM4_Datapacks/release/{mc_version_dir}/{file_name}", - "resourcepack": "" - }, - "name": version, - "supports": config.minecraft, - "dependencies": [] - }} - ) - if not (200 <= res.status_code < 300): - logger.warning(f"Failed to publish new version of {ctx.project_name}: {res.status_code} {res.text}") - return - logger.info(f"{ctx.project_name} {res.text}", extra={"gh_annotate_skip": True}) - def clear_release(ctx: Context): """ diff --git a/gm4/plugins/publish.py b/gm4/plugins/publish.py new file mode 100644 index 0000000000..734d71e293 --- /dev/null +++ b/gm4/plugins/publish.py @@ -0,0 +1,290 @@ +import json +import logging +import os +import re +import sys +from pathlib import Path + +import requests +from beet import Context, PackConfig, Project, ProjectBuilder +from beet.toolchain.config import load_config as beet_load_config + +from gm4.plugins.manifest import ManifestCacheModel, ManifestConfig +from gm4.utils import NoneAttribute, Version, run + +parent_logger = logging.getLogger("gm4.publish") + + +MODRINTH_API = "https://api.modrinth.com/v2" +MODRINTH_AUTH_KEY = "BEET_MODRINTH_TOKEN" +SMITHED_API = "https://api.smithed.dev/v2" +SMITHED_AUTH_KEY = "BEET_SMITHED_TOKEN" +USER_AGENT = "Gamemode4Dev/GM4_Datapacks/release-pipeline (gamemode4official@gmail.com)" + +def beet_default(ctx: Context): + """Published all module release .zips from VERSION/release to download sites + + If the module has the `version` and `meta.modrinth.project_id` fields, and + `BEET_MODRINTH_TOKEN` environment variable is set, will try to publish a + new version to Modrinth if it doesn't already exist. + + Similarly, if the module has the `version` and `meta.smithed.pack_id` fields, and + `BEET_SMITHED_TOKEN` environment variable is set, will try to publish a + new version to Smithed if it doesn't already exist.""" + + version_dir = os.getenv("VERSION", "26.2") + release_dir = Path("release") / version_dir + + corrected_project_id = stem if (stem:=ctx.directory.stem).startswith("lib") else ctx.project_id + + file_name = f"{corrected_project_id}_{version_dir.replace('.', '_')}.zip" + + config = ctx.validate("gm4", ManifestConfig) + + publish_to = ctx.cache["currently_publishing"].json.get("publish_to", None) + + # check if build already failed, and do not continue attempting to publish + if ctx.cache['gh_action_failed'].json.get('gh_action_failed', False): + return + + # publish to download platforms, based on which gh job this is + if publish_to == "smithed": + publish_smithed(ctx, config, file_name) + elif publish_to == "modrinth": + publish_modrinth(ctx, config, release_dir, file_name) + +def switch_platform(ctx: Context): + """Reads gm4.publish_to meta field to the cache, for subpipelines to check and run the correct publish plugin""" + publish_to: str = ctx.meta.get("gm4", {}).get("publish_to", None) + ctx.cache["currently_publishing"].json = {"publish_to": publish_to} + +def load_config(ctx: Context): + """Loads relavent fields from each module's beet.yaml, without inheriting the pipeline and running plugins""" + + config = beet_load_config(ctx.directory/"beet.yaml") + + config.require = [] + config.pipeline = ctx.meta.get("plugins", []) + config.data_pack = PackConfig() + config.resource_pack = PackConfig() + + # run the new list of plugins from beet-publish.yaml + ctx.require( + ProjectBuilder( + Project( + config, + resolved_cache=ctx.cache + ) + ) + ) + +def mark_gh_action_failed(ctx: Context): + """Marks beet cache that pipeline failed, allowing for cleanup steps (like annotations)""" + ctx.cache['gh_action_failed'].json = {'gh_action_failed': True} + +def check_gh_action_failed(ctx: Context): + """Checks beet cache and marks github action runner as 'failed'""" + yield + is_failed = ctx.cache['gh_action_failed'].json.get('gh_action_failed', False) + if is_failed: + sys.exit(1) + +def publish_modrinth(ctx: Context, config: ManifestConfig, release_dir: Path, file_name: str): + '''Attempts to publish pack to modrinth''' + auth_token = os.getenv(MODRINTH_AUTH_KEY, None) + logger = parent_logger.getChild(f"modrinth.{ctx.project_id}") + if config.modrinth and auth_token: + # update page description + res = requests.get(f"{MODRINTH_API}/project/{config.modrinth.project_id}", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}) + if not (200 <= res.status_code < 300): + if res.status_code == 404: + logger.warning(f"Cannot edit description of modrinth project {config.modrinth.project_id} as it doesn't exist.") + else: + logger.warning(f"Failed to get project: {res.status_code} {res.text}") + return + existing_readme = res.json()["body"] + if existing_readme != (d:=ctx.meta['modrinth_readme'].text): + logger.debug("Readme and modrinth-page content differ. Updating webpage body") + res = requests.patch(f"{MODRINTH_API}/project/{config.modrinth.project_id}", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}, json={"body": d}) + if not (200 <= res.status_code < 300): + logger.warning(f"Failed to update description: {res.status_code} {res.text}") + logger.info(f"Successfully updated description of {ctx.project_name}", extra={"gh_annotate_skip": True}) + + # upload datapack zip + if ctx.project_version: + version = ctx.cache["gm4_manifest"].json["modules"].get(ctx.project_id, {}).get("version", None) + if version is None: + logger.warning("Full version number not available in ctx.meta. Skipping publishing") + return + + res = requests.get(f"{MODRINTH_API}/project/{config.modrinth.project_id}/version", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}) + if not (200 <= res.status_code < 300): + if res.status_code == 404: + logger.warning(f"Cannot publish to modrinth project {config.modrinth.project_id} as it doesn't exist.") + else: + logger.warning(f"Failed to get project versions: {res.status_code} {res.text}") + return + project_data = res.json() + + matching_version = next((v for v in project_data if v["version_number"] == str(version)), None) + if matching_version is not None: # patch version already exists + # update mc versions if necessary + if len(config.minecraft) > 0 and not set(matching_version["game_versions"]) == set(config.minecraft): + # supported versions has changed and is not empty + logger.debug("Additional MC version support has been added to an existing patch version. Updating existing modrinth version data") + res = requests.patch(f"{MODRINTH_API}/version/{matching_version['id']}", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}, json={ + "game_versions": config.minecraft + }) + if res.status_code == 401: + logger.critical(f"Authentication error, cancelling publish. Check token validity!: {res.status_code} {res.text}") + ctx.require(mark_gh_action_failed) + return + elif not (200 <= res.status_code < 300): + logger.warning(f"Failed to patch project versions: {res.status_code} {res.text}") + return + + if len(config.minecraft) > 0: + # supported versions is not empty, post new version + with open(release_dir / file_name, "rb") as f: + file_bytes = f.read() + + changelog = run(["git", "log", "-1", "--format=%s"]) + changelog = re.sub(r"\(#(\d+)\)", "([#\\1](https://github.com/Gamemode4Dev/GM4_Datapacks/pull/\\1))", changelog) + + res = requests.post(f"{MODRINTH_API}/version", headers={'Authorization': auth_token, 'User-Agent': USER_AGENT}, files={ + "data": json.dumps({ + "name": f"{ctx.project_name} v{version}", + "version_number": version, + "changelog": changelog, + "dependencies": [], + "game_versions": config.minecraft, + "version_type": "release", + "loaders": ["datapack"], + "featured": False, + "project_id": config.modrinth.project_id, + "file_parts": [file_name], + }), + file_name: file_bytes, + }) + if res.status_code == 401: + logger.critical(f"Authentication error, cancelling publish. Check token validity!: {res.status_code} {res.text}") + ctx.require(mark_gh_action_failed) + return + elif not (200 <= res.status_code < 300): + logger.warning(f"Failed to publish new version version: {res.status_code} {res.text}") + return + logger.info(f"Successfully published {res.json()['name']}", extra={"gh_annotate_skip": True}) + + +def publish_smithed(ctx: Context, config: ManifestConfig, file_name: str): + """Attempts to publish pack to smithed""" + auth_token = os.getenv(SMITHED_AUTH_KEY, None) + logger = parent_logger.getChild(f"smithed.{ctx.project_id}") + mc_version_dir = os.getenv("VERSION", "26.2") + manifest = ManifestCacheModel.model_validate(ctx.cache["gm4_manifest"].json) + project_id = stem if (stem:=ctx.directory.stem).startswith("lib") else ctx.project_id + + if config.smithed and auth_token: + version = (manifest.modules|manifest.libraries).get(project_id, NoneAttribute()).version or "" + + # get project data and existing versions + res = requests.get(f"{SMITHED_API}/packs/{config.smithed.pack_id}") + if not (200 <= res.status_code < 300): + if res.status_code == 404: + logger.warning(f"Cannot publish to smithed project {config.smithed.pack_id} as it doesn't exist.") + else: + logger.warning(f"Failed to get project: {res.status_code} {res.text}") + return + + project_data = res.json() + + # update description and pack image + # ensures they point to the most up-to-date mc version branch + project_versions = project_data["versions"] + newest_version = sorted([Version(v["name"]) for v in project_versions])[-1] + if Version(version) > newest_version: # only update the description if we're not patching an old version + project_display = project_data["display"] + current_icon = f"https://raw.githubusercontent.com/Gamemode4Dev/GM4_Datapacks/release/{mc_version_dir}/generated/pack_icons/{project_id}.png" + current_readme = f"https://raw.githubusercontent.com/Gamemode4Dev/GM4_Datapacks/release/{mc_version_dir}/generated/smithed_readmes/{project_id}.md" + + if project_display["icon"] != current_icon or project_display["webPage"] != current_readme: + logger.debug("Pack Icon or Readme hyperlink is incorrect. Updating project") + res = requests.patch(f"{SMITHED_API}/packs/{config.smithed.pack_id}", params={'token': auth_token}, + json={"data": { + "display": { + "icon": current_icon, + "webPage": current_readme, + }, + }}) + if res.status_code == 401: + logger.critical(f"Authentication error, cancelling publish. Check token validity!: {res.status_code} {res.text}") + ctx.require(mark_gh_action_failed) + return + elif not (200 <= res.status_code < 300): + logger.warning(f"Failed to update descripion: {res.status_code} {res.text}") + logger.info(f"{ctx.project_name} {res.text}", extra={"gh_annotate_skip": True}) + + matching_version = next((v for v in project_versions if v["name"] == str(version)), None) + if matching_version is not None: # patch version already exists + # update MC version if necessary + if len(config.minecraft) > 0 and not set(matching_version["supports"]) == set(config.minecraft): + # supported versions has changed and is not empty + logger.debug("Additional MC version support has been added to an existing patch version. Updating existing smithed version data") + res = requests.patch(f"{SMITHED_API}/packs/{config.smithed.pack_id}/versions/{matching_version['name']}", params={'token': auth_token}, json={ + "data": { + "supports": config.minecraft + } + }) + if res.status_code == 401: + logger.critical(f"Authentication error, cancelling publish. Check token validity!: {res.status_code} {res.text}") + ctx.require(mark_gh_action_failed) + return + elif not (200 <= res.status_code < 300): + logger.warning(f"Failed to patch project versions: {res.status_code} {res.text}") + return + + # permalink previous version (in that MC version) to the git history + commit_hash = run("cd release && git log -1 --format=%H") + matching_mc_versions = sorted((Version(v["name"]) for v in project_versions if set(v['supports']) & set(config.minecraft))) + prior_version_in_mc_version = matching_mc_versions[-1] if len(matching_mc_versions) > 0 else None # newest version number, with any MC overlap + prior_url: str = next((v["downloads"]["datapack"] for v in project_versions if Version(v["name"]) == prior_version_in_mc_version), "") + if "https://github.com/Gamemode4Dev/GM4_Datapacks/blob/" not in prior_url and prior_version_in_mc_version: + res = requests.patch(f"{SMITHED_API}/packs/{config.smithed.pack_id}/versions/{prior_version_in_mc_version}", params={'token': auth_token}, json={ + "data":{ + "downloads": { + "datapack": f"https://github.com/Gamemode4Dev/GM4_Datapacks/blob/{commit_hash}/{mc_version_dir}/{file_name}?raw=true", + "resourcepack": "" + } + } + }) + if res.status_code == 401: + logger.critical(f"Authentication error, cancelling publish. Check token validity!: {res.status_code} {res.text}") + ctx.require(mark_gh_action_failed) + return + elif not (200 <= res.status_code < 300): + logger.warning(f"Failed to permalink {project_id} version {prior_version_in_mc_version}: {res.status_code} {res.text}") + else: + logger.info(f"Permalinked {project_id} {prior_version_in_mc_version} to git history: {res.text}", extra={"gh_annotate_skip": True}) + + if len(config.minecraft) > 0: + # supported versions is not empty, post new version + res = requests.post(f"{SMITHED_API}/packs/{config.smithed.pack_id}/versions", + params={'token': auth_token, 'version': version}, + json={"data":{ + "downloads":{ + "datapack": f"https://raw.githubusercontent.com/Gamemode4Dev/GM4_Datapacks/release/{mc_version_dir}/{file_name}", + "resourcepack": "" + }, + "name": version, + "supports": config.minecraft, + "dependencies": [] + }} + ) + if res.status_code == 401: + logger.critical(f"Authentication error, cancelling publish. Check token validity!: {res.status_code} {res.text}") + ctx.require(mark_gh_action_failed) + return + elif not (200 <= res.status_code < 300): + logger.warning(f"Failed to publish new version of {ctx.project_name}: {res.status_code} {res.text}") + return + logger.info(f"{ctx.project_name} {res.text}", extra={"gh_annotate_skip": True})