chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail closed when required CI jobs or classifier outputs are incomplete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
BOOLEAN_FLAGS: Final[tuple[str, ...]] = (
|
||||
"docs_only",
|
||||
"runtime_changed",
|
||||
"test_changed",
|
||||
"ci_changed",
|
||||
"dependency_changed",
|
||||
"release_changed",
|
||||
"windows_full_required",
|
||||
"frontend_changed",
|
||||
"tui_changed",
|
||||
"desktop_changed",
|
||||
"python_changed",
|
||||
"platform_sensitive_changed",
|
||||
"build_wheel_required",
|
||||
"full_required",
|
||||
)
|
||||
|
||||
ALWAYS_REQUIRED_RESULTS: Final[tuple[tuple[str, str], ...]] = (
|
||||
("RESULT_CLASSIFY", "Classify changed files"),
|
||||
("RESULT_WORKFLOW_LINT", "Workflow lint"),
|
||||
("RESULT_README_LOCALE", "README locale parity"),
|
||||
)
|
||||
|
||||
|
||||
def _flag_env(name: str) -> str:
|
||||
return f"FLAG_{name.upper()}"
|
||||
|
||||
|
||||
def _read_flags(env: Mapping[str, str], errors: list[str]) -> dict[str, bool]:
|
||||
flags: dict[str, bool] = {}
|
||||
for name in BOOLEAN_FLAGS:
|
||||
raw = env.get(_flag_env(name), "")
|
||||
if raw not in {"true", "false"}:
|
||||
errors.append(f"Classifier output {name} must be exactly true or false; got {raw!r}.")
|
||||
continue
|
||||
flags[name] = raw == "true"
|
||||
return flags
|
||||
|
||||
|
||||
def _require_result(
|
||||
env: Mapping[str, str],
|
||||
errors: list[str],
|
||||
variable: str,
|
||||
label: str,
|
||||
*,
|
||||
required: bool,
|
||||
) -> None:
|
||||
result = env.get(variable, "")
|
||||
allowed = {"success"} if required else {"success", "skipped"}
|
||||
if result not in allowed:
|
||||
expectation = "succeed" if required else "be successful or intentionally skipped"
|
||||
errors.append(f"{label} must {expectation}; got {result or 'missing'}.")
|
||||
|
||||
|
||||
def check_ci_results(env: Mapping[str, str]) -> list[str]:
|
||||
"""Return gate errors; an empty list means the aggregate check may pass."""
|
||||
|
||||
errors: list[str] = []
|
||||
flags = _read_flags(env, errors)
|
||||
|
||||
for variable, label in ALWAYS_REQUIRED_RESULTS:
|
||||
_require_result(env, errors, variable, label, required=True)
|
||||
|
||||
if len(flags) != len(BOOLEAN_FLAGS):
|
||||
return errors
|
||||
|
||||
full = flags["full_required"]
|
||||
conditional_results = (
|
||||
("RESULT_FRONTEND", "Frontend build and typecheck", flags["frontend_changed"] or full),
|
||||
("RESULT_TUI", "OpenTUI package tests", flags["tui_changed"] or full),
|
||||
("RESULT_DESKTOP", "Desktop Electron unit tests", flags["desktop_changed"] or full),
|
||||
("RESULT_UBUNTU", "Ubuntu quality gate", flags["python_changed"] or full),
|
||||
(
|
||||
"RESULT_WINDOWS_SMOKE",
|
||||
"Windows compatibility smoke tests",
|
||||
flags["python_changed"]
|
||||
or flags["platform_sensitive_changed"]
|
||||
or flags["dependency_changed"]
|
||||
or flags["release_changed"]
|
||||
or full,
|
||||
),
|
||||
(
|
||||
"RESULT_WINDOWS_FULL",
|
||||
"Windows high-risk matrix",
|
||||
flags["windows_full_required"] or full,
|
||||
),
|
||||
(
|
||||
"RESULT_MACOS_RECOVERY",
|
||||
"macOS profile recovery and native no-replace tests",
|
||||
flags["platform_sensitive_changed"] or flags["desktop_changed"] or full,
|
||||
),
|
||||
(
|
||||
"RESULT_DESKTOP_RECOVERY_E2E",
|
||||
"Desktop recovery E2E matrix",
|
||||
flags["platform_sensitive_changed"] or flags["desktop_changed"] or full,
|
||||
),
|
||||
(
|
||||
"RESULT_RELEASE",
|
||||
"Release packaging contracts",
|
||||
flags["release_changed"] or full,
|
||||
),
|
||||
)
|
||||
for variable, label, required in conditional_results:
|
||||
_require_result(env, errors, variable, label, required=required)
|
||||
|
||||
if flags["platform_sensitive_changed"] and not flags["windows_full_required"]:
|
||||
errors.append("Platform-sensitive changes must require the Windows high-risk matrix.")
|
||||
|
||||
if full:
|
||||
if flags["docs_only"]:
|
||||
errors.append("A full CI run cannot be classified as docs-only.")
|
||||
for name in BOOLEAN_FLAGS:
|
||||
if name in {"docs_only", "full_required"}:
|
||||
continue
|
||||
if not flags[name]:
|
||||
errors.append(f"Full CI classification must set {name}=true.")
|
||||
|
||||
if flags["docs_only"]:
|
||||
active = [
|
||||
name
|
||||
for name in BOOLEAN_FLAGS
|
||||
if name != "docs_only" and flags[name]
|
||||
]
|
||||
if active:
|
||||
errors.append(
|
||||
"Docs-only classification cannot enable other flags: " + ", ".join(active)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors = check_ci_results(os.environ)
|
||||
for variable, label in ALWAYS_REQUIRED_RESULTS:
|
||||
print(f"{label}: {os.environ.get(variable, 'missing')}")
|
||||
print(
|
||||
"Classifier flags: "
|
||||
+ " ".join(
|
||||
f"{name}={os.environ.get(_flag_env(name), 'missing')}" for name in BOOLEAN_FLAGS
|
||||
)
|
||||
)
|
||||
if not errors:
|
||||
print("All required CI results are complete and successful.")
|
||||
return 0
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
changed_files="${1:?usage: classify-ci-changes.sh <changed-files-list>}"
|
||||
output_file="${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
|
||||
|
||||
docs_only=true
|
||||
runtime_changed=false
|
||||
test_changed=false
|
||||
ci_changed=false
|
||||
dependency_changed=false
|
||||
release_changed=false
|
||||
windows_full_required=false
|
||||
frontend_changed=false
|
||||
tui_changed=false
|
||||
desktop_changed=false
|
||||
python_changed=false
|
||||
platform_sensitive_changed=false
|
||||
build_wheel_required=false
|
||||
full_required=false
|
||||
seen_file=false
|
||||
|
||||
mark_non_docs_changed() {
|
||||
docs_only=false
|
||||
}
|
||||
|
||||
mark_runtime_changed() {
|
||||
mark_non_docs_changed
|
||||
runtime_changed=true
|
||||
python_changed=true
|
||||
build_wheel_required=true
|
||||
}
|
||||
|
||||
mark_test_changed() {
|
||||
mark_non_docs_changed
|
||||
test_changed=true
|
||||
python_changed=true
|
||||
}
|
||||
|
||||
mark_ci_changed() {
|
||||
mark_non_docs_changed
|
||||
ci_changed=true
|
||||
python_changed=true
|
||||
}
|
||||
|
||||
mark_dependency_changed() {
|
||||
mark_runtime_changed
|
||||
dependency_changed=true
|
||||
release_changed=true
|
||||
windows_full_required=true
|
||||
}
|
||||
|
||||
mark_release_changed() {
|
||||
mark_non_docs_changed
|
||||
release_changed=true
|
||||
windows_full_required=true
|
||||
}
|
||||
|
||||
mark_frontend_changed() {
|
||||
mark_non_docs_changed
|
||||
frontend_changed=true
|
||||
}
|
||||
|
||||
mark_tui_changed() {
|
||||
mark_runtime_changed
|
||||
tui_changed=true
|
||||
}
|
||||
|
||||
mark_desktop_changed() {
|
||||
mark_non_docs_changed
|
||||
desktop_changed=true
|
||||
}
|
||||
|
||||
mark_platform_sensitive_changed() {
|
||||
mark_non_docs_changed
|
||||
platform_sensitive_changed=true
|
||||
windows_full_required=true
|
||||
}
|
||||
|
||||
mark_full_required() {
|
||||
docs_only=false
|
||||
runtime_changed=true
|
||||
test_changed=true
|
||||
ci_changed=true
|
||||
dependency_changed=true
|
||||
release_changed=true
|
||||
windows_full_required=true
|
||||
frontend_changed=true
|
||||
tui_changed=true
|
||||
desktop_changed=true
|
||||
python_changed=true
|
||||
platform_sensitive_changed=true
|
||||
build_wheel_required=true
|
||||
full_required=true
|
||||
}
|
||||
|
||||
while IFS= read -r path || [[ -n "${path}" ]]; do
|
||||
path="${path%$'\r'}"
|
||||
[[ -z "${path}" ]] && continue
|
||||
seen_file=true
|
||||
|
||||
case "${path}" in
|
||||
.ci/run-all)
|
||||
mark_full_required
|
||||
;;
|
||||
pyproject.toml | uv.lock)
|
||||
mark_dependency_changed
|
||||
;;
|
||||
opensquilla-webui/*)
|
||||
mark_frontend_changed
|
||||
;;
|
||||
src/opensquilla/cli/tui/opentui/package/*)
|
||||
mark_tui_changed
|
||||
;;
|
||||
.github/workflows/ci.yml | .github/scripts/classify-ci-changes.sh | .github/scripts/check_ci_results.py | .github/scripts/windows_test_shards.py)
|
||||
# Changes to the gate itself must exercise every path it can suppress.
|
||||
mark_full_required
|
||||
;;
|
||||
.github/workflows/wheelhouse-release.yml)
|
||||
mark_ci_changed
|
||||
mark_release_changed
|
||||
;;
|
||||
.github/workflows/*)
|
||||
mark_full_required
|
||||
;;
|
||||
.github/scripts/verify-release-profile-preservation.py)
|
||||
mark_ci_changed
|
||||
mark_release_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
.github/scripts/*)
|
||||
mark_full_required
|
||||
;;
|
||||
tests/test_scripts/test_build_wheelhouse_zip.py | tests/test_install_scripts.py | tests/test_root_start_scripts.py | tests/test_release_consistency.py | tests/test_public_release_hygiene.py)
|
||||
mark_test_changed
|
||||
mark_release_changed
|
||||
;;
|
||||
tests/test_tools/test_shell_* | tests/test_tools/test_path_* | tests/test_sandbox/* | tests/test_desktop/* | tests/test_compat/* | tests/test_recovery/* | tests/test_migration/* | tests/test_migrations/* | tests/test_persistence/* | tests/test_session/* | tests/test_scheduler/* | tests/test_uninstall/* | tests/test_packaging/*)
|
||||
mark_test_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
tests/test_onboarding/* | tests/test_provider/* | tests/test_provider*.py)
|
||||
mark_test_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
tests/functional/test_gateway_*_e2e.py)
|
||||
mark_test_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
tests/*)
|
||||
# New or unclassified tests fail closed to the Windows high-risk suite.
|
||||
mark_test_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
scripts/build_wheelhouse_zip.py | scripts/install_source.sh | scripts/install_source.ps1)
|
||||
mark_runtime_changed
|
||||
mark_release_changed
|
||||
;;
|
||||
install.sh | install.ps1 | start.sh | start.ps1 | README.release.md | RELEASES.md)
|
||||
mark_release_changed
|
||||
;;
|
||||
desktop/electron/scripts/test-packaged-update-policy.mjs)
|
||||
mark_desktop_changed
|
||||
mark_platform_sensitive_changed
|
||||
mark_release_changed
|
||||
;;
|
||||
desktop/*)
|
||||
mark_platform_sensitive_changed
|
||||
mark_desktop_changed
|
||||
;;
|
||||
src/opensquilla/uninstall/*)
|
||||
mark_runtime_changed
|
||||
mark_release_changed
|
||||
;;
|
||||
src/opensquilla/recovery/* | src/opensquilla/migration/* | src/opensquilla/persistence/* | src/opensquilla/memory/* | src/opensquilla/session/* | src/opensquilla/scheduler/* | src/opensquilla/sandbox/* | src/opensquilla/tools/boundary.py | src/opensquilla/tools/builtin/code_exec.py | src/opensquilla/tools/builtin/filesystem.py | src/opensquilla/tools/builtin/git.py | src/opensquilla/tools/builtin/shell.py | src/opensquilla/tools/builtin/shell_policy.py | src/opensquilla/tools/path_* | src/opensquilla/tools/policy* | src/opensquilla/tools/write_*)
|
||||
mark_runtime_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
src/opensquilla/onboarding/* | src/opensquilla/provider/*)
|
||||
mark_runtime_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
migrations/*)
|
||||
mark_runtime_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
src/* | scripts/*)
|
||||
# Unknown runtime surfaces are high risk until explicitly proven otherwise.
|
||||
mark_runtime_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
docs/* | README.md | README.*.md | CHANGELOG.md | CODE_OF_CONDUCT.md | CONTRIBUTING.md | MIGRATION.md | SECURITY.md | SUPPORT.md | THIRD_PARTY_NOTICES.md | META_SKILL_GUIDE.md | .github/pull_request_template.md | .github/ISSUE_TEMPLATE/*)
|
||||
;;
|
||||
*)
|
||||
# Fail closed for new non-documentation paths.
|
||||
mark_runtime_changed
|
||||
mark_platform_sensitive_changed
|
||||
;;
|
||||
esac
|
||||
done < "${changed_files}"
|
||||
|
||||
if [[ "${seen_file}" == "false" ]]; then
|
||||
mark_full_required
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'docs_only=%s\n' "${docs_only}"
|
||||
printf 'runtime_changed=%s\n' "${runtime_changed}"
|
||||
printf 'test_changed=%s\n' "${test_changed}"
|
||||
printf 'ci_changed=%s\n' "${ci_changed}"
|
||||
printf 'dependency_changed=%s\n' "${dependency_changed}"
|
||||
printf 'release_changed=%s\n' "${release_changed}"
|
||||
printf 'windows_full_required=%s\n' "${windows_full_required}"
|
||||
printf 'frontend_changed=%s\n' "${frontend_changed}"
|
||||
printf 'tui_changed=%s\n' "${tui_changed}"
|
||||
printf 'desktop_changed=%s\n' "${desktop_changed}"
|
||||
printf 'python_changed=%s\n' "${python_changed}"
|
||||
printf 'platform_sensitive_changed=%s\n' "${platform_sensitive_changed}"
|
||||
printf 'build_wheel_required=%s\n' "${build_wheel_required}"
|
||||
printf 'full_required=%s\n' "${full_required}"
|
||||
} >> "${output_file}"
|
||||
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synchronize OpenSquilla issue state after pull request lifecycle changes.
|
||||
|
||||
This script is intentionally safe for `pull_request_target`: it treats pull
|
||||
request text as data only and never executes code from the pull request head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, NamedTuple
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
HAS_LINKED_PR_LABEL = "has-linked-pr"
|
||||
MERGED_TO_DEV_LABEL = "merged-to-dev"
|
||||
NEEDS_VERIFICATION_LABEL = "needs-verification"
|
||||
|
||||
LABEL_DEFINITIONS = {
|
||||
HAS_LINKED_PR_LABEL: {
|
||||
"color": "C5DEF5",
|
||||
"description": "An open pull request is linked to this issue",
|
||||
},
|
||||
MERGED_TO_DEV_LABEL: {
|
||||
"color": "5319E7",
|
||||
"description": "A related fix has merged to dev but has not necessarily shipped",
|
||||
},
|
||||
NEEDS_VERIFICATION_LABEL: {
|
||||
"color": "C5DEF5",
|
||||
"description": "Maintainers or reporters should retest the current behavior",
|
||||
},
|
||||
}
|
||||
|
||||
CLOSING_KEYWORDS = frozenset(
|
||||
{
|
||||
"close",
|
||||
"closes",
|
||||
"closed",
|
||||
"fix",
|
||||
"fixes",
|
||||
"fixed",
|
||||
"resolve",
|
||||
"resolves",
|
||||
"resolved",
|
||||
}
|
||||
)
|
||||
#: Mutation statuses that describe the target's state or the token's scope on
|
||||
#: that one target (labeling a pull request needs pull-requests write scope
|
||||
#: this workflow does not request; locked or deleted issues answer 403/410)
|
||||
#: rather than a broken sync setup — the affected action is logged and
|
||||
#: skipped instead of failing every remaining action in the job.
|
||||
TOLERATED_MUTATION_STATUSES = frozenset({403, 404, 410})
|
||||
REFERENCE_KEYWORDS = frozenset({"ref", "refs", "reference", "references"})
|
||||
OPEN_PR_ACTIONS = frozenset({"opened", "reopened", "edited"})
|
||||
KEYWORD_RE = re.compile(
|
||||
r"\b(?P<keyword>close|closes|closed|fix|fixes|fixed|resolve|resolves|"
|
||||
r"resolved|ref|refs|reference|references)\s*:?\s+(?P<ref>"
|
||||
r"#\d+|"
|
||||
r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+|"
|
||||
r"https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/\d+"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LOCAL_REF_RE = re.compile(r"^#(?P<number>\d+)$")
|
||||
REPO_REF_RE = re.compile(
|
||||
r"^(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)#(?P<number>\d+)$"
|
||||
)
|
||||
ISSUE_URL_RE = re.compile(
|
||||
r"^https://github\.com/(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)/"
|
||||
r"issues/(?P<number>\d+)$"
|
||||
)
|
||||
|
||||
|
||||
class ParsedIssueLinks(NamedTuple):
|
||||
closing: tuple[int, ...]
|
||||
references: tuple[int, ...]
|
||||
|
||||
@property
|
||||
def all(self) -> tuple[int, ...]:
|
||||
return tuple(dict.fromkeys((*self.closing, *self.references)))
|
||||
|
||||
|
||||
class IssueSyncAction(NamedTuple):
|
||||
issue_number: int
|
||||
kind: str
|
||||
pr_number: int
|
||||
pr_url: str
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
def __init__(self, *, token: str, repository: str) -> None:
|
||||
self._token = token
|
||||
self._repository = repository
|
||||
self._api_root = f"https://api.github.com/repos/{repository}"
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: dict[str, Any] | None = None,
|
||||
ignore_statuses: set[int] | None = None,
|
||||
) -> Any:
|
||||
ignore_statuses = ignore_statuses or set()
|
||||
data = None
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
request = Request(
|
||||
f"{self._api_root}{path}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
raw = response.read()
|
||||
except HTTPError as exc:
|
||||
if exc.code in ignore_statuses:
|
||||
return None
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"GitHub API {method} {path} failed: {exc.code} {body}") from exc
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def get_issue(self, issue_number: int) -> dict[str, Any] | None:
|
||||
"""Fetch an issue, or None when it is missing or unreadable.
|
||||
|
||||
Pull requests are returned too (the REST issues endpoint covers
|
||||
both) — callers use the ``pull_request`` key to tell them apart.
|
||||
"""
|
||||
found = self.request_json(
|
||||
"GET",
|
||||
f"/issues/{issue_number}",
|
||||
ignore_statuses=TOLERATED_MUTATION_STATUSES,
|
||||
)
|
||||
return found if isinstance(found, dict) else None
|
||||
|
||||
def ensure_label(self, name: str) -> None:
|
||||
definition = LABEL_DEFINITIONS[name]
|
||||
label_name = quote(name, safe="")
|
||||
found = self.request_json(
|
||||
"GET",
|
||||
f"/labels/{label_name}",
|
||||
ignore_statuses={404},
|
||||
)
|
||||
if found is not None:
|
||||
return
|
||||
self.request_json(
|
||||
"POST",
|
||||
"/labels",
|
||||
payload={
|
||||
"name": name,
|
||||
"color": definition["color"],
|
||||
"description": definition["description"],
|
||||
},
|
||||
# 422: another run created the label between the GET and here.
|
||||
ignore_statuses=TOLERATED_MUTATION_STATUSES | {422},
|
||||
)
|
||||
|
||||
def add_labels(self, issue_number: int, labels: list[str]) -> None:
|
||||
for label in labels:
|
||||
self.ensure_label(label)
|
||||
self.request_json(
|
||||
"POST",
|
||||
f"/issues/{issue_number}/labels",
|
||||
payload={"labels": labels},
|
||||
ignore_statuses=TOLERATED_MUTATION_STATUSES,
|
||||
)
|
||||
|
||||
def remove_label(self, issue_number: int, label: str) -> None:
|
||||
label_name = quote(label, safe="")
|
||||
self.request_json(
|
||||
"DELETE",
|
||||
f"/issues/{issue_number}/labels/{label_name}",
|
||||
ignore_statuses=TOLERATED_MUTATION_STATUSES,
|
||||
)
|
||||
|
||||
def list_comments(self, issue_number: int) -> list[dict[str, Any]]:
|
||||
all_comments: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
comments = self.request_json(
|
||||
"GET",
|
||||
f"/issues/{issue_number}/comments?per_page=100&page={page}",
|
||||
)
|
||||
if not isinstance(comments, list) or not comments:
|
||||
return all_comments
|
||||
all_comments.extend(comments)
|
||||
if len(comments) < 100:
|
||||
return all_comments
|
||||
page += 1
|
||||
|
||||
def create_comment(self, issue_number: int, body: str) -> None:
|
||||
self.request_json(
|
||||
"POST",
|
||||
f"/issues/{issue_number}/comments",
|
||||
payload={"body": body},
|
||||
# Locked conversations reject comments with 403; the labels above
|
||||
# still applied, so the sync outcome is preserved.
|
||||
ignore_statuses=TOLERATED_MUTATION_STATUSES,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_repo_ref(raw_ref: str, *, owner: str, repo: str) -> int | None:
|
||||
local = LOCAL_REF_RE.match(raw_ref)
|
||||
if local is not None:
|
||||
return int(local.group("number"))
|
||||
|
||||
repo_ref = REPO_REF_RE.match(raw_ref)
|
||||
if repo_ref is not None:
|
||||
if repo_ref.group("owner").lower() != owner.lower():
|
||||
return None
|
||||
if repo_ref.group("repo").lower() != repo.lower():
|
||||
return None
|
||||
return int(repo_ref.group("number"))
|
||||
|
||||
issue_url = ISSUE_URL_RE.match(raw_ref)
|
||||
if issue_url is not None:
|
||||
if issue_url.group("owner").lower() != owner.lower():
|
||||
return None
|
||||
if issue_url.group("repo").lower() != repo.lower():
|
||||
return None
|
||||
return int(issue_url.group("number"))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_linked_issues(body: str | None, *, owner: str, repo: str) -> ParsedIssueLinks:
|
||||
closing: list[int] = []
|
||||
references: list[int] = []
|
||||
|
||||
for match in KEYWORD_RE.finditer(body or ""):
|
||||
issue_number = _normalize_repo_ref(match.group("ref"), owner=owner, repo=repo)
|
||||
if issue_number is None:
|
||||
continue
|
||||
keyword = match.group("keyword").lower()
|
||||
if keyword in CLOSING_KEYWORDS:
|
||||
closing.append(issue_number)
|
||||
elif keyword in REFERENCE_KEYWORDS:
|
||||
references.append(issue_number)
|
||||
|
||||
return ParsedIssueLinks(
|
||||
closing=tuple(dict.fromkeys(closing)),
|
||||
references=tuple(dict.fromkeys(references)),
|
||||
)
|
||||
|
||||
|
||||
def plan_issue_sync_actions(event: dict[str, Any]) -> tuple[IssueSyncAction, ...]:
|
||||
action = event.get("action")
|
||||
pr = event.get("pull_request") or {}
|
||||
repository = event.get("repository") or {}
|
||||
full_name = repository.get("full_name") or ""
|
||||
if "/" not in full_name:
|
||||
return ()
|
||||
owner, repo = full_name.split("/", 1)
|
||||
|
||||
pr_number = int(pr["number"])
|
||||
pr_url = str(pr.get("html_url") or "")
|
||||
parsed = parse_linked_issues(pr.get("body"), owner=owner, repo=repo)
|
||||
|
||||
if action in OPEN_PR_ACTIONS:
|
||||
return tuple(
|
||||
IssueSyncAction(
|
||||
issue_number=issue_number,
|
||||
kind="linked_pr_open",
|
||||
pr_number=pr_number,
|
||||
pr_url=pr_url,
|
||||
)
|
||||
for issue_number in parsed.all
|
||||
)
|
||||
|
||||
if action != "closed":
|
||||
return ()
|
||||
|
||||
if pr.get("merged") is True and (pr.get("base") or {}).get("ref") == "dev":
|
||||
closing_actions = tuple(
|
||||
IssueSyncAction(
|
||||
issue_number=issue_number,
|
||||
kind="merged_to_dev",
|
||||
pr_number=pr_number,
|
||||
pr_url=pr_url,
|
||||
)
|
||||
for issue_number in parsed.closing
|
||||
)
|
||||
closing_issue_numbers = set(parsed.closing)
|
||||
reference_cleanup_actions = tuple(
|
||||
IssueSyncAction(
|
||||
issue_number=issue_number,
|
||||
kind="remove_linked_pr",
|
||||
pr_number=pr_number,
|
||||
pr_url=pr_url,
|
||||
)
|
||||
for issue_number in parsed.references
|
||||
if issue_number not in closing_issue_numbers
|
||||
)
|
||||
return (*closing_actions, *reference_cleanup_actions)
|
||||
|
||||
if pr.get("merged") is not True:
|
||||
return tuple(
|
||||
IssueSyncAction(
|
||||
issue_number=issue_number,
|
||||
kind="closed_unmerged",
|
||||
pr_number=pr_number,
|
||||
pr_url=pr_url,
|
||||
)
|
||||
for issue_number in parsed.all
|
||||
)
|
||||
|
||||
return tuple(
|
||||
IssueSyncAction(
|
||||
issue_number=issue_number,
|
||||
kind="remove_linked_pr",
|
||||
pr_number=pr_number,
|
||||
pr_url=pr_url,
|
||||
)
|
||||
for issue_number in parsed.all
|
||||
)
|
||||
|
||||
|
||||
def comment_marker(*, kind: str, pr_number: int) -> str:
|
||||
marker_kind = kind.replace("_", "-")
|
||||
return f"<!-- opensquilla-issue-link-sync:{marker_kind}:pr-{pr_number} -->"
|
||||
|
||||
|
||||
def has_marker(comments: list[dict[str, Any]], marker: str) -> bool:
|
||||
return any(marker in str(comment.get("body") or "") for comment in comments)
|
||||
|
||||
|
||||
def _merged_to_dev_comment(action: IssueSyncAction) -> str:
|
||||
marker = comment_marker(kind=action.kind, pr_number=action.pr_number)
|
||||
return (
|
||||
f"{marker}\n"
|
||||
f"The linked fix for this issue has merged to `dev` via #{action.pr_number} "
|
||||
f"({action.pr_url}). Keeping it open for verification before release."
|
||||
)
|
||||
|
||||
|
||||
def apply_action(client: GitHubClient, action: IssueSyncAction) -> None:
|
||||
if action.kind == "linked_pr_open":
|
||||
client.add_labels(action.issue_number, [HAS_LINKED_PR_LABEL])
|
||||
return
|
||||
|
||||
if action.kind == "merged_to_dev":
|
||||
client.add_labels(
|
||||
action.issue_number,
|
||||
[MERGED_TO_DEV_LABEL, NEEDS_VERIFICATION_LABEL],
|
||||
)
|
||||
client.remove_label(action.issue_number, HAS_LINKED_PR_LABEL)
|
||||
marker = comment_marker(kind=action.kind, pr_number=action.pr_number)
|
||||
if not has_marker(client.list_comments(action.issue_number), marker):
|
||||
client.create_comment(action.issue_number, _merged_to_dev_comment(action))
|
||||
return
|
||||
|
||||
if action.kind in {"closed_unmerged", "remove_linked_pr"}:
|
||||
client.remove_label(action.issue_number, HAS_LINKED_PR_LABEL)
|
||||
return
|
||||
|
||||
raise ValueError(f"Unsupported issue sync action: {action.kind}")
|
||||
|
||||
|
||||
def classify_sync_target(client: GitHubClient, issue_number: int) -> str:
|
||||
"""Classify a parsed ``#N`` reference before mutating it.
|
||||
|
||||
``#N`` is textual and shares one number space between issues and pull
|
||||
requests, so a PR body saying "the main path was fixed in #535" parses as
|
||||
an issue link even though 535 is a pull request. Labeling a PR needs a
|
||||
scope this workflow does not hold (and is not the sync's job), so only
|
||||
``"issue"`` targets are acted on; ``"pull_request"`` and ``"missing"``
|
||||
are skipped by the caller.
|
||||
"""
|
||||
found = client.get_issue(issue_number)
|
||||
if found is None:
|
||||
return "missing"
|
||||
if found.get("pull_request"):
|
||||
return "pull_request"
|
||||
return "issue"
|
||||
|
||||
|
||||
def _load_event(path: str) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
||||
repository = os.environ.get("GITHUB_REPOSITORY")
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
|
||||
if not event_path:
|
||||
print("GITHUB_EVENT_PATH is required.", file=sys.stderr)
|
||||
return 2
|
||||
if not repository:
|
||||
print("GITHUB_REPOSITORY is required.", file=sys.stderr)
|
||||
return 2
|
||||
if not token:
|
||||
print("GITHUB_TOKEN is required.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
event = _load_event(event_path)
|
||||
actions = plan_issue_sync_actions(event)
|
||||
if not actions:
|
||||
print("No issue sync actions required.")
|
||||
return 0
|
||||
|
||||
client = GitHubClient(token=token, repository=repository)
|
||||
for action in actions:
|
||||
target_kind = classify_sync_target(client, action.issue_number)
|
||||
if target_kind != "issue":
|
||||
print(
|
||||
f"Skipping {action.kind} for #{action.issue_number} "
|
||||
f"from PR #{action.pr_number}: target is {target_kind}."
|
||||
)
|
||||
continue
|
||||
print(
|
||||
f"Applying {action.kind} for issue #{action.issue_number} "
|
||||
f"from PR #{action.pr_number}."
|
||||
)
|
||||
apply_action(client, action)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
base_ref="${PR_BASE_REF:-${BASE_REF:-${BASE:-}}}"
|
||||
event_path="${GITHUB_EVENT_PATH:-}"
|
||||
|
||||
if [[ -z "${base_ref}" ]]; then
|
||||
{
|
||||
echo "::error title=Missing PR target::PR_BASE_REF is required."
|
||||
echo "Unable to validate the pull request target branch."
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${base_ref}" == "main" ]]; then
|
||||
echo "Pull request targets main."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
event_label_names() {
|
||||
if [[ -n "${PR_LABELS:-}" ]]; then
|
||||
tr ',' '\n' <<< "${PR_LABELS}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "${event_path}" || ! -f "${event_path}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local python_bin=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python_bin="python3"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
python_bin="python"
|
||||
fi
|
||||
|
||||
if [[ -n "${python_bin}" ]]; then
|
||||
"${python_bin}" - "${event_path}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as event_file:
|
||||
event = json.load(event_file)
|
||||
|
||||
for label in event.get("pull_request", {}).get("labels", []):
|
||||
name = label.get("name")
|
||||
if name:
|
||||
print(name)
|
||||
PY
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -r '.pull_request.labels[]?.name // empty' "${event_path}"
|
||||
fi
|
||||
}
|
||||
|
||||
has_allowed_label() {
|
||||
local allowed_kind="${1:?allowed kind is required}"
|
||||
local label
|
||||
|
||||
while IFS= read -r label; do
|
||||
case "${allowed_kind}:${label}" in
|
||||
staging:maintainer-staging | staging:collaboration)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done < <(event_label_names)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
is_staging_branch() {
|
||||
case "${base_ref}" in
|
||||
sandbox-* | integration/* | staging/* | release/* | hotfix/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
if is_staging_branch || has_allowed_label staging; then
|
||||
echo "Pull request targets a staging/collaboration branch."
|
||||
echo "This is not a final integration path; final integration should target main."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "::error title=Wrong PR target::Ordinary pull requests should target main."
|
||||
echo "Use sandbox-*, integration/*, staging/*, release/*, hotfix/*, or a maintainer-staging/collaboration label for maintainer collaboration PRs."
|
||||
echo "Retarget this pull request to main or an approved staging/collaboration branch."
|
||||
} >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Warn when a pull request body misses OpenSquilla governance fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class RequiredField(NamedTuple):
|
||||
name: str
|
||||
pattern: re.Pattern[str]
|
||||
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
RequiredField("Scope boundary", re.compile(r"(?im)^\s*Scope boundary\s*:")),
|
||||
RequiredField("Base branch", re.compile(r"(?im)^\s*Base branch\s*:")),
|
||||
RequiredField(
|
||||
"Target exception",
|
||||
re.compile(r"(?im)^\s*(?:Target exception|Main exception)\s*:"),
|
||||
),
|
||||
RequiredField("Linked issue", re.compile(r"(?im)^\s*Linked issue\s*:")),
|
||||
RequiredField("Release note", re.compile(r"(?im)^\s*Release note\s*:")),
|
||||
RequiredField("Tests", re.compile(r"(?im)^\s*(?:#+\s*)?Tests\s*:?\s*$")),
|
||||
RequiredField("Maintainer live check", re.compile(r"(?im)^\s*Maintainer live check\s*:")),
|
||||
RequiredField("Third-party origin", re.compile(r"(?im)^\s*Third-party origin\s*:")),
|
||||
)
|
||||
|
||||
|
||||
def missing_fields(body: str | None) -> list[str]:
|
||||
text = body or ""
|
||||
return [field.name for field in REQUIRED_FIELDS if field.pattern.search(text) is None]
|
||||
|
||||
|
||||
def _annotation_escape(value: str) -> str:
|
||||
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
||||
|
||||
|
||||
def _warning(title: str, message: str) -> None:
|
||||
print(f"::warning title={_annotation_escape(title)}::{_annotation_escape(message)}")
|
||||
|
||||
|
||||
def _is_strict() -> bool:
|
||||
return os.environ.get("PR_BODY_LINT_STRICT", "0").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def _load_pr_body(event_path: str) -> str | None:
|
||||
with Path(event_path).open(encoding="utf-8") as event_file:
|
||||
event = json.load(event_file)
|
||||
return (event.get("pull_request") or {}).get("body")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
strict = _is_strict()
|
||||
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
||||
if not event_path:
|
||||
_warning("Missing PR event", "GITHUB_EVENT_PATH is not set; skipping PR body lint.")
|
||||
return 1 if strict else 0
|
||||
|
||||
try:
|
||||
body = _load_pr_body(event_path)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_warning("Unreadable PR event", f"Unable to read pull request event: {exc}")
|
||||
return 1 if strict else 0
|
||||
|
||||
missing = missing_fields(body)
|
||||
if not missing:
|
||||
print("PR body includes required governance fields.")
|
||||
return 0
|
||||
|
||||
for field in missing:
|
||||
_warning(
|
||||
"Missing PR template field",
|
||||
f"Add the `{field}:` field to make review intent explicit.",
|
||||
)
|
||||
|
||||
print(
|
||||
"PR body lint is warning-only. Maintainers may harden it by setting "
|
||||
"PR_BODY_LINT_STRICT=1 after existing pull requests are migrated."
|
||||
)
|
||||
return 1 if strict else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 2 ]]; then
|
||||
echo "usage: $0 CANDIDATE_DMG LABEL" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
candidate_dmg="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
label="$2"
|
||||
if [[ ! "${label}" =~ ^[A-Za-z0-9._-]{1,80}$ ]]; then
|
||||
echo "label must contain only ASCII letters, digits, dot, underscore, or dash" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
sandbox="${RUNNER_TEMP}/opensquilla-release-preservation-${label}"
|
||||
old_dir="${sandbox}/rc3"
|
||||
old_mount="${sandbox}/rc3-mount"
|
||||
candidate_mount="${sandbox}/candidate-mount"
|
||||
install_root="${sandbox}/Applications"
|
||||
user_data="${sandbox}/user-data/OpenSquilla"
|
||||
profile="${user_data}/opensquilla"
|
||||
probe="${GITHUB_WORKSPACE}/.github/scripts/verify-release-profile-preservation.py"
|
||||
old_asset="OpenSquilla-0.5.0-rc3-mac-arm64.dmg"
|
||||
mkdir -p "${old_dir}" "${old_mount}" "${candidate_mount}" "${install_root}" "${user_data}"
|
||||
|
||||
cleanup() {
|
||||
hdiutil detach "${candidate_mount}" -quiet >/dev/null 2>&1 || true
|
||||
hdiutil detach "${old_mount}" -quiet >/dev/null 2>&1 || true
|
||||
if [[ -n "${app_pid:-}" ]]; then
|
||||
kill "${app_pid}" >/dev/null 2>&1 || true
|
||||
wait "${app_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
gh release download v0.5.0rc3 \
|
||||
--repo opensquilla/opensquilla \
|
||||
--pattern "${old_asset}" \
|
||||
--dir "${old_dir}"
|
||||
old_dmg="${old_dir}/${old_asset}"
|
||||
test -f "${old_dmg}"
|
||||
test -f "${candidate_dmg}"
|
||||
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "${old_mount}" "${old_dmg}"
|
||||
ditto "${old_mount}/OpenSquilla.app" "${install_root}/OpenSquilla.app"
|
||||
hdiutil detach "${old_mount}" -quiet
|
||||
|
||||
python "${probe}" seed --home "${profile}" --label "${label}"
|
||||
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "${candidate_mount}" "${candidate_dmg}"
|
||||
mv "${install_root}/OpenSquilla.app" "${install_root}/OpenSquilla.rc3.app"
|
||||
ditto "${candidate_mount}/OpenSquilla.app" "${install_root}/OpenSquilla.app"
|
||||
hdiutil detach "${candidate_mount}" -quiet
|
||||
python "${probe}" verify --home "${profile}" --label "${label}"
|
||||
|
||||
app_binary="${install_root}/OpenSquilla.app/Contents/MacOS/OpenSquilla"
|
||||
test -x "${app_binary}"
|
||||
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE=1 \
|
||||
"${app_binary}" --use-mock-keychain "--user-data-dir=${user_data}" \
|
||||
>"${sandbox}/candidate-desktop.log" 2>&1 &
|
||||
app_pid=$!
|
||||
sleep 8
|
||||
kill -0 "${app_pid}"
|
||||
kill "${app_pid}" || true
|
||||
wait "${app_pid}" || true
|
||||
app_pid=""
|
||||
|
||||
gateway_binary="$(find \
|
||||
"${install_root}/OpenSquilla.app/Contents/Resources/runtime/gateway" \
|
||||
-type f -name opensquilla-gateway -perm -111 -print -quit)"
|
||||
test -x "${gateway_binary}"
|
||||
OPENSQUILLA_RECOVERY_OFFLINE=1 "${gateway_binary}" recovery inspect \
|
||||
--home "${profile}" --json >"${sandbox}/candidate-inspect.json"
|
||||
python - "${profile}" "${sandbox}/candidate-inspect.json" <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
home = Path(sys.argv[1]).resolve()
|
||||
report = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
|
||||
assert report["outcome"] in {"ready", "attention"}, report
|
||||
assert Path(report["primary_home"]).resolve() == home, report
|
||||
assert Path(report["effective_workspace"]).resolve() == home / "workspace", report
|
||||
configured_state = [
|
||||
candidate
|
||||
for candidate in report["candidates"]
|
||||
if candidate["kind"] == "state" and candidate["configured"] and candidate["valid"]
|
||||
]
|
||||
assert len(configured_state) == 1, report
|
||||
assert Path(configured_state[0]["path"]).resolve() == home / "state", report
|
||||
PY
|
||||
python "${probe}" verify --home "${profile}" --label "${label}"
|
||||
|
||||
python - "${install_root}/OpenSquilla.app" "${install_root}/OpenSquilla.rc3.app" <<'PY'
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
for app_path in sys.argv[1:]:
|
||||
shutil.rmtree(app_path)
|
||||
PY
|
||||
test ! -e "${install_root}/OpenSquilla.app"
|
||||
test ! -e "${install_root}/OpenSquilla.rc3.app"
|
||||
python "${probe}" verify --home "${profile}" --label "${label}"
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed and verify synthetic Desktop profile data around installer operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_LABEL_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,80}")
|
||||
|
||||
|
||||
def _validated_label(value: str) -> str:
|
||||
if _LABEL_PATTERN.fullmatch(value) is None:
|
||||
raise ValueError("label must contain only ASCII letters, digits, dot, underscore, or dash")
|
||||
return value
|
||||
|
||||
|
||||
def _workspace_files(label: str) -> dict[str, str]:
|
||||
return {
|
||||
"IDENTITY.md": f"# Synthetic {label} identity sentinel\n",
|
||||
"USER.md": f"# Synthetic {label} user\n",
|
||||
"SOUL.md": f"# Synthetic {label} soul\n",
|
||||
"MEMORY.md": f"# Synthetic {label} memory\n",
|
||||
}
|
||||
|
||||
|
||||
def _config_text(home: Path, label: str) -> str:
|
||||
return (
|
||||
f"# Synthetic {label} release-preservation profile\n"
|
||||
f"state_dir = {json.dumps(str(home / 'state'))}\n"
|
||||
f"workspace_dir = {json.dumps(str(home / 'workspace'))}\n"
|
||||
)
|
||||
|
||||
|
||||
def seed_profile(home: Path, label: str) -> None:
|
||||
"""Create a minimal synthetic RC3-shaped profile without replacing any file."""
|
||||
|
||||
home = home.resolve()
|
||||
workspace = home / "workspace"
|
||||
state = home / "state"
|
||||
protected = [home / "config.toml", state / "sessions.db"] + [
|
||||
workspace / name for name in _workspace_files(label)
|
||||
]
|
||||
existing = [path for path in protected if path.exists()]
|
||||
if existing:
|
||||
raise FileExistsError(f"refusing to overwrite preservation fixture: {existing[0]}")
|
||||
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
for name, expected in _workspace_files(label).items():
|
||||
(workspace / name).write_text(expected, encoding="utf-8", newline="")
|
||||
(home / "config.toml").write_text(_config_text(home, label), encoding="utf-8", newline="")
|
||||
|
||||
with sqlite3.connect(state / "sessions.db") as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE release_preservation_chat (id TEXT PRIMARY KEY, body TEXT NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO release_preservation_chat (id, body) VALUES (?, ?)",
|
||||
(f"{label}-session", f"synthetic retained chat ({label})"),
|
||||
)
|
||||
result = connection.execute("PRAGMA quick_check").fetchone()
|
||||
if result != ("ok",):
|
||||
raise RuntimeError(f"seeded sessions.db failed PRAGMA quick_check: {result!r}")
|
||||
|
||||
|
||||
def verify_profile(home: Path, label: str) -> None:
|
||||
"""Verify exact fixture bytes and a read-only SQLite integrity probe."""
|
||||
|
||||
home = home.resolve()
|
||||
workspace = home / "workspace"
|
||||
state = home / "state"
|
||||
for name, expected in _workspace_files(label).items():
|
||||
actual = (workspace / name).read_text(encoding="utf-8")
|
||||
if actual != expected:
|
||||
raise AssertionError(f"{name} changed while installing or uninstalling Desktop")
|
||||
|
||||
actual_config = (home / "config.toml").read_text(encoding="utf-8")
|
||||
if actual_config != _config_text(home, label):
|
||||
raise AssertionError("config.toml changed while installing or uninstalling Desktop")
|
||||
|
||||
database = state / "sessions.db"
|
||||
with sqlite3.connect(f"{database.as_uri()}?mode=ro", uri=True) as connection:
|
||||
quick_check = connection.execute("PRAGMA quick_check").fetchone()
|
||||
if quick_check != ("ok",):
|
||||
raise AssertionError(f"sessions.db failed PRAGMA quick_check: {quick_check!r}")
|
||||
row = connection.execute("SELECT id, body FROM release_preservation_chat").fetchone()
|
||||
expected_row = (f"{label}-session", f"synthetic retained chat ({label})")
|
||||
if row != expected_row:
|
||||
raise AssertionError(f"sessions.db retained-chat row changed: {row!r}")
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("seed", "verify"))
|
||||
parser.add_argument("--home", type=Path, required=True)
|
||||
parser.add_argument("--label", type=_validated_label, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parser().parse_args()
|
||||
try:
|
||||
if args.operation == "seed":
|
||||
seed_profile(args.home, args.label)
|
||||
print(f"profile preservation fixture seeded: {args.home}")
|
||||
else:
|
||||
verify_profile(args.home, args.label)
|
||||
print(f"profile preservation verified: {args.home}")
|
||||
except (
|
||||
AssertionError,
|
||||
FileExistsError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
sqlite3.Error,
|
||||
ValueError,
|
||||
) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,126 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CandidateInstaller,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]{1,80}$')]
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repository = 'opensquilla/opensquilla'
|
||||
$oldTag = 'v0.5.0rc3'
|
||||
$oldAsset = 'OpenSquilla-0.5.0-rc3-win-x64.exe'
|
||||
$candidate = (Resolve-Path -LiteralPath $CandidateInstaller).Path
|
||||
$sandbox = Join-Path $env:RUNNER_TEMP "opensquilla-release-preservation-$Label"
|
||||
$oldDir = Join-Path $sandbox 'rc3'
|
||||
$installDir = Join-Path $sandbox 'OpenSquilla'
|
||||
$appData = Join-Path $sandbox 'appdata'
|
||||
$userData = Join-Path $appData 'OpenSquilla'
|
||||
$profile = Join-Path $userData 'opensquilla'
|
||||
$probe = Join-Path $PWD '.github\scripts\verify-release-profile-preservation.py'
|
||||
$env:APPDATA = $appData
|
||||
$env:OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE = '1'
|
||||
$env:OPENSQUILLA_RECOVERY_OFFLINE = '1'
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $oldDir, $appData | Out-Null
|
||||
gh release download $oldTag --repo $repository --pattern $oldAsset --dir $oldDir
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to download the RC3 Windows installer.' }
|
||||
$oldInstaller = Join-Path $oldDir $oldAsset
|
||||
|
||||
function Stop-InstalledProcesses {
|
||||
Get-Process -Name 'OpenSquilla', 'opensquilla-gateway' -ErrorAction SilentlyContinue |
|
||||
ForEach-Object {
|
||||
try {
|
||||
$path = if ($_.Path) { [IO.Path]::GetFullPath($_.Path) } else { '' }
|
||||
$prefix = [IO.Path]::GetFullPath($installDir + [IO.Path]::DirectorySeparatorChar)
|
||||
if ($path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
& taskkill.exe /PID $_.Id /T /F 2>$null | Out-Null
|
||||
}
|
||||
} catch {
|
||||
if ($_.Exception.Message -notmatch 'exited|cannot find|No process') { throw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$old = Start-Process -FilePath $oldInstaller -ArgumentList @('/S', "/D=$installDir") `
|
||||
-Wait -PassThru
|
||||
if ($old.ExitCode -ne 0) { throw "RC3 installer failed with exit code $($old.ExitCode)." }
|
||||
|
||||
python $probe seed --home $profile --label $Label
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to seed the synthetic RC3 profile.' }
|
||||
|
||||
$installed = Start-Process -FilePath $candidate -ArgumentList @('/S', "/D=$installDir") `
|
||||
-Wait -PassThru
|
||||
if ($installed.ExitCode -ne 0) {
|
||||
throw "Candidate installer failed with exit code $($installed.ExitCode)."
|
||||
}
|
||||
python $probe verify --home $profile --label $Label
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Candidate installation changed RC3 profile data.' }
|
||||
|
||||
$app = Join-Path $installDir 'OpenSquilla.exe'
|
||||
if (-not (Test-Path -LiteralPath $app -PathType Leaf)) {
|
||||
throw 'Candidate installation did not publish OpenSquilla.exe.'
|
||||
}
|
||||
$launched = Start-Process -FilePath $app `
|
||||
-ArgumentList @('--use-mock-keychain', "--user-data-dir=$userData") -PassThru
|
||||
Start-Sleep -Seconds 8
|
||||
if ($launched.HasExited) {
|
||||
throw "Candidate Desktop exited during launch verification: $($launched.ExitCode)"
|
||||
}
|
||||
Stop-InstalledProcesses
|
||||
|
||||
$gateway = Get-ChildItem -Path (Join-Path $installDir 'resources\runtime\gateway') `
|
||||
-Filter 'opensquilla-gateway.exe' -File -Recurse | Select-Object -First 1
|
||||
if (-not $gateway) { throw 'Packaged recovery CLI was not found.' }
|
||||
$inspectionRaw = & $gateway.FullName recovery inspect --home $profile --json
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Packaged recovery inspection failed.' }
|
||||
$inspection = $inspectionRaw | ConvertFrom-Json
|
||||
if ($inspection.outcome -notin @('ready', 'attention')) {
|
||||
throw "Unsafe packaged profile inspection: $inspectionRaw"
|
||||
}
|
||||
if ([IO.Path]::GetFullPath($inspection.primary_home) -ne [IO.Path]::GetFullPath($profile)) {
|
||||
throw 'Candidate selected a different primary profile after upgrade.'
|
||||
}
|
||||
if (
|
||||
[IO.Path]::GetFullPath($inspection.effective_workspace) -ne
|
||||
[IO.Path]::GetFullPath((Join-Path $profile 'workspace'))
|
||||
) {
|
||||
throw 'Candidate selected a different workspace after upgrade.'
|
||||
}
|
||||
$configuredState = @($inspection.candidates | Where-Object {
|
||||
$_.kind -eq 'state' -and $_.configured -and $_.valid
|
||||
})
|
||||
if (
|
||||
$configuredState.Count -ne 1 -or
|
||||
[IO.Path]::GetFullPath($configuredState[0].path) -ne
|
||||
[IO.Path]::GetFullPath((Join-Path $profile 'state'))
|
||||
) {
|
||||
throw 'Candidate selected a different state directory after upgrade.'
|
||||
}
|
||||
python $probe verify --home $profile --label $Label
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Candidate launch changed RC3 profile data.' }
|
||||
|
||||
$uninstaller = Get-ChildItem -LiteralPath $installDir -Filter 'Uninstall*.exe' -File |
|
||||
Select-Object -First 1
|
||||
if (-not $uninstaller) { throw 'Candidate Windows uninstaller was not found.' }
|
||||
$uninstall = Start-Process -FilePath $uninstaller.FullName -ArgumentList @('/S') `
|
||||
-Wait -PassThru
|
||||
if ($uninstall.ExitCode -ne 0) {
|
||||
throw "Candidate uninstaller failed with exit code $($uninstall.ExitCode)."
|
||||
}
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(30)
|
||||
while (
|
||||
(Test-Path -LiteralPath $app -PathType Leaf) -and
|
||||
[DateTime]::UtcNow -lt $deadline
|
||||
) {
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (Test-Path -LiteralPath $app -PathType Leaf) {
|
||||
throw 'Candidate uninstaller did not remove OpenSquilla.exe.'
|
||||
}
|
||||
python $probe verify --home $profile --label $Label
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Candidate uninstaller changed RC3 profile data.' }
|
||||
} finally {
|
||||
Stop-InstalledProcesses
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Route offline pytest files into stable Windows CI responsibility shards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import tomllib
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Final
|
||||
|
||||
SHARD_NAMES: Final[tuple[str, ...]] = (
|
||||
"core",
|
||||
"gateway-sqlite",
|
||||
"recovery-migration",
|
||||
"desktop-installer-contracts",
|
||||
)
|
||||
|
||||
_GATEWAY_SQLITE_PREFIXES: Final[tuple[str, ...]] = (
|
||||
"tests/test_gateway/",
|
||||
"tests/test_health/",
|
||||
"tests/test_observability/",
|
||||
"tests/test_persistence/",
|
||||
"tests/test_scheduler/",
|
||||
"tests/test_search/",
|
||||
"tests/test_session/",
|
||||
)
|
||||
_RECOVERY_MIGRATION_PREFIXES: Final[tuple[str, ...]] = (
|
||||
"tests/test_migration/",
|
||||
"tests/test_migrations/",
|
||||
"tests/test_recovery/",
|
||||
)
|
||||
_DESKTOP_INSTALLER_PREFIXES: Final[tuple[str, ...]] = (
|
||||
"tests/test_desktop/",
|
||||
"tests/test_dist/",
|
||||
"tests/test_packaging/",
|
||||
"tests/test_uninstall/",
|
||||
)
|
||||
|
||||
_GATEWAY_SQLITE_NAME_TOKENS: Final[tuple[str, ...]] = (
|
||||
"database",
|
||||
"gateway",
|
||||
"memory",
|
||||
"scheduler",
|
||||
"session",
|
||||
"sqlite",
|
||||
)
|
||||
_RECOVERY_MIGRATION_NAME_TOKENS: Final[tuple[str, ...]] = (
|
||||
"legacy_config",
|
||||
"migrate",
|
||||
"migration",
|
||||
"recovery",
|
||||
)
|
||||
_DESKTOP_INSTALLER_NAME_TOKENS: Final[tuple[str, ...]] = (
|
||||
"artifact",
|
||||
"desktop",
|
||||
"install",
|
||||
"release",
|
||||
"uninstall",
|
||||
"wheelhouse",
|
||||
)
|
||||
_DESKTOP_INSTALLER_EXACT: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"tests/test_compose_yaml_shape.py",
|
||||
"tests/test_root_start_scripts.py",
|
||||
}
|
||||
)
|
||||
_CORE_EXACT: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"tests/test_ci/test_router_artifact_manifest.py",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def discover_test_files(root: Path) -> tuple[str, ...]:
|
||||
"""Return every pytest file below ``tests/`` as a repository-relative path."""
|
||||
|
||||
tests_root = root / "tests"
|
||||
excluded = _pytest_excluded_prefixes(root)
|
||||
relative_paths = (
|
||||
path.relative_to(root).as_posix() for path in tests_root.rglob("test_*.py")
|
||||
)
|
||||
return tuple(
|
||||
sorted(
|
||||
relative
|
||||
for relative in relative_paths
|
||||
if not any(relative.startswith(prefix) for prefix in excluded)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _pytest_excluded_prefixes(root: Path) -> tuple[str, ...]:
|
||||
pyproject = root / "pyproject.toml"
|
||||
try:
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
except (OSError, tomllib.TOMLDecodeError) as exc:
|
||||
raise ValueError(f"cannot read pytest collection contract from {pyproject}") from exc
|
||||
configured = data.get("tool", {}).get("pytest", {}).get("ini_options", {})
|
||||
return tuple(
|
||||
f"{PurePosixPath(path).as_posix().rstrip('/')}/"
|
||||
for path in configured.get("norecursedirs", ())
|
||||
if PurePosixPath(path).as_posix().startswith("tests/")
|
||||
)
|
||||
|
||||
|
||||
def matching_specialized_shards(path: str) -> tuple[str, ...]:
|
||||
"""Return specialized shards whose responsibility rules match ``path``."""
|
||||
|
||||
normalized = PurePosixPath(path).as_posix()
|
||||
if normalized in _CORE_EXACT:
|
||||
return ()
|
||||
name = PurePosixPath(normalized).name.casefold()
|
||||
prefix_matches: list[str] = []
|
||||
if normalized.startswith(_GATEWAY_SQLITE_PREFIXES):
|
||||
prefix_matches.append("gateway-sqlite")
|
||||
if normalized.startswith(_RECOVERY_MIGRATION_PREFIXES):
|
||||
prefix_matches.append("recovery-migration")
|
||||
if normalized.startswith(_DESKTOP_INSTALLER_PREFIXES):
|
||||
prefix_matches.append("desktop-installer-contracts")
|
||||
if prefix_matches:
|
||||
return tuple(prefix_matches)
|
||||
|
||||
matches: list[str] = []
|
||||
if any(token in name for token in _GATEWAY_SQLITE_NAME_TOKENS):
|
||||
matches.append("gateway-sqlite")
|
||||
if any(token in name for token in _RECOVERY_MIGRATION_NAME_TOKENS):
|
||||
matches.append("recovery-migration")
|
||||
if normalized in _DESKTOP_INSTALLER_EXACT or any(
|
||||
token in name for token in _DESKTOP_INSTALLER_NAME_TOKENS
|
||||
):
|
||||
matches.append("desktop-installer-contracts")
|
||||
|
||||
return tuple(matches)
|
||||
|
||||
|
||||
def shard_for_test(path: str) -> str:
|
||||
"""Return the one responsibility shard for ``path`` or fail on ambiguity."""
|
||||
|
||||
matches = matching_specialized_shards(path)
|
||||
if len(matches) > 1:
|
||||
joined = ", ".join(matches)
|
||||
raise ValueError(f"test file matches multiple Windows shards: {path} ({joined})")
|
||||
return matches[0] if matches else "core"
|
||||
|
||||
|
||||
def files_for_shard(root: Path, shard: str) -> tuple[str, ...]:
|
||||
if shard not in SHARD_NAMES:
|
||||
raise ValueError(f"unknown Windows shard: {shard}")
|
||||
return tuple(path for path in discover_test_files(root) if shard_for_test(path) == shard)
|
||||
|
||||
|
||||
def _write_failure_summary(junit_path: Path, summary_path: Path, exit_code: int) -> None:
|
||||
summary_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"pytest_exit_code={exit_code}"]
|
||||
if not junit_path.is_file():
|
||||
lines.append("junit_status=unavailable")
|
||||
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return
|
||||
|
||||
try:
|
||||
root = ET.parse(junit_path).getroot()
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
lines.extend(("junit_status=unreadable", f"detail={type(exc).__name__}"))
|
||||
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return
|
||||
|
||||
for testcase in root.iter("testcase"):
|
||||
failure = testcase.find("failure")
|
||||
if failure is None:
|
||||
failure = testcase.find("error")
|
||||
if failure is None:
|
||||
continue
|
||||
class_name = testcase.get("classname", "")
|
||||
test_name = testcase.get("name", "unknown")
|
||||
node = f"{class_name}::{test_name}" if class_name else test_name
|
||||
detail = (failure.text or failure.get("message") or "failure details unavailable").strip()
|
||||
lines.extend(
|
||||
(
|
||||
"junit_status=failed",
|
||||
f"first_failure={node}",
|
||||
"detail:",
|
||||
detail[:12_000],
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
lines.append("junit_status=passed" if exit_code == 0 else "junit_status=no-test-failure")
|
||||
|
||||
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _run(args: argparse.Namespace) -> int:
|
||||
import pytest
|
||||
|
||||
root = args.root.resolve()
|
||||
files = files_for_shard(root, args.shard)
|
||||
if not files:
|
||||
print(f"Windows shard {args.shard!r} has no tests", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
args.junit.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.summary.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.summary.write_text("pytest_status=started\n", encoding="utf-8")
|
||||
|
||||
pytest_args = list(args.pytest_args)
|
||||
if pytest_args[:1] == ["--"]:
|
||||
pytest_args = pytest_args[1:]
|
||||
pytest_args.extend(str(root / path) for path in files)
|
||||
pytest_args.append(f"--junitxml={args.junit}")
|
||||
|
||||
print(f"Running {len(files)} test files in Windows shard {args.shard}")
|
||||
exit_code = int(pytest.main(pytest_args))
|
||||
_write_failure_summary(args.junit, args.summary, exit_code)
|
||||
return exit_code
|
||||
|
||||
|
||||
def _list(args: argparse.Namespace) -> int:
|
||||
for path in files_for_shard(args.root.resolve(), args.shard):
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="list files assigned to one shard")
|
||||
list_parser.add_argument("shard", choices=SHARD_NAMES)
|
||||
list_parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
list_parser.set_defaults(handler=_list)
|
||||
|
||||
run_parser = subparsers.add_parser("run", help="run one shard through pytest")
|
||||
run_parser.add_argument("shard", choices=SHARD_NAMES)
|
||||
run_parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
run_parser.add_argument("--junit", type=Path, required=True)
|
||||
run_parser.add_argument("--summary", type=Path, required=True)
|
||||
run_parser.set_defaults(handler=_run)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = _parser()
|
||||
args, pytest_args = parser.parse_known_args()
|
||||
if args.command != "run" and pytest_args:
|
||||
parser.error(f"unrecognized arguments: {' '.join(pytest_args)}")
|
||||
args.pytest_args = pytest_args
|
||||
return int(args.handler(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user