mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
253c37dc45 | ||
|
|
57b2a3a785 | ||
|
|
c58ba98d51 |
No files matched your search
@@ -3,4 +3,5 @@
|
||||
# (Ludovic decides who is in it). /.github/ and takedowns stay Ludovic's.
|
||||
* @ludovic111 @horizon-rust/horizon-team
|
||||
/.github/ @ludovic111
|
||||
/scripts/ @ludovic111
|
||||
/revoked.toml @ludovic111
|
||||
@@ -0,0 +1,44 @@
|
||||
# Merges an owner's update of their own project without a reviewer: the submission form's pull request
|
||||
# (opened by the form's account, signed with an HMAC trailer in its body, label `owner-update`) whose diff is
|
||||
# one new [[versions]] table in mods/<id>.toml, by someone in that entry's `owners`. scripts/owner_update.py
|
||||
# verifies all of it and waits for the other workflows (check, and native when it ran) to pass on the head commit.
|
||||
# Anything else is a reviewer's, as before. Setup (Ludovic's): README.md, "Owner updates".
|
||||
#
|
||||
# pull_request_target: it runs main's copy of this file and script, with secrets, and never checks out or runs the
|
||||
# pull request's code: the script only reads the pull request through the API, as data. Merging needs the
|
||||
# secret REGISTRY_MERGE_TOKEN, an account allowed to bypass the review rule; a merge by GITHUB_TOKEN would not
|
||||
# start publish.yml either.
|
||||
name: owner-update
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, reopened, labeled, synchronize]
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: owner-update-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
owner-update:
|
||||
# The label only keeps this off other pull requests; the signed trailer is the proof.
|
||||
if: contains(github.event.pull_request.labels.*.name, 'owner-update')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- run: python3 scripts/test_owner_update.py
|
||||
- name: Verify, wait for the checks, merge
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.REGISTRY_MERGE_TOKEN }}
|
||||
UPDATE_KEY: ${{ secrets.REGISTRY_UPDATE_KEY }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
BOT: ${{ vars.REGISTRY_BOT || 'horizon-mods-bot' }}
|
||||
SELF: ${{ github.workflow }}
|
||||
run: python3 scripts/owner_update.py
|
||||
@@ -3,3 +3,4 @@
|
||||
/bootstrap/
|
||||
/native/target/
|
||||
/native/*/target/
|
||||
__pycache__/
|
||||
@@ -42,7 +42,38 @@ cargo horizon-mod check target/horizon-mod/my_mod.hmod
|
||||
```
|
||||
|
||||
**Updates** come the same way: the form adds a version to your entry. Only an entry's `owners` publish its updates
|
||||
without a reviewer's extra look. A published file never changes: publish a new version.
|
||||
without a reviewer's extra look (below). A published file never changes: publish a new version.
|
||||
|
||||
### Owner updates
|
||||
|
||||
When an owner of a project updates it through the form, and **only a new version is added** (the entry's key, owners,
|
||||
icon, kind, id and page text are exactly as on `main`; the version is signed if the entry has a key), no reviewer is
|
||||
needed. The flow:
|
||||
|
||||
1. The form checks that your GitHub login is in the entry's `owners` on `main` and that nothing else of the entry
|
||||
changed, and opens the pull request as the registry's account with the label `owner-update` and, at the end of its
|
||||
body, a trailer holding the id, version, SHA-256, file, your login and an HMAC-SHA256 over them with the secret
|
||||
`REGISTRY_UPDATE_KEY` (only the form and this repository know it). Anyone else's pull request cannot carry a valid one.
|
||||
2. [`owner-update.yml`](.github/workflows/owner-update.yml) runs [`scripts/owner_update.py`](scripts/owner_update.py)
|
||||
from `main` (never the pull request's code) and verifies: the HMAC; the author is the form's account (variable
|
||||
`REGISTRY_BOT`); the diff is exactly `mods/<id>.toml` with one new `[[versions]]` table matching the signed
|
||||
id, version, SHA-256 and file, every other field unchanged and `main`'s copy unchanged since; the submitter is in
|
||||
`owners` on `main`; the id is not in `revoked.toml`.
|
||||
3. It waits for the other workflows on the pull request's head commit (`check`, and `native` when it ran) to succeed.
|
||||
4. It merges (a merge commit pinned to the verified head) with `REGISTRY_MERGE_TOKEN`, which is how `publish.yml` runs
|
||||
on the merge.
|
||||
|
||||
A new project, a changed page, key, owners or icon, a missing signature, a failed check or anything that does not verify
|
||||
leaves the pull request to a reviewer as before (a red `owner-update` run says why). Tests of the verification:
|
||||
`python3 scripts/test_owner_update.py`.
|
||||
|
||||
**Setup** (Ludovic, once; the changes under `.github/` and `scripts/` are his to review):
|
||||
|
||||
| What | Where |
|
||||
| --- | --- |
|
||||
| `REGISTRY_UPDATE_KEY`: the same random secret (32+ characters) | the secret of this repository, and the variable of the Railway service `horizon-mod-registry` (see its README); unset on either side, nothing merges by itself |
|
||||
| `REGISTRY_MERGE_TOKEN`: a fine-grained token (or a GitHub App's) of an account that may bypass the review rule, with Contents and Pull requests read and write on this repository | the secret of this repository; add the account to the bypass list of the ruleset (or "Allow specified actors to bypass required pull requests" of the branch protection). Required status checks stay required for everyone |
|
||||
| `REGISTRY_BOT`: the login of the form's account (`GITHUB_BOT_TOKEN`'s owner) | the variable of this repository (also read by `check.yml`; default `horizon-mods-bot`) |
|
||||
|
||||
**Signing** is optional and recommended: put your [minisign](https://jedisct1.github.io/minisign/) public key in your
|
||||
entry, and every later version must be signed with it, so nobody else can publish an update in your name.
|
||||
@@ -63,6 +94,7 @@ why.
|
||||
| `revoked.toml` | Files taken down |
|
||||
| `.github/workflows/check.yml` | A pull request's checks |
|
||||
| `.github/workflows/publish.yml` | On merge: releases, then `index.json` on Pages |
|
||||
| `.github/workflows/owner-update.yml`, `scripts/owner_update.py` | Merges an owner's update of their own project when it verifies |
|
||||
| `.github/workflows/cleanup.yml` | Weekly: staging files nobody needs any more |
|
||||
| `.github/workflows/native.yml` | Horizon's native mods: each changed one's tests, `.hnmod` and game check, all of them together, and weekly on the newest Nightly |
|
||||
| `native/<name>/` | Horizon's own native mods (Zoom, FPS Counter, Keystrokes, Minimap, Recipes, Coordinates HUD): the source of their `.hnmod` files |
|
||||
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merges an owner update without a reviewer, when everything proves it is one.
|
||||
|
||||
The submission form (crates/horizon-mod-registry in horizon-rust/horizon) ends the body of an
|
||||
owner's update with a trailer signed with REGISTRY_UPDATE_KEY:
|
||||
|
||||
<!-- owner-update
|
||||
id: coords_hud
|
||||
version: 0.1.0
|
||||
sha256: <hex>
|
||||
file: coords_hud-0.1.0.hmod
|
||||
submitter: alice
|
||||
hmac: <hex HMAC-SHA256 of "owner-update-v1\\n<id>\\n<version>\\n<sha256>\\n<file>\\n<submitter>">
|
||||
-->
|
||||
|
||||
This script (run by .github/workflows/owner-update.yml, from main: never the pull request's copy) checks:
|
||||
a. the trailer's HMAC;
|
||||
b. the author is the form's bot account, and the diff is exactly mods/<id>.toml gaining one
|
||||
[[versions]] table that matches the trailer, every other field as it was;
|
||||
c. the submitter is in the entry's `owners` on main, and the id is not in revoked.toml;
|
||||
d. every workflow run on the head commit but this one succeeded, `check` among them.
|
||||
Then it merges (with REGISTRY_MERGE_TOKEN, pinned to the verified commit). Anything wrong: it exits 1 and
|
||||
the pull request waits for a reviewer as any other.
|
||||
|
||||
Environment: GH_TOKEN (read), MERGE_TOKEN, REPO, PR (number), BOT (the form's account),
|
||||
UPDATE_KEY, SELF (this workflow's name, skipped while waiting).
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
|
||||
TRAILER = re.compile(r"<!-- owner-update\r?\n(.*?)\r?\n-->", re.S)
|
||||
FIELDS = ("id", "version", "sha256", "file", "submitter")
|
||||
ID = re.compile(r"[a-z0-9_]{1,64}")
|
||||
VERSION = re.compile(r"[A-Za-z0-9.+_-]+")
|
||||
HEX64 = re.compile(r"[0-9a-f]{64}")
|
||||
LOGIN = re.compile(r"[A-Za-z0-9-]{1,39}")
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def trailer(body):
|
||||
"""The trailer's fields, or None when the body has none (the last one counts)."""
|
||||
found = TRAILER.findall(body or "")
|
||||
if not found:
|
||||
return None
|
||||
fields = {}
|
||||
for line in found[-1].splitlines():
|
||||
key, sep, value = line.partition(": ")
|
||||
if not sep or key in fields:
|
||||
raise Refused(f"the trailer's line {line!r} is not `key: value`, or repeated")
|
||||
fields[key] = value.strip()
|
||||
if set(fields) != {*FIELDS, "hmac"}:
|
||||
raise Refused("the trailer has other fields than " + ", ".join((*FIELDS, "hmac")))
|
||||
return fields
|
||||
|
||||
|
||||
def mac(key, fields):
|
||||
message = "\n".join(["owner-update-v1", *(fields[f] for f in FIELDS)])
|
||||
return hmac.new(key.encode(), message.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_trailer(key, fields):
|
||||
if not key:
|
||||
raise Refused("REGISTRY_UPDATE_KEY is not set")
|
||||
if not hmac.compare_digest(mac(key, fields), fields["hmac"].lower()):
|
||||
raise Refused("the trailer's HMAC is not valid: it was not made by the registry's form")
|
||||
if not (ID.fullmatch(fields["id"]) and VERSION.fullmatch(fields["version"])
|
||||
and HEX64.fullmatch(fields["sha256"]) and LOGIN.fullmatch(fields["submitter"])):
|
||||
raise Refused("the trailer's fields are malformed")
|
||||
|
||||
|
||||
def check_diff(fields, files, base_text, head_text, main_text, revoked_text):
|
||||
"""`files`: the pull request's changed files (name, status) against its merge base."""
|
||||
id_ = fields["id"]
|
||||
path = f"mods/{id_}.toml"
|
||||
if [(f["filename"], f["status"]) for f in files] != [(path, "modified")]:
|
||||
raise Refused(f"the diff is not exactly a modified {path}: {[f['filename'] for f in files]}")
|
||||
base, head, main = (tomllib.loads(t) for t in (base_text, head_text, main_text))
|
||||
if base != main:
|
||||
raise Refused(f"{path} changed on main since the pull request was opened")
|
||||
if head.get("id") != id_ or base.get("id") != id_:
|
||||
raise Refused("the entry's id is not the trailer's")
|
||||
if not any(o.lower() == fields["submitter"].lower() for o in base.get("owners", [])):
|
||||
raise Refused(f"{fields['submitter']} is not in {path}'s owners on main")
|
||||
revoked = [r for r in tomllib.loads(revoked_text).get("revoked", []) if r.get("id") == id_]
|
||||
if revoked:
|
||||
raise Refused(f"{id_} is in revoked.toml")
|
||||
rest = lambda e: {k: v for k, v in e.items() if k != "versions"} # noqa: E731
|
||||
if rest(head) != rest(base):
|
||||
changed = sorted(k for k in {*rest(head), *rest(base)} if head.get(k) != base.get(k))
|
||||
raise Refused(f"it changes more than a version: {', '.join(changed)}")
|
||||
old, new = base.get("versions", []), head.get("versions", [])
|
||||
added = [v for v in new if v not in old]
|
||||
if len(new) != len(old) + 1 or len(added) != 1 or any(v not in new for v in old):
|
||||
raise Refused("it does not add exactly one version and keep the others")
|
||||
v = added[0]
|
||||
for field in ("version", "sha256", "file"):
|
||||
if v.get(field) != fields[field]:
|
||||
raise Refused(f"the new version's {field} is {v.get(field)!r}, not the signed {fields[field]!r}")
|
||||
if base.get("key") and not v.get("signature"):
|
||||
raise Refused("the entry has a key and the version has no signature")
|
||||
return base.get("kind")
|
||||
|
||||
|
||||
def waiting(runs, own):
|
||||
"""(finished, failure): the workflow runs but `own`, on the head commit."""
|
||||
others = [r for r in runs if r["name"] != own]
|
||||
if not any(r["name"] == "check" for r in others):
|
||||
return False, None
|
||||
for r in others:
|
||||
if r["status"] == "completed" and r["conclusion"] not in ("success", "skipped", "neutral"):
|
||||
return True, f"{r['name']} ended {r['conclusion']}"
|
||||
return all(r["status"] == "completed" for r in others), None
|
||||
|
||||
|
||||
def gh(*args, token=None, raw=False):
|
||||
env = {**os.environ, "GH_TOKEN": token or os.environ["GH_TOKEN"]}
|
||||
out = subprocess.run(["gh", *args], env=env, capture_output=True, text=True)
|
||||
if out.returncode:
|
||||
raise Refused(f"gh {' '.join(args[:2])}: {out.stderr.strip()}")
|
||||
return out.stdout if raw else json.loads(out.stdout or "null")
|
||||
|
||||
|
||||
def contents(repo, path, ref):
|
||||
return gh("api", "-H", "Accept: application/vnd.github.raw+json", f"repos/{repo}/contents/{path}?ref={ref}", raw=True)
|
||||
|
||||
|
||||
def pull(repo, n):
|
||||
return gh("api", f"repos/{repo}/pulls/{n}")
|
||||
|
||||
|
||||
def main():
|
||||
repo, n, bot = os.environ["REPO"], os.environ["PR"], os.environ["BOT"]
|
||||
pr = pull(repo, n)
|
||||
if pr["state"] != "open" or pr["base"]["ref"] != "main":
|
||||
print("not an open pull request to main")
|
||||
return 0
|
||||
fields = trailer(pr["body"])
|
||||
if fields is None:
|
||||
print("no owner-update trailer: a reviewer's")
|
||||
return 0
|
||||
verify_trailer(os.environ.get("UPDATE_KEY", ""), fields)
|
||||
if pr["user"]["login"] not in (bot, f"{bot}[bot]"):
|
||||
raise Refused(f"the author {pr['user']['login']} is not the form's account {bot}")
|
||||
sha = pr["head"]["sha"]
|
||||
compare = gh("api", f"repos/{repo}/compare/main...{sha}")
|
||||
if compare.get("total_commits", 0) < 1 or len(compare.get("files", [])) >= 300:
|
||||
raise Refused("the comparison is not a small one")
|
||||
path = f"mods/{fields['id']}.toml"
|
||||
kind = check_diff(
|
||||
fields,
|
||||
compare["files"],
|
||||
contents(repo, path, compare["merge_base_commit"]["sha"]),
|
||||
contents(repo, path, sha),
|
||||
contents(repo, path, "main"),
|
||||
contents(repo, "revoked.toml", "main"),
|
||||
)
|
||||
print(f"{fields['id']} {fields['version']} ({kind}) by {fields['submitter']}: the trailer and the diff hold")
|
||||
deadline = time.time() + 50 * 60
|
||||
while True:
|
||||
runs = gh("api", f"repos/{repo}/actions/runs?head_sha={sha}&per_page=100")["workflow_runs"]
|
||||
done, failure = waiting(runs, os.environ.get("SELF", "owner-update"))
|
||||
if failure:
|
||||
raise Refused(f"a check failed: {failure}")
|
||||
if done:
|
||||
break
|
||||
if time.time() > deadline:
|
||||
raise Refused("the checks did not finish in 50 minutes")
|
||||
time.sleep(30)
|
||||
if pull(repo, n)["head"]["sha"] != sha:
|
||||
raise Refused("the pull request changed while its checks ran")
|
||||
gh("pr", "merge", n, "--repo", repo, "--merge", "--match-head-commit", sha, token=os.environ["MERGE_TOKEN"])
|
||||
print(f"merged {sha}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Refused as e:
|
||||
print(f"::error::owner update not merged: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""python3 scripts/test_owner_update.py"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import owner_update as ou # noqa: E402
|
||||
|
||||
KEY = "a test update key"
|
||||
SHA = "ab" * 32
|
||||
FIELDS = {"id": "zoom", "version": "1.1.0", "sha256": SHA, "file": "zoom-1.1.0.hnmod", "submitter": "alice"}
|
||||
|
||||
|
||||
def entry(owners='["alice"]', versions=("1.0.0",), extra="", key='key = "RWK"\n'):
|
||||
head = f'id = "zoom"\nkind = "native-mod"\nname = "Zoom"\nsummary = "s"\nowners = {owners}\nlicense = "MIT"\nsource = "https://x"\n{key}{extra}\n'
|
||||
return head + "".join(
|
||||
f'[[versions]]\nversion = "{v}"\npublished = "2026-10-30"\ngame = "26.3"\nfile = "zoom-{v}.hnmod"\n'
|
||||
f'sha256 = "{SHA if v == "1.1.0" else "00" * 32}"\nsignature = "sig"\n\n'
|
||||
for v in versions
|
||||
)
|
||||
|
||||
|
||||
BASE = entry()
|
||||
GOOD = entry(versions=("1.1.0", "1.0.0"))
|
||||
FILES = [{"filename": "mods/zoom.toml", "status": "modified"}]
|
||||
|
||||
|
||||
def run(head=GOOD, files=FILES, main=BASE, base=BASE, revoked="", fields=FIELDS):
|
||||
return ou.check_diff(fields, files, base, head, main, revoked)
|
||||
|
||||
|
||||
class Trailer(unittest.TestCase):
|
||||
def body(self, fields=FIELDS, key=KEY):
|
||||
lines = "\n".join(f"{k}: {v}" for k, v in fields.items())
|
||||
return f"hello\n\n<!-- owner-update\n{lines}\nhmac: {ou.mac(key, fields)}\n-->"
|
||||
|
||||
def test_valid(self):
|
||||
f = ou.trailer(self.body())
|
||||
ou.verify_trailer(KEY, f)
|
||||
self.assertEqual(f["submitter"], "alice")
|
||||
|
||||
def test_matches_the_form(self):
|
||||
# crates/horizon-mod-registry/tests/submit.rs builds the same message.
|
||||
f = {"id": "coords_hud", "version": "0.1.0", "sha256": "e1" * 32, "file": "coords_hud-0.1.0.hmod", "submitter": "alice"}
|
||||
import hashlib, hmac
|
||||
msg = "owner-update-v1\ncoords_hud\n0.1.0\n" + "e1" * 32 + "\ncoords_hud-0.1.0.hmod\nalice"
|
||||
self.assertEqual(ou.mac(KEY, f), hmac.new(KEY.encode(), msg.encode(), hashlib.sha256).hexdigest())
|
||||
|
||||
def test_none(self):
|
||||
self.assertIsNone(ou.trailer("no trailer"))
|
||||
self.assertIsNone(ou.trailer(None))
|
||||
|
||||
def test_forged(self):
|
||||
for body in (self.body(key="another"), self.body().replace("alice", "mallory")):
|
||||
with self.assertRaises(ou.Refused):
|
||||
ou.verify_trailer(KEY, ou.trailer(body))
|
||||
with self.assertRaises(ou.Refused):
|
||||
ou.verify_trailer("", ou.trailer(self.body()))
|
||||
|
||||
def test_malformed(self):
|
||||
with self.assertRaises(ou.Refused):
|
||||
ou.trailer(self.body().replace("file:", "other:"))
|
||||
with self.assertRaises(ou.Refused):
|
||||
ou.trailer(self.body().replace("-->", "id: x\n-->"))
|
||||
|
||||
|
||||
class Diff(unittest.TestCase):
|
||||
def refused(self, part, **kw):
|
||||
with self.assertRaises(ou.Refused) as e:
|
||||
run(**kw)
|
||||
self.assertIn(part, str(e.exception))
|
||||
|
||||
def test_an_owners_new_version(self):
|
||||
self.assertEqual(run(), "native-mod")
|
||||
self.assertEqual(run(head=entry(versions=("1.0.0", "1.1.0"))), "native-mod") # appended, as the diff reads
|
||||
|
||||
def test_files(self):
|
||||
self.refused("exactly", files=FILES + [{"filename": "icons/zoom.png", "status": "added"}])
|
||||
self.refused("exactly", files=[{"filename": "mods/zoom.toml", "status": "added"}])
|
||||
self.refused("exactly", files=[{"filename": "mods/other.toml", "status": "modified"}])
|
||||
|
||||
def test_owners_and_revoked(self):
|
||||
self.refused("not in", base=entry('["bob"]'), main=entry('["bob"]'), head=entry('["bob"]', ("1.1.0", "1.0.0")))
|
||||
self.refused("revoked", revoked='[[revoked]]\nid = "zoom"\nreason = "r"\ndate = "2026-10-30"\n')
|
||||
self.refused("changed on main", main=entry(extra='description = "new"\n'))
|
||||
|
||||
def test_only_a_version(self):
|
||||
self.refused("owners", head=entry('["alice", "mallory"]', ("1.1.0", "1.0.0")))
|
||||
self.refused("key", head=entry(versions=("1.1.0", "1.0.0"), key='key = "RWOther"\n'))
|
||||
self.refused("summary", head=GOOD.replace('summary = "s"', 'summary = "z"'))
|
||||
self.refused("icon", head=entry(versions=("1.1.0", "1.0.0"), extra='icon = "https://x/i.png"\n'))
|
||||
|
||||
def test_the_version_is_the_signed_one(self):
|
||||
self.refused("sha256", head=GOOD.replace(SHA, "cd" * 32))
|
||||
self.refused("exactly one", head=entry(versions=("1.1.0", "1.0.5", "1.0.0")))
|
||||
self.refused("exactly one", head=entry(versions=("1.1.0",)))
|
||||
self.refused("version", fields={**FIELDS, "version": "9.9.9"})
|
||||
self.refused("no signature", head=GOOD.replace('signature = "sig"\n', "", 1))
|
||||
|
||||
def test_a_published_version_is_never_edited(self):
|
||||
self.refused("exactly one", head=entry(versions=("1.1.0", "1.0.0")).replace("2026-10-30", "2026-10-31", 2))
|
||||
|
||||
|
||||
class Waiting(unittest.TestCase):
|
||||
def test_it(self):
|
||||
run_ = lambda name, status="completed", conclusion="success": {"name": name, "status": status, "conclusion": conclusion} # noqa: E731
|
||||
self.assertEqual(ou.waiting([], "owner-update"), (False, None))
|
||||
self.assertEqual(ou.waiting([run_("owner-update", "in_progress", None)], "owner-update"), (False, None))
|
||||
self.assertEqual(ou.waiting([run_("check", "in_progress", None)], "owner-update"), (False, None))
|
||||
self.assertEqual(ou.waiting([run_("check")], "owner-update"), (True, None))
|
||||
self.assertEqual(ou.waiting([run_("check"), run_("native", "queued", None)], "owner-update"), (False, None))
|
||||
self.assertEqual(ou.waiting([run_("check"), run_("native", conclusion="skipped")], "owner-update"), (True, None))
|
||||
self.assertEqual(ou.waiting([run_("check", conclusion="failure")], "owner-update"), (True, "check ended failure"))
|
||||
self.assertEqual(ou.waiting([run_("check"), run_("native", conclusion="cancelled")], "owner-update")[1], "native ended cancelled")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user