Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 37 additions & 11 deletions docs/HEXIUM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions docs/VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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,
Expand Down
50 changes: 33 additions & 17 deletions scripts/publish_hexium.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -158,15 +159,20 @@ 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)
if tag == 'div':
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':
Expand All @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions scripts/publish_nexusmods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions scripts/release_assets.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading