diff --git a/.codex/config.toml b/.codex/config.toml index f6bb587..d05b32b 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -13,6 +13,7 @@ enabled = true [permissions.github-workspace.network.domains] "api.github.com" = "allow" "github.com" = "allow" +"valheim.hexium.gg" = "allow" # GitHub raw files, source archives, and release downloads. "raw.githubusercontent.com" = "allow" diff --git a/docs/HEXIUM.md b/docs/HEXIUM.md index ea27bcb..7042ea4 100644 --- a/docs/HEXIUM.md +++ b/docs/HEXIUM.md @@ -11,9 +11,10 @@ Guild Chest 1.1.0 is now public on [Hexium](https://valheim.hexium.gg/mods/randenko/GuildChest). Its downloadable archive passes content verification; both DLLs have assembly and file version 1.1.0.0. The categories and Client & Server requirement are complete. Hexium -currently reports **Virus scan —**; the workflow remains incomplete until the -requested version shows **Scan successful**. Clean Gale profile and gameplay -checks remain manual. See the [publication record](VALIDATION.md). +currently reports **Virus scan —**. An unavailable scan is reported as a pending +follow-up; verified publication succeeds without waiting for it. Clean Gale +profile and gameplay checks remain manual. See the +[publication record](VALIDATION.md). ## Configuration and first publication @@ -32,6 +33,14 @@ authorization were established during the 1.1.0 publication. Keep credentials in environment variables or GitHub Secrets, never arguments, committed files, or chat messages. +Hexium also has a **team API upload visibility** setting. The owner supplied +Hexium's launch inbox notice confirming that API uploads are hidden by default +pending review unless the team turns that behavior off. The owner confirmed +changing team `randenko` to publish API uploads immediately. This setting +applies to the team's API uploads; GitHub's `HEXIUM_PUBLISH_ENABLED` repository +variable controls whether this workflow uploads at all. Manual preflight +remains read-only even when that variable is `true`. + 1. Review and merge the workflow and helper into `main` after CI passes. 2. In **Actions → Hexium → Run workflow**, select `main`, enter **v1.1.0**, and leave **Validate without uploading** enabled. Preflight works while the @@ -40,11 +49,14 @@ committed files, or chat messages. Thunderstore and Nexus Mods switches at their existing values. 4. Manually dispatch **v1.1.0** again with **Validate without uploading** disabled. Its earlier GitHub release event will not replay automatically. - The launch API upload created a **private** listing that returned 404 from - the public package API. If this happens, open the existing listing and choose + Hexium's default team API upload visibility setting made the launch listing + **private**, returning 404 from the public package API. If this happens, open + the existing listing and choose **⚙ → Make public**, then retry the workflow to complete metadata. Do not upload the archive again while that listing is private. Hexium's documented - API does not expose a visibility control. + API does not expose a visibility control. The owner subsequently changed the + team setting to publish API uploads immediately, removing this manual + visibility step from the configured future release process. 5. Check the public listing, four screenshots, changelog, Jötunn dependency, client/server requirement, and version scan. Install using a clean Gale profile; verify BepInEx and Jötunn resolution, both Guild Chest DLLs at @@ -87,20 +99,34 @@ After archive identity is established, the helper uses the to `both` and complete **Building**, **Storage**, **Quality of Life**, and **Open Source** categories. It verifies both the response and the approved public listing's categories, including **Client & Server**. Protected Hexium -categories can remain present. It also requires the requested version's public -**Scan successful** badge; a scan on another version does not satisfy this check. +categories can remain present. + +Virus scanning is reported separately after content and metadata verification. +The helper reads the requested version's public scan status once. **Scan +successful** is reported as successful; a missing or pending result, an +unavailable versions page, or an error reading it (including a truncated HTTP +response) is reported as **pending** with a link for follow-up. These conditions +do not wait for a scan or fail an otherwise verified publication. An explicit +**detections reported** badge still +fails the run for review. A badge on another version does not affect the +requested version's result. Existing matching versions skip upload and complete unfinished metadata. CLI errors and timeouts trigger reconciliation because Hexium may have accepted -the upload before its response was lost. Public visibility, downloads, metadata, -and scans are polled for up to five minutes. An unresolved result fails as +the upload before its response was lost. Public visibility, downloads, and +metadata are polled for up to five minutes. Unresolved publication fails as incomplete; wait for visibility, then retry the same release tag. A known inactive or not-yet-downloadable version never permits another upload in the same run. Non-404 API errors stop rather than being treated as absence. Metadata writes set fixed values and can be retried safely after a lost response. The Actions summary reports the listing URL, source SHA-256, downloaded SHA-256, -and content-verification result, including incomplete reconciliation results. +content-verification result, and separate virus-scan status, including incomplete +reconciliation results. Preflight and incomplete publication report a scan as +**not checked**. A pending scan needs follow-up on Hexium's versions page; +re-uploading is unnecessary. Archive conflicts, missing dependencies, and +incomplete or conflicting metadata still fail the run. + No publishing token is sent on reads, downloads, or redirects. The GitHub token is restricted to its API origin. tcli receives only the Hexium token through `TCLI_AUTH_TOKEN` and selected runtime environment settings. The metadata token diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 0d0320e..9ebb8df 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -23,6 +23,13 @@ compatibility; runtime behavior on newer builds needs separate verification. listing. Public API verification returned 404 for five minutes and correctly remained incomplete. The owner made the existing listing public through its gear menu. No archive was uploaded again. +- The owner subsequently supplied Hexium's inbox notice explaining the cause: + API uploads are hidden by default pending review unless the team disables + that behavior in its **API upload visibility** setting. The owner confirmed + changing that setting to publish API uploads immediately. GitHub's + `HEXIUM_PUBLISH_ENABLED` repository variable remains `true` and controls + whether the workflow uploads. The notice explains the initial privacy state; + it provides no explanation for the absent virus scan result. - [Guild Chest 1.1.0](https://valheim.hexium.gg/mods/randenko/GuildChest) is publicly available. The downloaded ZIP has SHA-256 `b1138d63a7d4d64f1301a2c92ded5a608831969370071c394043ef877713aef6`. @@ -33,8 +40,22 @@ compatibility; runtime behavior on newer builds needs separate verification. recognized the matching existing version and completed Building, Storage, Quality of Life, Open Source, and Client & Server metadata. The public page includes all four release-specific screenshots, the changelog, and Jötunn. - Static analysis reports 10 files analyzed. **Virus scan —** remains visible; - the required successful virus-scan result has not been established. + Static analysis reports 10 files analyzed. That run failed after waiting five + minutes for a **Scan successful** badge while Hexium displayed **Virus scan —**. + A successful virus-scan result has not been established. +- Publication now reports unavailable scans as a separate pending follow-up + after archive and metadata verification. It checks the requested version once + without polling for a scan result, including when that page cannot be read. + An explicit virus-detections badge still fails the run for review. Archive, + dependency, and metadata failures retain their existing checks and retries. + Truncated-response regressions cover the scan reader, shared release + downloader, and both authenticated metadata clients. Required reads still + fail, and lost metadata responses require reconciliation without repeating + writes or disclosing response contents. + The updated NUKE Test target passes 15 C# and 162 Python tests; actionlint + passes. A live read-only reconciliation of v1.1.0 succeeds with content and + metadata verified and the virus scan reported as pending. That check explicitly + disables upload, metadata writes, and polling waits. - Regression tests cover the shared release checks, disabled publishing, GET-only preflight, exact outgoing ZIP bytes, manifest normalization, changed DLLs and other files, invalid categories/dependencies, credential isolation, diff --git a/scripts/publish_hexium.py b/scripts/publish_hexium.py index f5b779a..b3b3570 100644 --- a/scripts/publish_hexium.py +++ b/scripts/publish_hexium.py @@ -2,6 +2,7 @@ """Preflight or publish the original GitHub release ZIP to Hexium.""" import argparse import hashlib +from http.client import HTTPException from html.parser import HTMLParser import io import json @@ -11,7 +12,7 @@ import subprocess import tempfile import time -from urllib.error import HTTPError, URLError +from urllib.error import HTTPError from urllib.parse import urljoin, urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener import zipfile @@ -53,7 +54,7 @@ def update(self, namespace, name): return json.loads(response.read()) except HTTPError as error: raise ValueError(f'Hexium metadata update failed with HTTP {error.code}; retry this release tag.') from error - except URLError as error: + except (HTTPException, OSError) as error: raise ValueError('Hexium metadata response was lost; retry this release tag to reconcile.') from error @@ -158,6 +159,7 @@ def __init__(self, version): self.depth = 0 self.target_depth = None self.clean = False + self.detected = False def handle_starttag(self, tag, attrs): attrs = dict(attrs) @@ -165,8 +167,12 @@ def handle_starttag(self, tag, attrs): self.depth += 1 if attrs.get('id') == f'v-{self.version}' and 'version-entry' in attrs.get('class', '').split(): self.target_depth = self.depth - if self.target_depth is not None and 'status-badge--vt-clean' in attrs.get('class', '').split(): - self.clean = True + if self.target_depth is not None: + classes = attrs.get('class', '').split() + if 'status-badge--vt-clean' in classes: + self.clean = True + if 'status-badge--vt-detected' in classes: + self.detected = True def handle_endtag(self, tag): if tag == 'div': @@ -175,13 +181,23 @@ def handle_endtag(self, tag): self.depth -= 1 -def scan_complete(http, listing, version): - page = http.get(listing + '/versions', optional=True) - if page is None: - raise Pending('Hexium versions page is not visible yet.') - parser = VersionScan(version) - parser.feed(page.decode('utf-8')) - return parser.clean +def scan_status(http, listing, version): + follow_up = f'{listing}/versions#v-{version}' + try: + page = http.get(listing + '/versions', optional=True) + if page is None: + return f'pending (status unavailable); follow up at {follow_up}' + parser = VersionScan(version) + parser.feed(page.decode('utf-8')) + except (ValueError, OSError, HTTPException): + # Scan reporting is separate from the verified publication. A missing + # page, unreadable response, or transport error cannot undo that result. + return f'pending (status unavailable); follow up at {follow_up}' + if parser.detected: + raise ValueError(f'Hexium reports virus detections for {version}; review {follow_up}.') + if parser.clean: + return 'successful' + return f'pending (no successful result available); follow up at {follow_up}' def configuration(namespace, manifest): @@ -201,11 +217,12 @@ def configuration(namespace, manifest): return '\n'.join(lines) -def report(status, release, listing, downloaded=None): +def report(status, release, listing, downloaded=None, *, scan='not checked'): result = 'passed (all other files identical; manifest normalization allowed)' if downloaded else 'pending' return (f'{status}: {release.tag}\n\nListing: {listing}\n\n' f'Source SHA-256: {release.sha256}\n\n' - f'Downloaded SHA-256: {downloaded or "not available"}\n\nContent verification: {result}.') + f'Downloaded SHA-256: {downloaded or "not available"}\n\nContent verification: {result}.\n\n' + f'Virus scan: {scan}.') def publish(tag, *, repository=ROOT, namespace='', token='', http=None, metadata=None, @@ -273,10 +290,9 @@ def publish(tag, *, repository=ROOT, namespace='', token='', http=None, metadata complete = public_metadata(http, namespace, release.manifest) if not complete: raise Pending('Hexium listing metadata is not complete yet.') - if not scan_complete(http, listing, release.version): - raise Pending('Hexium version scan is not successful yet.') - status = 'Published and verified' if submitted else 'Already published; metadata and scan verified' - return report(status, release, listing, downloaded) + scan = scan_status(http, listing, release.version) + status = 'Published and verified' if submitted else 'Already published; content and metadata verified' + return report(status, release, listing, downloaded, scan=scan) except Pending as error: pending = str(error) remaining = deadline - now() diff --git a/scripts/publish_nexusmods.py b/scripts/publish_nexusmods.py index 53d0a42..4b5db1e 100644 --- a/scripts/publish_nexusmods.py +++ b/scripts/publish_nexusmods.py @@ -3,12 +3,13 @@ import argparse from dataclasses import asdict, dataclass import html +from http.client import HTTPException import json import os from pathlib import Path import re import time -from urllib.error import HTTPError, URLError +from urllib.error import HTTPError from urllib.request import HTTPRedirectHandler, Request, build_opener from package_format import LINK, REPOSITORY, VERSION, release_links @@ -52,7 +53,7 @@ def request(self, path, *, method='GET', body=None): except HTTPError as error: # Do not print response bodies, keys, request headers, or signed URLs. raise ValueError(f'Nexus {method} {path} failed with HTTP {error.code}.') from error - except (URLError, TimeoutError) as error: + except (HTTPException, OSError) as error: raise ValueError(f'Nexus {method} {path} failed; reconcile remote state before retrying writes.') from error def query(self, query, variables=None): diff --git a/scripts/release_assets.py b/scripts/release_assets.py index fc8568a..0a7d4c8 100644 --- a/scripts/release_assets.py +++ b/scripts/release_assets.py @@ -1,6 +1,7 @@ """Read and validate the exact published GitHub assets shared by the publishers.""" from dataclasses import dataclass import hashlib +from http.client import HTTPException import io import json from pathlib import Path @@ -55,6 +56,9 @@ def get(self, url, *, optional=False, binary=False): raise ValueError(f'GET {url} failed with HTTP {error.code}; retry after resolving the error.') from error except URLError as error: raise ValueError(f'GET {url} failed: {error.reason}; retry after resolving the error.') from error + except (HTTPException, OSError) as error: + raise ValueError(f'GET {url} failed while receiving the response ({type(error).__name__}); ' + 'retry after resolving the error.') from error def json(self, url, *, optional=False): data = self.get(url, optional=optional) diff --git a/tests/test_hexium.py b/tests/test_hexium.py index 57f8151..4d28351 100644 --- a/tests/test_hexium.py +++ b/tests/test_hexium.py @@ -1,5 +1,6 @@ """Exercise Hexium publication without credentials or external writes.""" import hashlib +from http.client import HTTPException, IncompleteRead import io import json import os @@ -9,7 +10,7 @@ import tomllib import unittest from unittest.mock import patch -from urllib.error import HTTPError +from urllib.error import HTTPError, URLError from urllib.request import Request import zipfile @@ -162,6 +163,7 @@ def test_upload_uses_original_zip_tag_metadata_and_isolated_environment(self): self.assertIn('Published and verified', message) self.assertIn('Downloaded SHA-256: ' + hashlib.sha256(self.contents).hexdigest(), message) self.assertIn('Content verification: passed', message) + self.assertIn('Virus scan: successful', message) self.assertEqual(len(self.uploads), 1) upload = self.uploads[0] self.assertEqual(upload['archive'], self.contents) @@ -318,9 +320,12 @@ def test_delayed_publication_and_download_visibility_are_polled(self): self.http.responses[self.download_url] = [None, self.contents] self.http.responses[self.package_url] = [None, self.public()] self.http.responses[self.listing + '/versions'] = [self.scan(status='pending'), self.scan()] - self.assertIn('Published and verified', self.run_publish(preflight=False)) + message = self.run_publish(preflight=False) + self.assertIn('Published and verified', message) + self.assertIn('Virus scan: pending', message) self.assertEqual(len(self.uploads), 1) self.assertGreaterEqual(len(self.sleeps), 3) + self.assertEqual(self.http.calls.count(self.listing + '/versions'), 1) def test_known_inactive_version_is_polled_without_duplicate_upload(self): self.http.responses[self.version_url] = [{**self.existing(), 'is_active': False}, self.existing()] @@ -328,7 +333,7 @@ def test_known_inactive_version_is_polled_without_duplicate_upload(self): self.assertIn('Already published', self.run_publish(preflight=False)) self.assertFalse(self.uploads) - def test_unconfirmed_publication_and_pending_scan_require_later_retry(self): + def test_unconfirmed_publication_requires_retry_but_pending_scan_does_not(self): with self.assertRaisesRegex(hexium.Pending, 'publication incomplete.*Retry this release tag'): self.run_publish(preflight=False) self.assertEqual(sum(self.sleeps), 300) @@ -336,18 +341,78 @@ def test_unconfirmed_publication_and_pending_scan_require_later_retry(self): self.ready() self.http.responses[self.listing + '/versions'] = self.scan(status='pending') self.sleeps.clear() - with self.assertRaisesRegex(hexium.Pending, 'scan is not successful'): - self.run_publish(preflight=False) - self.assertEqual(sum(self.sleeps), 300) + message = self.run_publish(preflight=False) + self.assertIn('Already published; content and metadata verified', message) + self.assertIn('Virus scan: pending', message) + self.assertFalse(self.sleeps) self.http.responses[self.listing + '/versions'] = self.scan() - self.assertIn('Already published', self.run_publish(preflight=False)) + self.assertIn('Virus scan: successful', self.run_publish(preflight=False)) self.assertEqual(len(self.uploads), 1) - def test_scan_for_another_version_cannot_complete_publication(self): + def test_successful_scan_for_another_version_does_not_mark_requested_scan_successful(self): self.ready() self.http.responses[self.listing + '/versions'] = self.scan(version='1.2.4') + self.scan(status='pending') - with self.assertRaisesRegex(hexium.Pending, 'scan is not successful'): - self.run_publish(preflight=False) + message = self.run_publish(preflight=False) + self.assertIn('Already published', message) + self.assertIn('Virus scan: pending', message) + self.assertFalse(self.sleeps) + self.assertFalse(self.uploads) + + def test_missing_or_pending_scan_is_reported_without_waiting(self): + self.ready() + for page in (None, b'Virus scan: —', self.scan(status='pending'), + self.scan(version='1.2.4'), b'\xff'): + with self.subTest(page=page): + self.http.responses[self.listing + '/versions'] = page + self.http.calls.clear() + message = self.run_publish(preflight=False) + self.assertIn('Already published; content and metadata verified', message) + self.assertIn('Content verification: passed', message) + self.assertIn('Virus scan: pending', message) + self.assertIn(self.listing + '/versions#v-1.2.3', message) + self.assertEqual(self.http.calls.count(self.listing + '/versions'), 1) + self.assertFalse(self.sleeps) + self.assertFalse(self.uploads) + self.assertFalse(self.metadata.calls) + + def test_new_upload_succeeds_with_pending_scan_without_waiting(self): + self.http.responses[self.version_url] = [None, self.existing()] + self.http.responses[self.download_url] = self.contents + self.http.responses[self.listing + '/versions'] = self.scan(status='pending') + message = self.run_publish(preflight=False) + self.assertIn('Published and verified', message) + self.assertIn('Content verification: passed', message) + self.assertIn('Virus scan: pending', message) + self.assertEqual(len(self.uploads), 1) + self.assertEqual(len(self.metadata.calls), 1) + self.assertFalse(self.sleeps) + + def test_scan_request_errors_do_not_fail_verified_publication(self): + self.ready() + for error in (ValueError('GET failed with HTTP 500'), TimeoutError('Scan request timed out'), + IncompleteRead(self.scan(), 100), HTTPException('Broken scan response')): + with self.subTest(error=error): + self.http.responses[self.listing + '/versions'] = error + self.http.calls.clear() + message = self.run_publish(preflight=False) + self.assertIn('Already published; content and metadata verified', message) + self.assertIn('Virus scan: pending (status unavailable)', message) + self.assertEqual(self.http.calls.count(self.listing + '/versions'), 1) + self.assertFalse(self.sleeps) + self.assertFalse(self.uploads) + self.assertFalse(self.metadata.calls) + + def test_reported_virus_detections_still_fail_publication(self): + self.ready() + for page in (self.scan(status='detected'), self.scan() + self.scan(status='detected')): + self.http.responses[self.listing + '/versions'] = page + self.assert_rejected('Hexium reports virus detections for 1.2.3') + self.assertFalse(self.sleeps) + + def test_virus_detections_for_another_version_do_not_affect_requested_scan(self): + self.ready() + self.http.responses[self.listing + '/versions'] = self.scan(version='1.2.4', status='detected') + self.scan() + self.assertIn('Virus scan: successful', self.run_publish(preflight=False)) self.assertFalse(self.uploads) def test_non_404_lookup_errors_never_allow_upload(self): @@ -356,7 +421,7 @@ def test_non_404_lookup_errors_never_allow_upload(self): def test_reconciliation_deadline_accounts_for_time_spent_on_requests(self): self.ready() - self.http.responses[self.listing + '/versions'] = self.scan(status='pending') + self.http.responses[self.package_url] = None ticks = iter((0, 295, 305)) with self.assertRaisesRegex(hexium.Pending, 'publication incomplete'): self.run_publish(preflight=False, now=lambda: next(ticks)) @@ -375,6 +440,18 @@ def test_cli_defaults_to_preflight_and_passes_upload_switch(self): class HexiumHttpTests(unittest.TestCase): + def test_metadata_response_read_errors_require_reconciliation(self): + metadata = hexium.MetadataHttp('private-fixture') + for error in (IncompleteRead(b'private-fixture', 100), HTTPException('private-fixture'), + TimeoutError('private-fixture'), URLError('private-fixture')): + with self.subTest(error=error), patch.object(metadata.opener, 'open') as open_url: + open_url.return_value.__enter__.return_value.read.side_effect = error + with self.assertRaisesRegex(ValueError, 'response was lost; retry this release tag to reconcile') as caught: + metadata.update('randenko', 'GuildChest') + self.assertNotIn('private-fixture', str(caught.exception)) + self.assertIs(caught.exception.__cause__, error) + self.assertEqual(open_url.call_count, 1, 'A lost write response must not resubmit automatically.') + def test_metadata_write_is_fixed_origin_and_token_only_in_header(self): metadata = hexium.MetadataHttp('private-fixture') with patch.object(metadata.opener, 'open') as open_url: diff --git a/tests/test_nexusmods.py b/tests/test_nexusmods.py index adddef7..3551e24 100644 --- a/tests/test_nexusmods.py +++ b/tests/test_nexusmods.py @@ -1,6 +1,7 @@ """Exercise Nexus publication and recovery without making remote writes.""" import copy import hashlib +from http.client import HTTPException, IncompleteRead import json import os from pathlib import Path @@ -558,6 +559,20 @@ def test_cli_rejects_baseline_prepare_before_loading_or_writing(self): class TransportTests(unittest.TestCase): + def test_response_read_errors_require_reconciliation_without_disclosing_contents(self): + api = nexus.NexusHttp('private-fixture') + for method in ('GET', 'POST', 'PUT'): + for error in (IncompleteRead(b'private-fixture', 100), HTTPException('private-fixture'), + TimeoutError('private-fixture'), URLError('private-fixture')): + with self.subTest(method=method, error=error), patch.object(api.opener, 'open') as request: + request.return_value.__enter__.return_value.read.side_effect = error + with self.assertRaisesRegex(ValueError, 'reconcile remote state before retrying writes') as caught: + api.request('/v3/mods/123/files', method=method, + body=None if method == 'GET' else {'value': 'fixture'}) + self.assertNotIn('private-fixture', str(caught.exception)) + self.assertIs(caught.exception.__cause__, error) + self.assertEqual(request.call_count, 1, 'A lost response must not repeat a write automatically.') + def test_nexus_key_never_leaves_fixed_api_origin_and_redirects_are_rejected(self): api = nexus.NexusHttp('private-key') with patch.object(api.opener, 'open') as request: diff --git a/tests/test_thunderstore.py b/tests/test_thunderstore.py index 22e7890..b29a6ef 100644 --- a/tests/test_thunderstore.py +++ b/tests/test_thunderstore.py @@ -1,6 +1,7 @@ """Validate the publisher with disposable tags, mocked GETs, and a recording CLI.""" import copy import hashlib +from http.client import HTTPException, IncompleteRead import io import json import os @@ -355,6 +356,21 @@ def test_absolute_links_to_other_releases_are_rejected(self): class HttpTests(unittest.TestCase): + def test_response_read_errors_fail_required_and_optional_downloads(self): + http = Http('private-fixture-token') + for error in (IncompleteRead(b'private-response', 100), HTTPException('private-response'), + TimeoutError('private-response')): + for optional in (False, True): + for binary in (False, True): + with self.subTest(error=error, optional=optional, binary=binary), \ + patch.object(http.opener, 'open') as open_url: + open_url.return_value.__enter__.return_value.read.side_effect = error + with self.assertRaisesRegex(ValueError, 'failed while receiving the response.*retry') as caught: + http.get('https://cdn.example.invalid/package', optional=optional, binary=binary) + self.assertNotIn('private-response', str(caught.exception)) + self.assertIs(caught.exception.__cause__, error) + self.assertEqual(open_url.call_count, 1) + def test_publication_metadata_checks_do_not_reuse_cached_absence(self): http = Http() url = 'https://thunderstore.io/api/experimental/package/Paper_Street_Software/GuildChest/1.1.0/'