diff --git a/.github/ISSUE_TEMPLATE/add-dll-release.md b/.github/ISSUE_TEMPLATE/add-dll-release.md new file mode 100644 index 0000000..05a3391 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/add-dll-release.md @@ -0,0 +1,16 @@ +--- +name: Add DLL Release to Registry +about: Request review of a native plugin release (separate from pak mods) +title: 'Add DLL release: ' +--- + +The release must have a semantic version tag. +Native code is not sandboxed; each version requires approval in a pull request. + +```json +{ + "action": "add-dll-release", + "repo_url": "https://github.com/Organization/PluginName", + "release_tag": "v1.0.0" +} +``` diff --git a/.github/actions/registry/README.md b/.github/actions/registry/README.md index c6c5fa2..c2004fa 100644 --- a/.github/actions/registry/README.md +++ b/.github/actions/registry/README.md @@ -1,9 +1,30 @@ # Registry integration -Uses `zero-infra-mod-registry` at `5577c4c3f5870f192c4929eaecc11ae6d7becf77` with `upstream.patch` applied before building. The patch preserves newline-delimited indexes and propagates validation/processing failures; the image cache key includes the patch hash. +Builds `zero-infra-mod-registry` directly from the immutable `REVISION` pinned in +`action.yml`. No source patches are maintained or applied here. Registry logic, +including DLL support and the index/error-handling fixes, lives upstream. + +DLL support extends the upstream CLI, `FilesystemPackageRegistry`, and GitHub +retriever, not a separate script. `add-dll-release REPO_URL TAG` +writes the separate `dll_db/index.json`; `validate` checks its schema and +`validate --verify-dll-downloads` checks downloaded bytes. The composite action +exposes that option as `verify_dll_downloads: 'true'` (the default). DLL issue +processing explicitly requires download verification before committing registry +changes or opening a PR; issue payloads cannot disable it. + +For releases with multiple DLL assets, set `dll_file: 'Plugin.dll'` to select an +exact, case-sensitive filename. The action passes it as `--dll-file`; omission +requires exactly one DLL asset. Issue requests use the same `dll_file` field. +Only the selected DLL is registered, with no fallback on missing matches. Run the deterministic Docker/CLI regression suite with: ```sh python3 -m unittest discover -s tests -v ``` + +Before an upstream dependency is published, you can test a local source checkout +at the exact pinned commit with `REGISTRY_TEST_SOURCE=/path/to/checkout` set for +that command. The production workflow still fetches upstream; it does not use +local source overrides. Merge the upstream changes before submitting the +C2ModRegistry PR, then update the pin to the published upstream commit as needed. diff --git a/.github/actions/registry/action.yml b/.github/actions/registry/action.yml index e117619..44a84c5 100644 --- a/.github/actions/registry/action.yml +++ b/.github/actions/registry/action.yml @@ -16,6 +16,12 @@ inputs: log_level: default: INFO description: Logging level + verify_dll_downloads: + default: 'true' + description: Download DLLs and verify indexed hash and size during validation + dll_file: + default: '' + description: Exact DLL asset filename for add-dll-release outputs: failed: description: Whether the command failed @@ -35,29 +41,32 @@ runs: REPO_URL: ${{ inputs.repo_url }} RELEASE_TAG: ${{ inputs.release_tag }} LOG_LEVEL: ${{ inputs.log_level }} - REGISTRY_ACTION_PATH: ${{ github.action_path }} + VERIFY_DLL_DOWNLOADS: ${{ inputs.verify_dll_downloads }} + DLL_FILE: ${{ inputs.dll_file }} run: | SOURCE="$RUNNER_TEMP/zero-infra-mod-registry" - REVISION=5577c4c3f5870f192c4929eaecc11ae6d7becf77 - PATCH="$REGISTRY_ACTION_PATH/upstream.patch" - PATCH_HASH=$(sha256sum "$PATCH" | cut -d ' ' -f 1) - IMAGE="zero-infra-mod-registry:$REVISION-$PATCH_HASH" + REVISION=fc1ed0e7787adf5913b47c7c2160c004e4da8dd7 + IMAGE="zero-infra-mod-registry:$REVISION" if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then git init "$SOURCE" git -C "$SOURCE" fetch --depth=1 https://github.com/Chiv2-Community/zero-infra-mod-registry.git "$REVISION" git -C "$SOURCE" checkout --detach FETCH_HEAD - git -C "$SOURCE" apply --check "$PATCH" - git -C "$SOURCE" apply "$PATCH" docker build --tag "$IMAGE" "$SOURCE" fi ARGS=("$COMMAND") + if [[ "$COMMAND" == "validate" && "${VERIFY_DLL_DOWNLOADS:-true}" == "true" ]]; then + ARGS+=("--verify-dll-downloads") + fi if [[ -n "$REPO_URL" ]]; then ARGS+=("$REPO_URL") fi if [[ -n "$RELEASE_TAG" ]]; then ARGS+=("$RELEASE_TAG") fi + if [[ "$COMMAND" == "add-dll-release" && -n "${DLL_FILE:-}" ]]; then + ARGS+=("--dll-file" "$DLL_FILE") + fi RESULT=$(mktemp "$RUNNER_TEMP/registry-result.XXXXXX") set +e diff --git a/.github/actions/registry/upstream.patch b/.github/actions/registry/upstream.patch deleted file mode 100644 index a3ce88d..0000000 --- a/.github/actions/registry/upstream.patch +++ /dev/null @@ -1,87 +0,0 @@ -diff --git a/src/zero_infra_mod_registry/main.py b/src/zero_infra_mod_registry/main.py ---- a/src/zero_infra_mod_registry/main.py -+++ b/src/zero_infra_mod_registry/main.py -@@ -101,7 +101,8 @@ def main() -> None: - ) - - if args.command == "process-registry-updates": -- registry.process_registry_updates(args.dry_run) -+ if not registry.process_registry_updates(args.dry_run): -+ exit(1) - elif args.command == "add-package": - [org, repoName] = args.repo_url.strip().split("/")[-2:] - num_added = registry.add_package([Repo(org, repoName)], args.dry_run) -@@ -121,7 +122,8 @@ def main() -> None: - [org, repoName] = args.repo_url.strip().split("/")[-2:] - registry.remove_mods([Repo(org, repoName)], args.dry_run) - elif args.command == "validate": -- registry.validate_package_db([]) -+ if not registry.validate_package_db([]): -+ exit(1) - logging.info("Validation complete!") - else: - logging.error("Unknown command.") -diff --git a/src/zero_infra_mod_registry/registry/filesystem_package_registry.py b/src/zero_infra_mod_registry/registry/filesystem_package_registry.py ---- a/src/zero_infra_mod_registry/registry/filesystem_package_registry.py -+++ b/src/zero_infra_mod_registry/registry/filesystem_package_registry.py -@@ -73,6 +73,16 @@ class FilesystemPackageRegistry(PackageRegistry): - # Load redirect manager - self.redirect_manager = SimpleRedirectManager.from_file(self.redirects_path) - -+ @staticmethod -+ def _append_index_entry(path: str, entry: str) -> None: -+ # Existing indexes may have an unterminated final line. -+ with open(path, "a+b") as file: -+ if file.tell() > 0: -+ file.seek(-1, os.SEEK_END) -+ if file.read(1) != b"\n": -+ file.write(b"\n") -+ file.write((entry + "\n").encode("utf-8")) -+ - def _load_package_list(self, path: str) -> List[str]: - """ - Load a package list from a file. -@@ -188,7 +198,8 @@ class FilesystemPackageRegistry(PackageRegistry): - try: - split_entries = [entry.split("/") for entry in new_entries] - repo_entries = [Repo(entry[0], entry[1]) for entry in split_entries] -- self.add_package(repo_entries, dry_run) -+ if self.add_package(repo_entries, dry_run) != len(repo_entries): -+ failed = True - except Exception as e: - # If we fail to initialize a repo, remove it from the package list - logging.error(f"Failed to initialize repos: {e}\n") -@@ -252,7 +263,8 @@ class FilesystemPackageRegistry(PackageRegistry): - ) - return 0 # Return 0 for failure - -- self.validate_package_db(filtered_mods) -+ if not self.validate_package_db(filtered_mods): -+ return 0 - - new_maps = [] - new_blueprints = [] -@@ -385,11 +397,8 @@ class FilesystemPackageRegistry(PackageRegistry): - - if not found: - # Write the repository URL to the file -- with open(registry_org_index, "a") as f: -- f.write(f"{repo_url}") -- -- with open(self.mod_list_index_path, "a") as f: -- f.write(f"{index_entry}") -+ self._append_index_entry(registry_org_index, repo_url) -+ self._append_index_entry(self.mod_list_index_path, index_entry) - - logging.info( - f"Added {index_entry} to the registry index at {registry_org_index}" -@@ -518,7 +527,8 @@ class FilesystemPackageRegistry(PackageRegistry): - - updated_mod = self.mod_retriever.update_mod_with_release(mod, release) - -- self.validate_package_db([updated_mod]) -+ if not self.validate_package_db([updated_mod]): -+ return False - - if dry_run: - logging.warning("Dry run; not writing to mod metadata.") diff --git a/.github/workflows/add-release-from-issue.yml b/.github/workflows/add-release-from-issue.yml index 5d7407a..fabdb55 100644 --- a/.github/workflows/add-release-from-issue.yml +++ b/.github/workflows/add-release-from-issue.yml @@ -84,11 +84,19 @@ jobs: - name: Validate JSON payload id: validate_payload if: steps.issue_json_payload.outputs.failed == 'false' + env: + PAYLOAD: ${{ steps.issue_json_payload.outputs.payload }} run: | - PAYLOAD='${{ steps.issue_json_payload.outputs.payload }}' - ACTION=$(echo $PAYLOAD | jq -r '.action') - if [[ "$ACTION" != "add-package" && "$ACTION" != "add-package-release" ]]; then - echo "Invalid action: $ACTION. Must be either 'add-package' or 'add-package-release'" >> $GITHUB_STEP_SUMMARY + ACTION=$(jq -r '.action' <<< "$PAYLOAD") + if [[ "$ACTION" == "add-dll-release" ]]; then + if ! jq -e 'if has("dll_file") then (.dll_file | type == "string" and length > 0) else true end' <<< "$PAYLOAD" >/dev/null; then + echo "dll_file must be a nonempty string when supplied" >> "$GITHUB_STEP_SUMMARY" + echo "failed=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + fi + if [[ "$ACTION" != "add-package" && "$ACTION" != "add-package-release" && "$ACTION" != "add-dll-release" ]]; then + echo "Invalid action: $ACTION. Must be 'add-package', 'add-package-release' or 'add-dll-release'" >> $GITHUB_STEP_SUMMARY echo "failed=true" >> $GITHUB_OUTPUT exit 1 else @@ -101,7 +109,7 @@ jobs: with: issue-number: ${{ github.event.issue.number }} body: | - Failed to validate the JSON in the issue body. The `action` field must be either `add-package` or `add-package-release`. + Failed to validate the JSON in the issue body. The `action` field must be `add-package`, `add-package-release` or `add-dll-release`. For DLL requests, `dll_file` must be a nonempty string when supplied. Check out the [Unchained Discord](https://discord.gg/chiv2unchained) for support. @@ -124,8 +132,8 @@ jobs: - name: Generate branch name id: branch-name run: | - if [[ "${{ needs.parse-json.outputs.action }}" == "add-package" ]]; then - BRANCH_NAME="issue-${{ github.event.issue.number }}-add-package" + if [[ "${{ needs.parse-json.outputs.action }}" != "add-package-release" ]]; then + BRANCH_NAME="issue-${{ github.event.issue.number }}-${{ needs.parse-json.outputs.action }}" else BRANCH_NAME="main" fi @@ -137,7 +145,7 @@ jobs: ref: main - name: Create new branch for add-package - if: needs.parse-json.outputs.action == 'add-package' + if: needs.parse-json.outputs.action != 'add-package-release' run: | git checkout -b ${{ steps.branch-name.outputs.branch_name }} @@ -148,10 +156,34 @@ jobs: with: command: ${{ needs.parse-json.outputs.action }} repo_url: ${{ fromJson(needs.parse-json.outputs.json_payload).repo_url }} - release_tag: ${{ needs.parse-json.outputs.action == 'add-package-release' && fromJson(needs.parse-json.outputs.json_payload).release_tag || '' }} + release_tag: ${{ needs.parse-json.outputs.action != 'add-package' && fromJson(needs.parse-json.outputs.json_payload).release_tag || '' }} + dll_file: ${{ needs.parse-json.outputs.action == 'add-dll-release' && fromJson(needs.parse-json.outputs.json_payload).dll_file || '' }} + github_token: ${{ secrets.GITHUB_TOKEN }} + log_level: INFO + + - name: Validate DLL registry downloads before committing + id: validate-dll-release + if: steps.update-package-list.outputs.failed == 'false' && needs.parse-json.outputs.action == 'add-dll-release' + uses: ./.github/actions/registry + continue-on-error: true + with: + command: validate + verify_dll_downloads: 'true' github_token: ${{ secrets.GITHUB_TOKEN }} log_level: INFO + - name: Report DLL integrity validation failure + uses: peter-evans/create-or-update-comment@v4 + if: needs.parse-json.outputs.action == 'add-dll-release' && steps.validate-dll-release.outputs.failed == 'true' + with: + issue-number: ${{ github.event.issue.number }} + body: | + DLL download-integrity validation failed. No registry commit or pull request will be created. Resolve the failure, then reply **retry**. + + ``` + ${{ steps.validate-dll-release.outputs.result }} + ``` + - name: Post failure Results uses: peter-evans/create-or-update-comment@v4 if: steps.update-package-list.outputs.failed == 'true' @@ -167,9 +199,31 @@ jobs: Check out the [Unchained Discord](https://discord.gg/chiv2unchained) for support. + - name: Explain DLL asset selection after an ambiguous release + uses: peter-evans/create-or-update-comment@v4 + if: >- + steps.update-package-list.outputs.failed == 'true' && + needs.parse-json.outputs.action == 'add-dll-release' && + !fromJson(needs.parse-json.outputs.json_payload).dll_file && + contains(steps.update-package-list.outputs.result, 'A DLL release must contain exactly one DLL asset unless --dll-file selects one') + with: + issue-number: ${{ github.event.issue.number }} + body: | + This release contains multiple DLL files. Choose the one you want to register. + + Edit the JSON in this issue's description to add a `dll_file` field containing the **exact, case-sensitive asset filename**, for example: + + ```json + "dll_file": "UnchainedPlugin.dll" + ``` + + Add a comma after the preceding `release_tag` field so the JSON remains valid. Use the actual asset filename from your GitHub release, not necessarily the example above. Then reply **retry** on this issue. + + Only the chosen DLL will be downloaded and validated. No registry changes or pull request were created by this failed attempt. + - name: Commit changes id: commit-registry - if: steps.update-package-list.outputs.failed == 'false' + if: steps.update-package-list.outputs.failed == 'false' && (needs.parse-json.outputs.action != 'add-dll-release' || steps.validate-dll-release.outputs.failed == 'false') uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: | @@ -178,7 +232,7 @@ jobs: Issue: #${{ github.event.issue.number }} branch: ${{ steps.branch-name.outputs.branch_name }} add_options: '--all' - file_pattern: 'package_db/* registry/*' + file_pattern: 'package_db/* registry/* dll_db/*' # GITHUB_TOKEN pushes do not trigger the main push workflow. - name: Dispatch RC sync after a release commit @@ -193,19 +247,23 @@ jobs: run: bash .github/workflows/scripts/dispatch-rc-registry-sync.sh - name: Create pull request - if: steps.update-package-list.outputs.failed == 'false' && needs.parse-json.outputs.action == 'add-package' + if: steps.update-package-list.outputs.failed == 'false' && needs.parse-json.outputs.action != 'add-package-release' && (needs.parse-json.outputs.action != 'add-dll-release' || steps.validate-dll-release.outputs.failed == 'false') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_URL: ${{ fromJson(needs.parse-json.outputs.json_payload).repo_url }} + REGISTRY_ACTION: ${{ needs.parse-json.outputs.action }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + BRANCH_NAME: ${{ steps.branch-name.outputs.branch_name }} run: | - REPO_NAME=$(echo "${{ fromJson(needs.parse-json.outputs.json_payload).repo_url }}" | sed -E 's|.*github.com/([^/]+/[^/]+).*|\1|' | sed 's|.git$||') - PR_TITLE="Add package: $REPO_NAME" - PR_BODY=$"Adds package from ${{ fromJson(needs.parse-json.outputs.json_payload).repo_url }}, Resolves #${{ github.event.issue.number }}" + REPO_NAME=$(echo "$REPO_URL" | sed -E 's|.*github.com/([^/]+/[^/]+).*|\1|' | sed 's|.git$||') + PR_TITLE="$REGISTRY_ACTION: $REPO_NAME" + PR_BODY="Registers release/package from $REPO_URL, Resolves #$ISSUE_NUMBER" gh pr create \ --title "$PR_TITLE" \ --body "$PR_BODY" \ --base main \ - --head ${{ steps.branch-name.outputs.branch_name }} + --head "$BRANCH_NAME" - name: Post success results for add-package-release uses: peter-evans/create-or-update-comment@v4 @@ -220,7 +278,7 @@ jobs: - name: Post success results for add-package uses: peter-evans/create-or-update-comment@v4 - if: steps.update-package-list.outputs.failed == 'false' && needs.parse-json.outputs.action == 'add-package' + if: steps.update-package-list.outputs.failed == 'false' && needs.parse-json.outputs.action != 'add-package-release' && (needs.parse-json.outputs.action != 'add-dll-release' || steps.validate-dll-release.outputs.failed == 'false') with: issue-number: ${{ github.event.issue.number }} body: | diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 36874c3..f82c48d 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -35,6 +35,7 @@ jobs: uses: ./.github/actions/registry with: command: validate + verify_dll_downloads: 'true' github_token: ${{ secrets.GITHUB_TOKEN }} log_level: INFO @@ -44,4 +45,4 @@ jobs: with: branch: ${{ github.head_ref }} add_options: '--all' - file_pattern: 'package_db/* registry/*' + file_pattern: 'package_db/* registry/* dll_db/*' diff --git a/README.md b/README.md index f0aeea6..0593513 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # C2ModRegistry A registry for available chivalry 2 mods. People who create mods can register them here, so that they show up in the mod browser. ---- - ### Registering your mod ### Creating a Release: @@ -82,4 +80,31 @@ A registry for available chivalry 2 mods. People who create mods can register t **3. Troubleshooting:** - If the request fails, check the error posted on the issue. After correcting the problem, reply "retry". - - For any challenges or queries, consult with a community member. They'll likely be able to guide or assist you. + - For any challenges or queries, consult with a community member. They'll likely be able to guide or assist you. + +--- + +### Registering a DLL Plugin: + +Most mods use `.pak` files and should follow the steps above. This section is for advanced users publishing native DLL plugins. DLLs run native code; approval and hash checks do not make them safe. Only enable plugins from publishers you trust. + +**1. Creating a Release:** + - Create a GitHub release with a semantic version tag, such as `v1.0.0`. Prereleases such as `v1.0.0-RC2` are supported. + - Attach your `.dll` file. DLL plugins do not need a `mod.json` or a pak asset manifest. + +**2. Requesting Inclusion:** + - Create an issue using the "Add DLL Release to Registry" template: + ```json + { + "action": "add-dll-release", + "repo_url": "https://github.com/Username/Plugin-Name", + "release_tag": "v1.0.0" + } + ``` + - The registry downloads and verifies the DLL, then opens a pull request for approval. Every version requires approval, including updates. + - Approved entries go in `dll_db/index.json`, separate from pak mods, with their download URL, SHA-256, size, and version. Published versions cannot be replaced; create a new version instead. + +**3. Troubleshooting:** + - If the release has multiple DLLs, the bot will ask you to add `"dll_file": "Plugin-Name.dll"` to the issue JSON. Use the exact, case-sensitive asset filename, then reply "retry". + - For other failures, check the error posted on the issue. After correcting the problem, reply "retry". + - DLL download verification is required in issue processing and PR checks. Failed issue validation blocks commits and PR creation. diff --git a/dll_db/index.json b/dll_db/index.json new file mode 100644 index 0000000..e27c950 --- /dev/null +++ b/dll_db/index.json @@ -0,0 +1,10 @@ +[ + { + "organization": "Chiv2-Community", + "name": "UnchainedPlugin", + "version": "0.5.0", + "download_url": "https://github.com/Chiv2-Community/UnchainedPlugin/releases/download/v0.5.0/UnchainedPlugin.dll", + "sha256": "b2e852828ed0b67ae1d37bcb34c1558456b84c67653b139bae097b629b23cfea", + "size": 3646464 + } +] diff --git a/tests/test_dll_issue_request.py b/tests/test_dll_issue_request.py new file mode 100644 index 0000000..f686e63 --- /dev/null +++ b/tests/test_dll_issue_request.py @@ -0,0 +1,99 @@ +"""Exercise production issue validation and safe asset-name forwarding.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class DllIssueRequestTests(unittest.TestCase): + def test_dll_issue_integrity_validation_is_mandatory_before_publication(self): + workflow = (ROOT / '.github/workflows/add-release-from-issue.yml').read_text() + validation = workflow.split(' - name: Validate DLL registry downloads before committing\n', 1)[1].split('\n - name:', 1)[0] + self.assertIn("verify_dll_downloads: 'true'", validation) + self.assertIn('command: validate', validation) + self.assertNotIn('json_payload', validation) + for name in ('Commit changes', 'Create pull request', 'Post success results for add-package'): + step = workflow.split(' - name: ' + name + '\n', 1)[1].split('\n - name:', 1)[0] + condition = next(line for line in step.splitlines() if line.strip().startswith('if:')) + self.assertIn("steps.validate-dll-release.outputs.failed == 'false'", condition) + + def test_wrapper_verifies_downloads_by_default(self): + action = (ROOT / '.github/actions/registry/action.yml').read_text() + self.assertIn("verify_dll_downloads:\n default: 'true'", action) + script = textwrap.dedent(action.split(' run: |\n', 1)[1]) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + docker = path / 'docker' + docker.write_text('#!/usr/bin/env python3\nimport json, os, sys\n' + 'if sys.argv[1] == "run":\n' + ' open(os.environ["CAPTURE"], "w").write(json.dumps(sys.argv[2:]))\n') + docker.chmod(0o755) + env = dict(os.environ, PATH=directory + ':' + os.environ['PATH'], + COMMAND='validate', REPO_URL='', RELEASE_TAG='', + RUNNER_TEMP=directory, GITHUB_WORKSPACE=directory, + GITHUB_OUTPUT=directory + '/output', CAPTURE=directory + '/args') + env.pop('VERIFY_DLL_DOWNLOADS', None) + result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', script], cwd=directory, + env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads((path / 'args').read_text())[-2:], ['validate', '--verify-dll-downloads']) + + def test_initial_template_omits_filename_and_failure_comment_explains_retry(self): + template = (ROOT / '.github/ISSUE_TEMPLATE/add-dll-release.md').read_text() + self.assertNotIn('dll_file', template) + payload = json.loads(template.split('```json\n', 1)[1].split('```', 1)[0]) + self.assertEqual(set(payload), {'action', 'repo_url', 'release_tag'}) + workflow = (ROOT / '.github/workflows/add-release-from-issue.yml').read_text() + step = workflow.split(' - name: Explain DLL asset selection after an ambiguous release\n', 1)[1].split('\n - name:', 1)[0] + self.assertIn("outputs.failed == 'true'", step) + self.assertIn("outputs.action == 'add-dll-release'", step) + self.assertIn('!fromJson(needs.parse-json.outputs.json_payload).dll_file', step) + self.assertIn('contains(steps.update-package-list.outputs.result', step) + self.assertIn('"dll_file": "UnchainedPlugin.dll"', step) + self.assertIn('exact, case-sensitive asset filename', step) + self.assertIn('retry', step) + self.assertIn('issue-number: ${{ github.event.issue.number }}', step) + + def test_payload_filename_validation(self): + workflow = (ROOT / '.github/workflows/add-release-from-issue.yml').read_text() + step = workflow.split(' - name: Validate JSON payload\n', 1)[1].split('\n - name:', 1)[0] + script = textwrap.dedent(step.split(' run: |\n', 1)[1]) + with tempfile.TemporaryDirectory() as directory: + for fields, valid in (({}, True), ({'dll_file': 'Plugin.dll'}, True), + ({'dll_file': 'Plugin debug.dll'}, True), + ({'dll_file': ''}, False), ({'dll_file': None}, False), + ({'dll_file': True}, False), ({'dll_file': ['Plugin.dll']}, False)): + with self.subTest(fields=fields): + result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', script], + env=dict(os.environ, PAYLOAD=json.dumps(dict(action='add-dll-release', **fields)), + GITHUB_OUTPUT=directory + '/output', GITHUB_STEP_SUMMARY=directory + '/summary'), + capture_output=True, text=True) + self.assertEqual(result.returncode == 0, valid, result.stderr) + + def test_wrapper_passes_asset_name_as_one_literal_argument(self): + action = (ROOT / '.github/actions/registry/action.yml').read_text() + script = textwrap.dedent(action.split(' run: |\n', 1)[1]) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + docker = path / 'docker' + docker.write_text('#!/usr/bin/env python3\nimport json, os, sys\n' + 'if sys.argv[1] == "run":\n' + ' open(os.environ["CAPTURE"], "w").write(json.dumps(sys.argv[2:]))\n') + docker.chmod(0o755) + filename = 'Plugin debug $(touch SHOULD_NOT_EXIST).dll' + result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', script], cwd=directory, + env=dict(os.environ, PATH=directory + ':' + os.environ['PATH'], + COMMAND='add-dll-release', REPO_URL='https://github.com/Test/Plugin', + RELEASE_TAG='v1.0.0', DLL_FILE=filename, + RUNNER_TEMP=directory, GITHUB_WORKSPACE=directory, + GITHUB_OUTPUT=directory + '/output', CAPTURE=directory + '/args'), + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + args = json.loads((path / 'args').read_text()) + self.assertEqual(args[-2:], ['--dll-file', filename]) + self.assertFalse((path / 'SHOULD_NOT_EXIST').exists()) diff --git a/tests/test_registry_integration.py b/tests/test_registry_integration.py index bb23b1c..292f9c1 100644 --- a/tests/test_registry_integration.py +++ b/tests/test_registry_integration.py @@ -1,4 +1,4 @@ -"""Exercise the patched Docker CLI and the exact composite action shell. +"""Exercise the upstream Docker CLI and the exact composite action shell. Only remote metadata retrieval is replaced with deterministic fixtures; registry loading, validation, processing, index writes, CLI exits and Docker are real. @@ -15,14 +15,12 @@ ROOT = Path(__file__).resolve().parents[1] ACTION = ROOT / ".github/actions/registry" -REVISION = "5577c4c3f5870f192c4929eaecc11ae6d7becf77" -IMAGE = f"zero-infra-mod-registry:{REVISION}-" + hashlib.sha256( - (ACTION / "upstream.patch").read_bytes() -).hexdigest() # Extract rather than copy the production shell, so tests cannot drift from CI. ACTION_SHELL = textwrap.dedent( (ACTION / "action.yml").read_text().split(" run: |\n", 1)[1] ) +REVISION = ACTION_SHELL.split("REVISION=", 1)[1].splitlines()[0].strip() +IMAGE = f"zero-infra-mod-registry:{REVISION}" def metadata(name, missing_dependency=False): @@ -47,8 +45,19 @@ def metadata(name, missing_dependency=False): class RegistryIntegrationTests(unittest.TestCase): @classmethod def setUpClass(cls): - # Build through the production integration, including its pinned fetch, - # patch application and cache key (or reuse that exact patched image). + # Optional local dependency testing before the upstream SHA is pushed. + # Production always fetches the pinned commit directly, without patches. + source = os.environ.get("REGISTRY_TEST_SOURCE") + if source: + head = subprocess.check_output(["git", "-C", source, "rev-parse", "HEAD"], text=True).strip() + if head != REVISION: + raise RuntimeError("Local registry test source must match the pinned commit") + result = subprocess.run(["docker", "build", "--tag", IMAGE, source], + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode != 0: + raise RuntimeError(result.stdout) + # Build through the production integration's pinned fetch, or reuse that + # exact image when testing a not-yet-published dependency locally. with tempfile.TemporaryDirectory(prefix="registry-build-") as directory: workspace = Path(directory) result, outputs = cls.action(workspace, "validate") @@ -67,7 +76,7 @@ def action(workspace, command): env = dict(os.environ, COMMAND=command, REPO_URL="", RELEASE_TAG="", GITHUB_TOKEN="fixture-token", LOG_LEVEL="INFO", RUNNER_TEMP=str(runner), GITHUB_WORKSPACE=str(workspace), - GITHUB_OUTPUT=str(output), REGISTRY_ACTION_PATH=str(ACTION)) + GITHUB_OUTPUT=str(output)) result = subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", ACTION_SHELL], env=env, text=True, stdout=subprocess.PIPE, @@ -89,6 +98,9 @@ def setUp(self): (support / "sitecustomize.py").write_text(textwrap.dedent(''' import json from pathlib import Path + from types import SimpleNamespace + from github import Github + import requests from zero_infra_mod_registry.models import Mod, Release from zero_infra_mod_registry.retriever import GithubModMetadataRetriever @@ -101,6 +113,23 @@ def fetch_release(self, mod, tag): GithubModMetadataRetriever.fetch_repo_metadata = fetch_repo GithubModMetadataRetriever.fetch_release_metadata = fetch_release + + def dll_repo(self, name): + data = json.loads(Path("fixtures/dll-release.json").read_text()) + release = SimpleNamespace(**data) + release.get_assets = lambda: [SimpleNamespace(**asset) for asset in data["assets"]] + return SimpleNamespace(get_release=lambda tag: release) + + class DllResponse: + url = "https://example.com/plugin.dll" + def __enter__(self): return self + def __exit__(self, *args): pass + def raise_for_status(self): pass + def iter_content(self, chunk_size): + yield Path("fixtures/plugin.dll").read_bytes() + + Github.get_repo = dll_repo + requests.get = lambda *args, **kwargs: DllResponse() ''')) (self.workspace / "fixtures").mkdir() @@ -114,7 +143,7 @@ def fixture(self, name, missing_dependency=False): def cli(self, *args): return subprocess.run([ - "docker", "run", "--rm", "--env", "GITHUB_TOKEN=fixture-token", + "docker", "run", "--rm", "--user", f"{os.getuid()}:{os.getgid()}", "--env", "GITHUB_TOKEN=fixture-token", "--env", "LOG_LEVEL=INFO", "--env", "PYTHONPATH=/github/workspace/test_support", "--env", "PYTHONDONTWRITEBYTECODE=1", "--volume", f"{self.workspace}:/github/workspace", @@ -212,6 +241,83 @@ def test_processing_valid_package_passes_and_can_be_appended(self): self.assertEqual(self.index.read_text().splitlines(), ["Test/Existing", "Test/New"]) self.assert_success(self.cli("validate")) + def dll_fixture(self, version="v1.0.0", assets=1, draft=False): + binary = b"DLL fixture bytes" + (self.workspace / "fixtures/plugin.dll").write_bytes(binary) + (self.workspace / "fixtures/dll-release.json").write_text(json.dumps({ + "tag_name": version, "draft": draft, + "assets": [{"name": f"Plugin{i}.dll", "browser_download_url": "https://example.com/plugin.dll", + "size": len(binary)} for i in range(assets)]})) + + def test_dll_registration_separate_index_and_duplicate_rejected(self): + self.dll_fixture() + args = ("add-dll-release", "https://github.com/Test/Plugin", "v1.0.0") + self.assert_success(self.cli(*args)) + path = self.workspace / "dll_db/index.json" + entries = json.loads(path.read_text()) + self.assertEqual(entries[0], { + "organization": "Test", "name": "Plugin", "version": "1.0.0", + "download_url": "https://example.com/plugin.dll", + "size": len(b"DLL fixture bytes"), "sha256": hashlib.sha256(b"DLL fixture bytes").hexdigest()}) + self.assertEqual(self.index.read_text(), "") + self.assertFalse((self.registry / "Test.txt").exists()) + self.assert_success(self.cli("validate", "--verify-dll-downloads")) + before = path.read_bytes() + self.assertNotEqual(self.cli(*args).returncode, 0) + self.assertEqual(path.read_bytes(), before) + + def test_dll_invalid_releases_do_not_write_index(self): + for kwargs in ({"assets": 0}, {"assets": 2}, {"version": "latest"}, {"draft": True}): + with self.subTest(kwargs=kwargs): + self.dll_fixture(**kwargs) + self.assertNotEqual(self.cli("add-dll-release", "https://github.com/Test/Plugin", "v1.0.0").returncode, 0) + self.assertFalse((self.workspace / "dll_db/index.json").exists()) + + def test_dll_dry_run_does_not_write_index(self): + self.dll_fixture() + self.assert_success(self.cli("--dry-run", "add-dll-release", "https://github.com/Test/Plugin", "v1.0.0")) + self.assertFalse((self.workspace / "dll_db/index.json").exists()) + + def test_dll_multiple_assets_require_exact_selection(self): + self.dll_fixture(assets=2) + common = ("add-dll-release", "https://github.com/Test/Plugin", "v1.0.0") + for filename in ("Missing.dll", "plugin0.dll", "*.dll", "Plugin0.pak"): + with self.subTest(filename=filename): + self.assertNotEqual(self.cli(*common, "--dll-file", filename).returncode, 0) + self.assertFalse((self.workspace / "dll_db/index.json").exists()) + self.assert_success(self.cli("--dry-run", *common, "--dll-file", "Plugin1.dll")) + self.assertFalse((self.workspace / "dll_db/index.json").exists()) + self.assert_success(self.cli(*common, "--dll-file", "Plugin1.dll")) + entries = json.loads((self.workspace / "dll_db/index.json").read_text()) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["sha256"], hashlib.sha256(b"DLL fixture bytes").hexdigest()) + + def test_dll_prerelease_dry_run_and_version_preservation(self): + for tag in ("v1.0.0-RC2", "v1.0.0-RC1", "v1.0.0-rc0"): + with self.subTest(tag=tag): + self.dll_fixture(version=tag) + before = (self.workspace / "dll_db/index.json").read_bytes() if (self.workspace / "dll_db/index.json").exists() else None + self.assert_success(self.cli("--dry-run", "add-dll-release", "https://github.com/Test/Plugin", tag)) + after = (self.workspace / "dll_db/index.json").read_bytes() if (self.workspace / "dll_db/index.json").exists() else None + self.assertEqual(after, before) + self.assert_success(self.cli("add-dll-release", "https://github.com/Test/Plugin", tag)) + entries = json.loads((self.workspace / "dll_db/index.json").read_text()) + self.assertEqual(entries[-1]["version"], tag.removeprefix("v")) + + def test_dll_corrupt_metadata_and_download_fail_validation(self): + self.dll_fixture() + self.assert_success(self.cli("add-dll-release", "https://github.com/Test/Plugin", "v1.0.0")) + (self.workspace / "fixtures/plugin.dll").write_bytes(b"tampered") + self.assertNotEqual(self.cli("validate", "--verify-dll-downloads").returncode, 0) + path = self.workspace / "dll_db/index.json" + entries = json.loads(path.read_text()) + for field, value in (("organization", "../escape"), ("version", "latest"), ("sha256", "bad"), + ("size", 0), ("size", True), ("download_url", "http://example.com/a.dll")): + with self.subTest(field=field): + invalid = [dict(entries[0], **{field: value})] + path.write_text(json.dumps(invalid)) + self.assertNotEqual(self.cli("validate").returncode, 0) + if __name__ == "__main__": unittest.main()