Skip to content
Open
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
36 changes: 36 additions & 0 deletions .github/workflows/validate-entries.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Checks mods/*.json against schema/entry.schema.json and runs the validator's own tests. Runs no
# code from a submission and holds no secrets.
name: validate entries

on:
pull_request:
push:
branches: [main]

permissions: {}

jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false

- name: Install the validator's one dependency
run: python3 -m venv "$RUNNER_TEMP/venv" && "$RUNNER_TEMP/venv/bin/pip" install --quiet jsonschema==4.26.0

- name: Validator tests
run: $RUNNER_TEMP/venv/bin/python -I -m unittest discover -s tests -v

- name: Validate the listed entries
run: $RUNNER_TEMP/venv/bin/python -I scripts/validate_entries.py --log validate.jsonl

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: validate-entries
path: validate.jsonl
retention-days: 90
51 changes: 51 additions & 0 deletions docs/entry-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Entry format

A listed mod is one file, `mods/<id>.json`, checked against [`schema/entry.schema.json`](../schema/entry.schema.json).
The first half is what the author files; a maintainer adds the rest when the mod is approved and after
each release.

| Field | Who | What |
|---|---|---|
| `id`, `name`, `summary`, `description` | author | The `id` is the one in the mod's `echo-mod.json`. `summary` is one line for the plugin list; `description` says what the mod does, what it hooks and what it sends where. |
| `source` | author | `repo` (`owner/name`) and its `license` (the SPDX id GitHub reports; `NOASSERTION` is refused). |
| `contact` | author | `github` (required), `echovrce`, up to five `links`. Public, so no e-mail addresses. |
| `media` | author | Up to eight `screenshots` (`url`, `alt`, optional `caption`) and one `video`. `https` only. |
| `credits` | author | Everyone whose work the mod builds on: `name`, `role`, optional `url`. |
| `fork` | maintainer | The EchoTools copy that is built and signed (`EchoTools/<name>`). Required once a version is past signer status `unsigned`. |
| `listing` | maintainer | `state` (`listed` or `delisted`, which needs a `delisted_reason`), the `submission_issue`, `listed_at`. |
| `versions` | maintainer | Newest first. One release each, below. |

## A version

The release fields follow the plugin-release spec: release-signer's
[`docs/plugin-release.md`](https://github.com/thesprockee/release-signer/blob/teth/plugin-release-spec/docs/plugin-release.md)
(PR #25, at 642888d). The `signer` block is not in the spec and stays provisional.

| Field | What |
|---|---|
| `version`, `tag`, `commit` | The semver, `v<version>` (`v1.2.0`, `v1.2.0-rc.1`; spec §2), and the 40-hex commit the tag points at in the fork. These and the sha256s below are the pin the signer approves. |
| `assets.package` / `manifest` / `sums` | Exactly the three release assets (spec §4): `<id>-v<version>-windows.zip`, `echo-mod.json`, `SHA256SUMS`. Name and `sha256` as the release workflow built and attested them; `size` is for the plugin browser, the pin does not name it. |
| `prerelease` | `true` exactly when the version has a `-suffix`. |
| `pulled` | `reason` and `at`, set by "Pull a version". |
| `signer.status` (provisional) | `unsigned` (listed before signing), `pending` (draft exists), `approved` (pin merged in the signer), `signed`, `revoked`. |
| `signer.record`, `signed_at`, `package`, `sums` (provisional) | Only on `signed` and `revoked`: the signer's public record, the time, and the digests of the signed zip and the regenerated sums. `echo-mod.json` passes through signing untouched (spec §4), so it has no signed digest. They must differ from the attested ones. |

Versions are listed newest first by semver precedence (`1.2.0` above `1.2.0-rc.2` above `1.2.0-rc.1`),
each strictly above the next: the signer refuses a tag at or below one it already signed (spec §2).

## Checking

```
pip install jsonschema==4.26.0
python3 scripts/validate_entries.py # every mods/*.json
python3 -I -m unittest discover -s tests -v # the validator's own tests
```

The validator writes one JSON line per entry (file, sha256, id, ok, errors) and a summary line, and
exits 1 on any invalid entry and 2 when it can't run. `--log FILE` appends the same lines to a file;
CI keeps them as the `validate-entries` artifact. The `validate entries` workflow runs both on every
pull request and every push to `main`.

What the schema can't say, the validator checks: the file is `<id>.json`, the tag is `v<version>`,
asset names are the spec's, versions are unique and in descending semver precedence, a signed digest differs
from the attested one, and a fork is not the author's own repo.
Empty file added mods/.gitkeep
Empty file.
187 changes: 187 additions & 0 deletions schema/entry.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/EchoTools/echo-mods/schema/entry.schema.json",
"title": "A listed mod in echo-mods",
"description": "mods/<id>.json. The top-level fields up to 'credits' are what the author files (the submission). 'listing', 'fork' and 'versions' are filled in by a maintainer on approval and after each release. The release fields follow the plugin-release spec (release-signer docs/plugin-release.md); the signer block ($defs/release.signer) is not pinned by it and stays provisional. See docs/entry-format.md.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "name", "summary", "description", "source", "contact", "listing", "versions"],
"properties": {
"schema": { "const": 1 },
"id": {
"description": "The id in the mod's echo-mod.json. The file is mods/<id>.json.",
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"summary": { "description": "One line, shown in the plugin list.", "type": "string", "minLength": 1, "maxLength": 140, "pattern": "^[^\\n]+$" },
"description": { "description": "What it does, what it hooks, what it sends where. Plain text or Markdown.", "type": "string", "minLength": 1, "maxLength": 4000 },
"source": {
"description": "The author's own repository.",
"type": "object",
"additionalProperties": false,
"required": ["repo", "license"],
"properties": {
"repo": { "$ref": "#/$defs/repo" },
"license": { "description": "SPDX id GitHub reports for the repo.", "type": "string", "pattern": "^[A-Za-z0-9.+-]{1,64}$", "not": { "const": "NOASSERTION" } }
}
},
"contact": {
"description": "How to reach the author. Public, like the rest of the entry: no e-mail addresses.",
"type": "object",
"additionalProperties": false,
"required": ["github"],
"properties": {
"github": { "$ref": "#/$defs/githubLogin" },
"echovrce": { "description": "The EchoVRCE username players see.", "type": "string", "minLength": 1, "maxLength": 64 },
"links": { "type": "array", "maxItems": 5, "uniqueItems": true, "items": { "$ref": "#/$defs/httpsUrl" } }
}
},
"media": {
"type": "object",
"additionalProperties": false,
"properties": {
"screenshots": {
"type": "array",
"maxItems": 8,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["url", "alt"],
"properties": {
"url": { "$ref": "#/$defs/httpsUrl" },
"alt": { "description": "What the picture shows, for people who can't see it.", "type": "string", "minLength": 1, "maxLength": 200 },
"caption": { "type": "string", "maxLength": 200 }
}
}
},
"video": { "$ref": "#/$defs/httpsUrl" }
}
},
"credits": {
"description": "Everyone whose work the mod builds on. The credit rule: code taken from somebody is named here, with its license kept in the repo.",
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "role"],
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"role": { "type": "string", "minLength": 1, "maxLength": 120 },
"url": { "$ref": "#/$defs/httpsUrl" }
}
}
},
"fork": {
"description": "The EchoTools-owned repo that is built and signed. Required once any version is past signer status 'unsigned'.",
"type": "object",
"additionalProperties": false,
"required": ["repo"],
"properties": {
"repo": { "type": "string", "pattern": "^EchoTools/[A-Za-z0-9._-]{1,100}$" }
}
},
"listing": {
"type": "object",
"additionalProperties": false,
"required": ["state", "submission_issue", "listed_at"],
"properties": {
"state": { "description": "'listed' shows in the plugin list; 'delisted' keeps the record but offers nothing.", "enum": ["listed", "delisted"] },
"submission_issue": { "description": "The EchoTools/echo-mods issue the author filed.", "type": "integer", "minimum": 1 },
"listed_at": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" },
"delisted_reason": { "type": "string", "minLength": 1, "maxLength": 300 }
},
"if": { "properties": { "state": { "const": "delisted" } } },
"then": { "required": ["delisted_reason"] },
"else": { "not": { "required": ["delisted_reason"] } }
},
"versions": {
"description": "Newest first.",
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/release" }
}
},
"$defs": {
"repo": { "description": "owner/name on GitHub.", "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$" },
"githubLogin": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$" },
"httpsUrl": { "type": "string", "pattern": "^https://[^\\s/]+(?:/\\S*)?$", "maxLength": 500 },
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"asset": {
"type": "object",
"additionalProperties": false,
"required": ["name", "sha256", "size"],
"properties": {
"name": { "type": "string", "pattern": "^[A-Za-z0-9._+-]{1,128}$" },
"sha256": { "$ref": "#/$defs/sha256" },
"size": { "type": "integer", "minimum": 1 }
}
},
"digest": {
"type": "object",
"additionalProperties": false,
"required": ["sha256", "size"],
"properties": {
"sha256": { "$ref": "#/$defs/sha256" },
"size": { "type": "integer", "minimum": 1 }
}
},
"release": {
"description": "One release as the plugin-release spec defines it (release-signer docs/plugin-release.md, PR #25 at 642888d): section 2 the tag, section 4 the three assets, and the pin the signer approves (tag, commit, sha256 of each attested asset). The signer block is not in the spec.",
"type": "object",
"additionalProperties": false,
"required": ["version", "tag", "commit", "assets", "signer"],
"properties": {
"version": { "description": "echo-mod.json's version (semver).", "type": "string", "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" },
"tag": { "description": "v<semver>, in the fork (spec section 2).", "type": "string", "pattern": "^v\\S+$" },
"commit": { "description": "The 40-hex commit the tag points at; the attestation names the same one.", "type": "string", "pattern": "^[0-9a-f]{40}$" },
"prerelease": { "type": "boolean" },
"assets": {
"description": "Exactly the three files the release workflow attested (spec section 4), with the sha256 it built them with (the signer's pin). size is for the plugin browser; the pin does not name it.",
"type": "object",
"additionalProperties": false,
"required": ["package", "manifest", "sums"],
"properties": {
"package": { "description": "<id>-v<version>-windows.zip: the DLL, LICENSE, NOTICE.", "$ref": "#/$defs/asset" },
"manifest": { "description": "echo-mod.json", "$ref": "#/$defs/asset" },
"sums": { "description": "SHA256SUMS", "$ref": "#/$defs/asset" }
}
},
"signer": {
"description": "PROVISIONAL: the spec pins none of this. The listing's own record of where the release is in signing.",
"type": "object",
"additionalProperties": false,
"required": ["status"],
"properties": {
"status": {
"description": "unsigned: listed before signing existed. pending: the draft exists, no pin yet. approved: the pin is merged in release-signer. signed: the live run replaced the zip. revoked: signed once, no longer to be trusted.",
"enum": ["unsigned", "pending", "approved", "signed", "revoked"]
},
"record": { "description": "The signer's public record of the run.", "$ref": "#/$defs/httpsUrl" },
"signed_at": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" },
"package": { "description": "The signed zip's digest. The manifest's is unchanged by signing; the sums file's is regenerated.", "$ref": "#/$defs/digest" },
"sums": { "$ref": "#/$defs/digest" }
},
"allOf": [
{
"if": { "properties": { "status": { "enum": ["signed", "revoked"] } } },
"then": { "required": ["record", "signed_at", "package", "sums"] },
"else": { "not": { "anyOf": [ { "required": ["record"] }, { "required": ["signed_at"] }, { "required": ["package"] }, { "required": ["sums"] } ] } }
}
]
},
"pulled": {
"description": "Set by 'Pull a version': the launcher offers players the version before.",
"type": "object",
"additionalProperties": false,
"required": ["reason", "at"],
"properties": {
"reason": { "type": "string", "minLength": 1, "maxLength": 300 },
"at": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" }
}
}
}
}
}
}
Loading
Loading