chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:33 +08:00
commit aacb60a4af
3387 changed files with 981307 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
name: Bug report
description: Report a reproducible OpenSquilla defect
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Do not include credentials, provider tokens, account identifiers, raw local paths, or vulnerability details. Use SECURITY.md for suspected vulnerabilities. A diagnostics bundle generated by `opensquilla bundle` (or the Web UI / desktop download button) is redacted and safe to attach.
- type: input
id: version
attributes:
label: OpenSquilla version or commit
description: Use the release version or commit hash where the issue occurs.
placeholder: "0.2.0 or commit hash"
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- CLI
- Gateway
- Web UI
- Desktop app
- Provider integration
- Channel integration
- Memory
- Scheduler
- Tools
- Packaging or install
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Provide the smallest public reproduction you can share.
placeholder: |
1. Run ...
2. Configure ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: Paste minimal sanitized output if it helps.
validations:
required: true
- type: input
id: environment
attributes:
label: Environment
placeholder: "Windows 11, Python 3.12+, uv version"
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: Diagnostic bundle and error reference
description: >-
Attach a diagnostics bundle if you can: run `opensquilla bundle`, or use
the Diagnostic bundle button on the Web UI Logs page, or the desktop app menu
"Download Diagnostics…". The bundle is redacted by default (secrets
removed, paths normalized, no conversation content) and safe to attach.
If your error message showed a reference code like `(ref: a1b2c3d4)`,
paste it here.
placeholder: "ref: a1b2c3d4 — bundle attached"
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+45
View File
@@ -0,0 +1,45 @@
name: Documentation issue
description: Report missing, confusing, or outdated OpenSquilla documentation
title: "[Docs]: "
labels: ["documentation"]
body:
- type: markdown
attributes:
value: |
Thanks for improving OpenSquilla's docs. Do not include credentials, provider tokens, private transcripts, account identifiers, or sensitive local paths.
- type: input
id: page
attributes:
label: Page or section
description: Link the page, heading, or command example that needs work.
placeholder: "docs/quickstart.md, docs/features/memory.md, or a GitHub URL"
validations:
required: true
- type: dropdown
id: issue_type
attributes:
label: Issue type
options:
- Incorrect command or option
- Missing setup step
- Confusing explanation
- Missing feature guide
- Broken link
- Screenshot or UI mismatch
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: What is wrong or missing?
description: Describe what you expected to learn and where the documentation fell short.
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested fix
description: If you know the correction, include the command, wording, or link that would help.
validations:
required: false
@@ -0,0 +1,44 @@
name: Feature request
description: Propose a focused OpenSquilla improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: Describe the user-facing problem or workflow gap.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the smallest behavior change that would solve the problem.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- CLI
- Gateway
- Web UI
- Provider integration
- Channel integration
- Memory
- Scheduler
- Tools
- Packaging or install
- Documentation
- Other
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Note any simpler workaround or existing OpenSquilla behavior you tried.
validations:
required: false
+59
View File
@@ -0,0 +1,59 @@
## Scope
Scope boundary:
Non-goals:
## Branch
Base branch: main | staging/collaboration
Target exception: N/A | release | hotfix | staging/collaboration | maintainer-approved
## Issue
Linked issue: Fixes #... | Refs #... | None
If None, reason:
## Release Note
Release note: NONE |
## Tests
Ruff:
Pytest:
Build:
Regression tests: added | not needed
Notes:
The default test path remains offline, deterministic, credential-free, and safe for forks.
## Maintainer Live Check
Maintainer live check: no | yes
Surface: N/A | provider | browser | gateway | channel | release
Maintainer-only note: contributors are not expected to provide secrets or run credentialed live checks. Maintainers may run `Live Release E2E` for provider, browser, gateway, channel, or release smoke coverage.
## Safety
Secrets, local-only artifacts, private prompts/transcripts, channel identifiers, AI session artifacts, non-public fixtures, and tests/_private/ contents must not be committed.
## Third-Party Origin
Third-party origin: none | inspired-by | adapted/ported | vendored | direct dependency | modified upstream
Details if non-none: upstream URL, license, copied/adapted code/rules/fixtures/text, and notice/provenance updates.
## Documentation Changes
- [ ] Links point to existing repository files or stable external pages.
- [ ] Code fences and Markdown tables render correctly on GitHub.
- [ ] Examples avoid real secrets, local private paths, and private transcripts.
+161
View File
@@ -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())
+221
View File
@@ -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}"
+437
View 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
+92
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+252
View File
@@ -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())
+876
View File
@@ -0,0 +1,876 @@
name: CI
"on":
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
OPENSQUILLA_TESTING: "true"
jobs:
classify-changes:
name: Classify changed files
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
docs_only: ${{ steps.classify.outputs.docs_only }}
runtime_changed: ${{ steps.classify.outputs.runtime_changed }}
test_changed: ${{ steps.classify.outputs.test_changed }}
ci_changed: ${{ steps.classify.outputs.ci_changed }}
dependency_changed: ${{ steps.classify.outputs.dependency_changed }}
release_changed: ${{ steps.classify.outputs.release_changed }}
windows_full_required: ${{ steps.classify.outputs.windows_full_required }}
frontend_changed: ${{ steps.classify.outputs.frontend_changed }}
tui_changed: ${{ steps.classify.outputs.tui_changed }}
desktop_changed: ${{ steps.classify.outputs.desktop_changed }}
python_changed: ${{ steps.classify.outputs.python_changed }}
platform_sensitive_changed: ${{ steps.classify.outputs.platform_sensitive_changed }}
build_wheel_required: ${{ steps.classify.outputs.build_wheel_required }}
full_required: ${{ steps.classify.outputs.full_required }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: List changed files
shell: bash
run: |
set -euo pipefail
changed_files="${RUNNER_TEMP}/changed-files.txt"
case "${{ github.event_name }}" in
pull_request)
git diff --name-only \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" > "${changed_files}"
;;
push)
before="${{ github.event.before }}"
after="${{ github.event.after }}"
if [[ -n "${before}" && "${before}" != "0000000000000000000000000000000000000000" ]]; then
git diff --name-only "${before}" "${after}" > "${changed_files}"
else
printf '.ci/run-all\n' > "${changed_files}"
fi
;;
*)
printf '.ci/run-all\n' > "${changed_files}"
;;
esac
echo "Changed files:"
sed 's/^/ /' "${changed_files}"
printf 'CHANGED_FILES=%s\n' "${changed_files}" >> "${GITHUB_ENV}"
- name: Classify changed files
id: classify
shell: bash
run: bash .github/scripts/classify-ci-changes.sh "${CHANGED_FILES}"
workflow-lint:
name: Validate GitHub Actions workflows
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Run actionlint
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -color=false
frontend-check:
name: Frontend build and typecheck
needs: classify-changes
if: ${{ needs.classify-changes.outputs.frontend_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: opensquilla-webui/package-lock.json
- name: Install frontend dependencies
working-directory: opensquilla-webui
run: npm ci
- name: Run frontend unit tests
working-directory: opensquilla-webui
run: npm run test:unit
- name: Build frontend
working-directory: opensquilla-webui
run: npm run build
- name: Verify committed dist is fresh
run: |
if [ ! -f "src/opensquilla/gateway/static/dist/index.html" ]; then
echo "ERROR: Frontend dist not found"
exit 1
fi
# The build above regenerates src/opensquilla/gateway/static/dist. If
# the committed dist differs from a fresh build, a Web UI source change
# was made without rebuilding + committing the bundle it ships (the
# gateway serves the committed dist, not source). Fail so a stale
# bundle can never ship (e.g. an approval-button removal that only
# lands in source — the #413 residue).
if ! git diff --quiet -- src/opensquilla/gateway/static/dist; then
echo "ERROR: committed Web UI dist is stale — rebuild and commit it:"
echo " cd opensquilla-webui && npm ci && npm run build"
echo "Changed dist files:"
git --no-pager diff --stat -- src/opensquilla/gateway/static/dist
exit 1
fi
echo "Frontend dist verified fresh"
tui-check:
name: OpenTUI package tests
needs: classify-changes
if: ${{ needs.classify-changes.outputs.tui_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install OpenTUI host dependencies
working-directory: src/opensquilla/cli/tui/opentui/package
shell: bash
run: bun install --frozen-lockfile
- name: Run OpenTUI Node tests
working-directory: src/opensquilla/cli/tui/opentui/package
run: npm run test
- name: Run OpenTUI Bun tests
working-directory: src/opensquilla/cli/tui/opentui/package
run: bun run test:bun
desktop-check:
name: Desktop Electron unit tests
needs: classify-changes
if: ${{ needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# The pure-logic unit tests run against the compiled dist and never launch
# Electron, so skip the heavy Electron/Playwright binary downloads.
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: desktop/electron/package-lock.json
- name: Install desktop dependencies
working-directory: desktop/electron
run: npm ci
- name: Build desktop TypeScript
working-directory: desktop/electron
run: npm run build
- name: Run desktop unit tests
working-directory: desktop/electron
run: |
node scripts/test-secret-storage-policy.mjs
node scripts/test-update-resolver.mjs
node scripts/test-desktop-locale.mjs
node scripts/test-desktop-profile-substrate.mjs
node scripts/test-desktop-profile-context.mjs
node scripts/test-desktop-cleanup-contract.mjs
ubuntu-quality:
name: Lint, test, and build (ubuntu-latest, 3.12)
needs: classify-changes
if: ${{ needs.classify-changes.outputs.python_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
lfs: ${{ needs.classify-changes.outputs.full_required == 'true' }}
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Lint
shell: bash
run: uv run ruff check src tests
- name: Type check
shell: bash
run: uv run mypy src/opensquilla --show-error-codes
- name: Test
shell: bash
run: |
set -euo pipefail
markers="not llm and not live_search and not live_channel and not webui_browser and not tui_real_terminal and not local_golden and not agent_context_boundary and not llm_router_acc"
if [[ "${{ needs.classify-changes.outputs.full_required }}" == "true" ]]; then
uv run pytest tests -q -m "${markers}" --durations=50
else
uv run pytest \
tests/unit \
tests/test_ci \
--ignore=tests/test_ci/test_router_artifact_manifest.py \
tests/test_desktop/test_electron_startup_contract.py \
tests/test_recovery \
tests/test_migration/test_opensquilla_home_migration.py \
tests/test_engine/test_coding_mode.py \
tests/test_engine/test_stream_wrappers.py \
tests/test_tools/test_shell_process_isolation.py \
-q -m "${markers}" --durations=50
fi
- name: Verify metric counter names unchanged
shell: bash
run: |
set -e
for name in opensquilla_queue_depth in_flight_turns_total turn_cancellations_total queue_full_errors_total; do
count=$(grep -c "$name" src/opensquilla/gateway/task_runtime.py || echo 0)
if [ "$count" -lt 1 ]; then
echo "ERROR: metric name '$name' missing from task_runtime.py"
exit 1
fi
done
- name: tracemalloc leak smoke test
if: ${{ needs.classify-changes.outputs.full_required == 'true' }}
shell: bash
run: |
uv run python -m pytest tests/test_gateway/test_task_runtime_terminal_cleanup.py::test_no_leak_under_load -v
- name: Build wheel
if: ${{ needs.classify-changes.outputs.build_wheel_required == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
shell: bash
run: uv build --wheel
windows-compat:
name: Windows compatibility smoke (3.12)
needs: classify-changes
if: ${{ needs.classify-changes.outputs.python_changed == 'true' || needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.dependency_changed == 'true' || needs.classify-changes.outputs.release_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: windows-latest
timeout-minutes: 20
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Test Windows compatibility smoke
shell: bash
run: |
uv run pytest \
tests/test_artifacts.py \
tests/test_contrib/test_codetask \
tests/test_desktop/test_electron_startup_contract.py \
tests/test_engine/test_coding_mode.py \
tests/test_engine/test_stream_wrappers.py \
tests/test_tools/test_shell_process_isolation.py \
-q
windows-full:
name: Windows high-risk (${{ matrix.shard }})
needs: classify-changes
if: ${{ needs.classify-changes.outputs.windows_full_required == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: windows-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard:
- core
- gateway-sqlite
- recovery-migration
- desktop-installer-contracts
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Prepare diagnostic report
shell: bash
run: |
set -euo pipefail
report_dir="${RUNNER_TEMP}/ci-reports/${{ matrix.shard }}"
mkdir -p "${report_dir}"
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
{
printf 'runner_os=%s\n' "${RUNNER_OS}"
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
printf 'shard=%s\n' "${{ matrix.shard }}"
} > "${report_dir}/environment.txt"
printf 'pytest_status=not_started\n' > "${report_dir}/first-failure.txt"
- name: Check out repository
uses: actions/checkout@v4
with:
lfs: ${{ matrix.shard == 'core' }}
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Bun
if: ${{ matrix.shard == 'core' }}
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install OpenTUI host dependencies
if: ${{ matrix.shard == 'core' }}
shell: bash
run: bun install --frozen-lockfile --cwd=src/opensquilla/cli/tui/opentui/package
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Record tool versions
shell: bash
run: |
set -euo pipefail
{
python --version
uv --version
uv run pytest --version
} >> "${CI_REPORT_DIR}/environment.txt" 2>&1
- name: Provision distinct Windows test volumes
if: ${{ matrix.shard == 'recovery-migration' }}
shell: pwsh
run: |
$token = [guid]::NewGuid().ToString("N")
$volumeA = Join-Path -Path $env:RUNNER_TEMP -ChildPath "opensquilla-windows-volume-a-$token"
$volumeB = Join-Path -Path $env:LOCALAPPDATA -ChildPath "opensquilla-windows-volume-b-$token"
if (Test-Path -LiteralPath $volumeA) {
throw "Windows test volume root already exists: $volumeA"
}
if (Test-Path -LiteralPath $volumeB) {
throw "Windows test volume root already exists: $volumeB"
}
$driveA = [System.IO.Path]::GetPathRoot($volumeA)
$driveB = [System.IO.Path]::GetPathRoot($volumeB)
$systemDriveRoot = [System.IO.Path]::GetPathRoot("$($env:SystemDrive)\")
if (-not [string]::Equals($driveB, $systemDriveRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Windows test volume B must use SystemDrive"
}
if ([string]::Equals($driveA, $driveB, [StringComparison]::OrdinalIgnoreCase)) {
throw "Windows test volume roots must use different drives"
}
$createdRoots = [System.Collections.Generic.List[string]]::new()
try {
New-Item -ItemType Directory -Path $volumeA -ErrorAction Stop | Out-Null
$createdRoots.Add($volumeA)
New-Item -ItemType Directory -Path $volumeB -ErrorAction Stop | Out-Null
$createdRoots.Add($volumeB)
"OPENSQUILLA_WINDOWS_TEST_VOLUME_A=$volumeA" |
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSQUILLA_WINDOWS_TEST_VOLUME_B=$volumeB" |
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
} catch {
foreach ($testRoot in $createdRoots) {
if (Test-Path -LiteralPath $testRoot) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
}
throw
}
- name: Test Windows native recovery move primitives first
if: ${{ matrix.shard == 'recovery-migration' }}
shell: bash
run: |
set -euo pipefail
maxfail_args=()
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
maxfail_args+=(--maxfail=3)
fi
set +e
uv run pytest \
tests/test_recovery/test_atomic_and_locking.py::test_native_move_moves_a_regular_tree_between_real_parents \
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_legacy_lock_survives_profile_move_and_rebind \
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_replacement_locks_survive_two_profile_moves \
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_recent_locked_profile_tree_moves_without_metadata_false_positive \
tests/test_recovery/test_atomic_and_locking.py::test_windows_primitive_collision_preserves_both_trees \
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_refuses_real_cross_volume_move \
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_handles_real_path_longer_than_260_characters \
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_rejects_real_junction_in_source_tree \
tests/test_recovery/test_atomic_and_locking.py::test_windows_no_replace_pins_both_parents_during_real_mutation_window \
-q \
--durations=25 \
--junitxml="${CI_REPORT_DIR}/native-move-junit.xml" \
"${maxfail_args[@]}" \
2>&1 | tee "${CI_REPORT_DIR}/native-move.log"
status=${PIPESTATUS[0]}
set -e
if [[ "${status}" -ne 0 ]]; then
printf 'native_move_exit_code=%s\n' "${status}" \
> "${CI_REPORT_DIR}/first-failure.txt"
exit "${status}"
fi
- name: Test Windows shard
shell: bash
run: |
set -euo pipefail
markers="not llm and not live_search and not live_channel and not webui_browser and not tui_real_terminal and not local_golden and not agent_context_boundary and not llm_router_acc"
maxfail_args=()
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
maxfail_args+=(--maxfail=3)
fi
uv run python .github/scripts/windows_test_shards.py run \
"${{ matrix.shard }}" \
--junit "${CI_REPORT_DIR}/junit.xml" \
--summary "${CI_REPORT_DIR}/first-failure.txt" \
-- \
-q \
-m "${markers}" \
--durations=50 \
"${maxfail_args[@]}" \
2>&1 | tee "${CI_REPORT_DIR}/pytest.log"
- name: Clean up Windows test volumes
if: ${{ always() && matrix.shard == 'recovery-migration' }}
shell: pwsh
run: |
$testRoots = @(
$env:OPENSQUILLA_WINDOWS_TEST_VOLUME_A,
$env:OPENSQUILLA_WINDOWS_TEST_VOLUME_B
)
foreach ($testRoot in $testRoots) {
if ($testRoot -and (Test-Path -LiteralPath $testRoot)) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
}
- name: Upload Windows shard report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: windows-high-risk-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/ci-reports/${{ matrix.shard }}
if-no-files-found: error
retention-days: 14
macos-recovery:
name: macOS profile recovery and native no-replace (3.12)
needs: classify-changes
if: ${{ needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: macos-latest
timeout-minutes: 30
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Prepare macOS recovery report
shell: bash
run: |
set -euo pipefail
report_dir="${RUNNER_TEMP}/macos-recovery"
mkdir -p "${report_dir}"
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
{
printf 'runner_os=%s\n' "${RUNNER_OS}"
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
} > "${report_dir}/environment.txt"
printf 'pytest_status=not_started\n' > "${report_dir}/first-failure.txt"
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Test native profile recovery contracts
shell: bash
run: |
set -euo pipefail
maxfail_args=()
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
maxfail_args+=(--maxfail=3)
fi
set +e
uv run pytest \
tests/test_recovery \
tests/test_migration/test_opensquilla_home_migration.py \
tests/test_desktop/test_electron_startup_contract.py \
-q \
--durations=50 \
--junitxml="${CI_REPORT_DIR}/junit.xml" \
"${maxfail_args[@]}" \
2>&1 | tee "${CI_REPORT_DIR}/pytest.log"
status=${PIPESTATUS[0]}
set -e
printf 'pytest_exit_code=%s\n' "${status}" > "${CI_REPORT_DIR}/first-failure.txt"
exit "${status}"
- name: Upload macOS recovery report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: macos-recovery-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/macos-recovery
if-no-files-found: error
retention-days: 14
desktop-recovery-e2e:
name: Desktop recovery E2E (${{ matrix.os }})
needs: classify-changes
if: ${{ needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Prepare Desktop recovery report
shell: bash
run: |
set -euo pipefail
report_dir="${RUNNER_TEMP}/desktop-recovery-e2e"
mkdir -p "${report_dir}"
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
{
printf 'runner_os=%s\n' "${RUNNER_OS}"
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
} > "${report_dir}/environment.txt"
printf 'e2e_status=not_started\n' > "${report_dir}/first-failure.txt"
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: desktop/electron/package-lock.json
- name: Install Python dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Install Desktop dependencies
working-directory: desktop/electron
shell: bash
run: npm ci
- name: Record Desktop recovery tool versions
shell: bash
run: |
set -euo pipefail
{
python --version
uv --version
node --version
npm --version
} >> "${CI_REPORT_DIR}/environment.txt" 2>&1
- name: Build Desktop TypeScript
working-directory: desktop/electron
shell: bash
run: npm run build
- name: Run compiled Desktop recovery flows
working-directory: desktop/electron
shell: bash
env:
OPENSQUILLA_DESKTOP_RECOVERY_SCREENSHOT: ${{ runner.temp }}/desktop-recovery-e2e/recovery-page.png
run: |
set -euo pipefail
run_case() {
local name="$1"
local script="$2"
if [[ "${RUNNER_OS}" == "Linux" ]]; then
xvfb-run -a node "${script}" 2>&1 | tee "${CI_REPORT_DIR}/${name}.log"
else
node "${script}" 2>&1 | tee "${CI_REPORT_DIR}/${name}.log"
fi
}
for entry in \
'profile-recovery-flow:scripts/test-profile-recovery-flow.mjs' \
'profile-recovery-accessibility:scripts/test-profile-recovery-accessibility.mjs' \
'profile-import-flow:scripts/test-profile-import-flow.mjs' \
'unsafe-profile-no-write:scripts/test-unsafe-profile-no-write.mjs'
do
name="${entry%%:*}"
script="${entry#*:}"
if ! run_case "${name}" "${script}"; then
printf 'failed_case=%s\n' "${name}" > "${CI_REPORT_DIR}/first-failure.txt"
exit 1
fi
done
printf 'e2e_status=passed\n' > "${CI_REPORT_DIR}/first-failure.txt"
- name: Upload Desktop recovery report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: desktop-recovery-e2e-${{ matrix.os }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/desktop-recovery-e2e
if-no-files-found: error
retention-days: 14
release-packaging:
name: Release packaging contracts
needs: classify-changes
if: ${{ needs.classify-changes.outputs.release_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
lfs: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Run release packaging contract tests
shell: bash
run: |
uv run pytest \
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 \
-q
readme-locale-check:
name: README locale parity
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
- name: Check README locale parity
working-directory: opensquilla-webui
run: node scripts/check-readme-locales.mjs
ci-result:
name: CI result
needs:
- classify-changes
- workflow-lint
- readme-locale-check
- frontend-check
- tui-check
- desktop-check
- ubuntu-quality
- windows-compat
- windows-full
- macos-recovery
- desktop-recovery-e2e
- release-packaging
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check required CI results
env:
RESULT_CLASSIFY: ${{ needs.classify-changes.result }}
RESULT_WORKFLOW_LINT: ${{ needs.workflow-lint.result }}
RESULT_README_LOCALE: ${{ needs.readme-locale-check.result }}
RESULT_FRONTEND: ${{ needs.frontend-check.result }}
RESULT_TUI: ${{ needs.tui-check.result }}
RESULT_DESKTOP: ${{ needs.desktop-check.result }}
RESULT_UBUNTU: ${{ needs.ubuntu-quality.result }}
RESULT_WINDOWS_SMOKE: ${{ needs.windows-compat.result }}
RESULT_WINDOWS_FULL: ${{ needs.windows-full.result }}
RESULT_MACOS_RECOVERY: ${{ needs.macos-recovery.result }}
RESULT_DESKTOP_RECOVERY_E2E: ${{ needs.desktop-recovery-e2e.result }}
RESULT_RELEASE: ${{ needs.release-packaging.result }}
FLAG_DOCS_ONLY: ${{ needs.classify-changes.outputs.docs_only }}
FLAG_RUNTIME_CHANGED: ${{ needs.classify-changes.outputs.runtime_changed }}
FLAG_TEST_CHANGED: ${{ needs.classify-changes.outputs.test_changed }}
FLAG_CI_CHANGED: ${{ needs.classify-changes.outputs.ci_changed }}
FLAG_DEPENDENCY_CHANGED: ${{ needs.classify-changes.outputs.dependency_changed }}
FLAG_RELEASE_CHANGED: ${{ needs.classify-changes.outputs.release_changed }}
FLAG_WINDOWS_FULL_REQUIRED: ${{ needs.classify-changes.outputs.windows_full_required }}
FLAG_FRONTEND_CHANGED: ${{ needs.classify-changes.outputs.frontend_changed }}
FLAG_TUI_CHANGED: ${{ needs.classify-changes.outputs.tui_changed }}
FLAG_DESKTOP_CHANGED: ${{ needs.classify-changes.outputs.desktop_changed }}
FLAG_PYTHON_CHANGED: ${{ needs.classify-changes.outputs.python_changed }}
FLAG_PLATFORM_SENSITIVE_CHANGED: ${{ needs.classify-changes.outputs.platform_sensitive_changed }}
FLAG_BUILD_WHEEL_REQUIRED: ${{ needs.classify-changes.outputs.build_wheel_required }}
FLAG_FULL_REQUIRED: ${{ needs.classify-changes.outputs.full_required }}
run: python .github/scripts/check_ci_results.py
+234
View File
@@ -0,0 +1,234 @@
name: Container Images
# Builds the multi-arch (linux/amd64 + linux/arm64) gateway image and
# publishes it to GHCR so home-server/NAS users can `docker pull` a release
# instead of building from a source checkout. See docs/docker.md.
#
# Publishing model:
# * Pushing a release tag (v0.5.0rc3, v0.6.0, ...) first publishes the
# immutable ghcr.io/<owner>/<repo>:<tag> image. The workflow verifies its
# amd64/arm64 manifest and container HEALTHCHECK before moving :latest.
# :latest tracks the most recently pushed release tag — including
# prereleases and backports; if a backport repoints it, re-run this
# workflow from the newest tag.
# * workflow_dispatch validates the full multi-arch build without
# publishing; set publish=true to push the result as :edge.
#
# The GITHUB_TOKEN with job-level `packages: write` is the only credential.
# First-time note for maintainers: a new GHCR package is created private —
# flip ghcr.io/<owner>/<repo> to public in the package settings once.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
publish:
description: Push the built image to GHCR as the edge tag (leave off to only validate the build)
type: boolean
required: false
default: false
permissions:
contents: read
concurrency:
group: container-images-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-publish:
name: Build multi-arch gateway image
runs-on: ubuntu-latest
# The arm64 leg runs under QEMU emulation. All compiled dependencies
# install from prebuilt aarch64 wheels, so this is emulated pip time,
# not source builds — slow but bounded.
timeout-minutes: 120
permissions:
contents: read
packages: write
steps:
- name: Validate release tag
if: github.event_name == 'push'
run: |
if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+.*$ ]]; then
echo "Release tag must look like v0.2.0rc1 or v0.2.0: ${GITHUB_REF_NAME}" >&2
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
- name: Check tag version
if: github.event_name == 'push'
run: |
version="$(python3 - <<'PY'
import tomllib
from pathlib import Path
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
print(project["project"]["version"])
PY
)"
tag_version="${GITHUB_REF_NAME#v}"
if [[ "${tag_version}" != "${version}" ]]; then
echo "Tag ${GITHUB_REF_NAME} does not match project version ${version}" >&2
exit 1
fi
- name: Hydrate router assets
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
# The Dockerfile hard-fails on LFS pointer files; failing here first
# gives a clearer error than a mid-build SystemExit.
- name: Verify router assets are hydrated
run: |
python3 - <<'PY'
from pathlib import Path
root = Path("src/opensquilla/squilla_router/models/v4.2_phase3_inference")
required = [
root / "PROVENANCE.md",
root / "artifact_manifest.json",
root / "bge_onnx" / "model.onnx",
root / "features" / "tfidf.pkl",
root / "lgbm_main.bin",
root / "mlp" / "model.onnx",
root / "router.runtime.yaml",
]
for path in required:
assert path.is_file(), f"missing router asset: {path}"
prefix = path.read_bytes()[:80]
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
f"Git LFS pointer file is not hydrated: {path}"
)
PY
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: ${{ github.event_name == 'push' || inputs.publish == true }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Release tags are PEP 440 (v0.5.0rc3), not strict semver, so the git
# tag is mapped through as-is instead of via type=semver.
- name: Compute image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag,enable=${{ github.event_name == 'push' }}
type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }}
flavor: |
latest=false
- name: Build multi-arch image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' || inputs.publish == true }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Keep the pushed artifact a plain two-entry manifest list; the
# default provenance attestation adds an unknown/unknown entry that
# confuses registry UIs and `docker pull` inspection.
provenance: false
- name: Select pushed image
if: ${{ github.event_name == 'push' || inputs.publish == true }}
id: pushed_image
shell: bash
run: |
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
image_tag="${GITHUB_REF_NAME}"
else
image_tag="edge"
fi
echo "ref=ghcr.io/${GITHUB_REPOSITORY}:${image_tag}" >> "${GITHUB_OUTPUT}"
- name: Verify pushed manifest platforms
if: ${{ github.event_name == 'push' || inputs.publish == true }}
env:
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
run: |
python3 - <<'PY'
import json
import os
import subprocess
image_ref = os.environ["IMAGE_REF"]
manifest = json.loads(
subprocess.check_output(
["docker", "buildx", "imagetools", "inspect", image_ref, "--raw"],
text=True,
)
)
platforms = {
f'{item["platform"]["os"]}/{item["platform"]["architecture"]}'
for item in manifest.get("manifests", [])
}
expected = {"linux/amd64", "linux/arm64"}
if platforms != expected:
raise SystemExit(
f"{image_ref} has platforms {sorted(platforms)}; "
f"expected {sorted(expected)}"
)
print(f"Verified {image_ref}: {sorted(platforms)}")
PY
- name: Smoke pushed image HEALTHCHECK
if: ${{ github.event_name == 'push' || inputs.publish == true }}
env:
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
shell: bash
run: |
set -euo pipefail
container_id="$(docker run --detach --pull=always "${IMAGE_REF}")"
trap 'docker rm --force "${container_id}" >/dev/null 2>&1 || true' EXIT
deadline=$((SECONDS + 180))
while (( SECONDS < deadline )); do
running="$(docker inspect --format '{{.State.Running}}' "${container_id}")"
health="$(docker inspect \
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
"${container_id}")"
echo "Container health: ${health}"
if [[ "${health}" == "healthy" ]]; then
exit 0
fi
if [[ "${running}" != "true" || "${health}" == "unhealthy" || "${health}" == "missing" ]]; then
docker logs "${container_id}"
exit 1
fi
sleep 5
done
echo "Container did not become healthy within 180 seconds" >&2
docker logs "${container_id}"
exit 1
# Promotion happens only after the immutable release image passes both
# remote-manifest verification and its image-defined HEALTHCHECK.
- name: Promote verified release image to latest
if: github.event_name == 'push'
env:
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
LATEST_REF: ghcr.io/${{ github.repository }}:latest
run: |
docker buildx imagetools create \
--tag "${LATEST_REF}" \
"${IMAGE_REF}"
+35
View File
@@ -0,0 +1,35 @@
name: Issue Link Sync
"on":
pull_request_target:
types: [opened, reopened, edited, closed]
branches: [main]
permissions:
contents: read
issues: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
sync-linked-issues:
name: Sync linked issue state
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted base branch scripts
uses: actions/checkout@v4
with:
# pull_request_target has write-capable tokens. Run scripts from the
# pre-merge base commit, never from the pull request head.
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Sync issue labels and comments
env:
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_TOKEN: ${{ github.token }}
run: python3 .github/scripts/issue_link_sync.py
+98
View File
@@ -0,0 +1,98 @@
name: Live Release E2E
"on":
workflow_dispatch:
inputs:
run_telegram_channel:
description: "Send one real Telegram smoke message"
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
live-release-e2e:
name: Maintainer live release gates
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN: ${{ secrets.OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN }}
OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID: ${{ secrets.OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --extra dev --extra recommended --frozen
- name: Install Playwright browser
run: npx --yes playwright install chromium
- name: Fail if OpenRouter secret is missing
shell: bash
run: |
if [ -z "$OPENROUTER_API_KEY" ]; then
echo "OPENROUTER_API_KEY GitHub secret is required"
exit 1
fi
- name: Fail if Telegram secrets are missing when channel smoke is enabled
if: ${{ inputs.run_telegram_channel }}
shell: bash
run: |
if [ -z "$OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN" ]; then
echo "OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN GitHub secret is required when Telegram smoke is enabled"
exit 1
fi
if [ -z "$OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID" ]; then
echo "OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID GitHub secret is required when Telegram smoke is enabled"
exit 1
fi
- name: Run gateway LLM e2e
env:
OPENSQUILLA_GATEWAY_LLM_E2E: "1"
run: uv run pytest tests/functional/test_gateway_llm_e2e.py -q -s
- name: Run real-browser chat e2e
env:
OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E: "1"
run: uv run pytest tests/functional/test_webui_browser_chat_e2e.py -q -s
- name: Run live Telegram channel smoke
if: ${{ inputs.run_telegram_channel }}
env:
OPENSQUILLA_LIVE_TELEGRAM_E2E: "1"
run: uv run pytest tests/functional/test_live_channel_telegram_smoke.py -q -s
+101
View File
@@ -0,0 +1,101 @@
name: Live Search E2E
"on":
workflow_dispatch:
inputs:
run_extended_matrix:
description: "Run the broader live provider/API matrix"
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
live-search-e2e:
name: Live search provider and agent gates
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
OPENSQUILLA_LIVE_SEARCH: "1"
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --extra dev --extra recommended --frozen
- name: Fail if required live search secrets are missing
shell: bash
run: |
if [ -z "$OPENROUTER_API_KEY" ]; then
echo "OPENROUTER_API_KEY GitHub secret is required"
exit 1
fi
if [ -z "$TAVILY_API_KEY" ]; then
echo "TAVILY_API_KEY GitHub secret is required"
exit 1
fi
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
&& [ -z "$BRAVE_SEARCH_API_KEY" ]; then
echo "BRAVE_SEARCH_API_KEY GitHub secret is required for the extended matrix"
exit 1
fi
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
&& [ -z "$EXA_API_KEY" ]; then
echo "EXA_API_KEY GitHub secret is required for the extended matrix"
exit 1
fi
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
&& [ -z "$FIRECRAWL_API_KEY" ]; then
echo "FIRECRAWL_API_KEY GitHub secret is required for the extended matrix"
exit 1
fi
- name: Run core live search gates
run: |
timeout 180s uv run pytest \
tests/live/test_search_retrieval_live.py \
tests/live/test_web_search_agent_e2e.py \
-m live_search \
-q -s
- name: Run extended live search matrix
if: ${{ inputs.run_extended_matrix }}
env:
OPENSQUILLA_LIVE_SEARCH_MATRIX: "1"
run: |
timeout 240s uv run pytest \
tests/live/test_search_api_matrix_live.py \
-m live_search \
-q -s
+42
View File
@@ -0,0 +1,42 @@
name: LLM Smoke
"on":
workflow_dispatch:
permissions:
contents: read
jobs:
llm-smoke:
name: Live LLM smoke
runs-on: ubuntu-latest
timeout-minutes: 20
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
OPENSQUILLA_TURN_CALL_LOG: "0"
TMPDIR: /tmp
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install Python dependencies
run: uv sync --extra dev --frozen
- name: Run smoke
run: uv run pytest tests/functional/test_llm_smoke.py -q -s
+78
View File
@@ -0,0 +1,78 @@
name: Models.dev Snapshot Refresh
"on":
schedule:
# Monthly, 06:00 UTC on the 1st. Data-only refresh of the vendored
# models.dev snapshot; the PR it opens still goes through normal review.
- cron: "0 6 1 * *"
workflow_dispatch:
# The job fetches public catalog data and opens a PR with the diff. It must
# never receive provider API keys or other live-test secrets — only the
# workflow GITHUB_TOKEN, scoped to branch + PR creation.
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
refresh-snapshot:
name: Refresh vendored models.dev snapshot
runs-on: ubuntu-latest
timeout-minutes: 10
env:
UV_PYTHON: "3.12"
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
# Base dependencies only: the refresh script needs the provider
# registry and httpx, nothing from the dev/recommended extras.
run: uv sync --frozen
- name: Refresh models.dev snapshot
shell: bash
run: uv run python scripts/refresh_models_dev_snapshot.py
- name: Open data-refresh pull request
uses: peter-evans/create-pull-request@v7
with:
branch: data/models-dev-snapshot-refresh
delete-branch: true
add-paths: src/opensquilla/provider/models_dev_snapshot.json
commit-message: Refresh vendored models.dev snapshot
title: Refresh vendored models.dev snapshot (scheduled data refresh)
body: |
Scheduled data-only refresh of the vendored models.dev snapshot
(`src/opensquilla/provider/models_dev_snapshot.json`), produced by
`uv run python scripts/refresh_models_dev_snapshot.py`.
- Scope: vendored model-metadata snapshot only; no runtime code
changes.
- Branch target: main
- Linked issue: None
- Release notes: not needed (data refresh).
- Tests: default CI on this PR revalidates the snapshot shape.
- Safety: **needs human review before merge** — upstream data
mistakes (context windows, output caps) poison context budgeting
at runtime. Review the diff line by line; the snapshot is kept
deliberately small for this reason.
- Third-party origin: models.dev api.json (MIT, maintained by the
SST team).
+33
View File
@@ -0,0 +1,33 @@
name: PR body lint
"on":
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: read
jobs:
validate-body:
name: Validate PR body fields
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted workflow scripts
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Check out bootstrap body linter before first merge
if: ${{ hashFiles('.github/scripts/validate_pr_body.py') == '' }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Validate PR body fields
env:
PR_BODY_LINT_STRICT: "0"
run: python3 .github/scripts/validate_pr_body.py
+37
View File
@@ -0,0 +1,37 @@
name: PR target branch
"on":
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
jobs:
validate-target:
name: Validate target branch
runs-on: ubuntu-latest
steps:
- name: Check out trusted workflow scripts
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Check out bootstrap validator before first merge
if: ${{ hashFiles('.github/scripts/validate-pr-target-branch.sh') == '' }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Validate target branch
env:
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
GH_TOKEN: ${{ github.token }}
run: bash .github/scripts/validate-pr-target-branch.sh
+47
View File
@@ -0,0 +1,47 @@
name: Web UI Browser Smoke
"on":
workflow_dispatch:
permissions:
contents: read
jobs:
webui-browser-smoke:
name: Real browser Web UI smoke
runs-on: ubuntu-latest
timeout-minutes: 20
env:
OPENSQUILLA_WEBUI_BROWSER_E2E: "1"
OPENSQUILLA_TURN_CALL_LOG: "0"
TMPDIR: /tmp
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install Python dependencies
run: uv sync --extra dev --frozen
- name: Install Playwright browser
run: npx --yes playwright install chromium
- name: Run Web UI browser smoke
run: uv run pytest tests/functional/test_webui_browser_e2e.py -q -s
+875
View File
@@ -0,0 +1,875 @@
name: Release Assets
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Optional existing tag to upload artifacts to
required: false
default: ""
permissions:
contents: read
concurrency:
group: release-assets-${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
env:
RELEASE_PROFILE: recommended
RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag }}
jobs:
build-release-assets:
name: Build Python release assets
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Validate workflow inputs
shell: bash
run: |
if [[ -n "${RELEASE_TAG}" && ! "${RELEASE_TAG}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+.*$ ]]; then
echo "Release tag must look like v0.2.0rc1 or v0.2.0: ${RELEASE_TAG}" >&2
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
- name: Hydrate router assets
shell: bash
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
- name: Verify router assets are hydrated
shell: bash
run: |
python - <<'PY'
from pathlib import Path
root = Path("src/opensquilla/squilla_router/models/v4.2_phase3_inference")
required = [
root / "PROVENANCE.md",
root / "artifact_manifest.json",
root / "bge_onnx" / "model.onnx",
root / "features" / "tfidf.pkl",
root / "lgbm_main.bin",
root / "mlp" / "model.onnx",
root / "router.runtime.yaml",
]
for path in required:
assert path.is_file(), f"missing router asset: {path}"
prefix = path.read_bytes()[:80]
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
f"Git LFS pointer file is not hydrated: {path}"
)
PY
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
run: python -m pip install uv
- name: Check tag version
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
shell: bash
run: |
version="$(python - <<'PY'
import tomllib
from pathlib import Path
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
print(pyproject["project"]["version"])
PY
)"
tag="${RELEASE_TAG}"
tag_version="${tag#v}"
if [[ "${tag_version}" != "${version}" ]]; then
echo "Tag ${tag} does not match project version ${version}" >&2
exit 1
fi
- name: Test release asset contracts
run: uv run --extra dev pytest tests/test_scripts/test_build_wheelhouse_zip.py
- name: Build versioned wheel
shell: bash
run: |
rm -rf dist build/wheelhouse-zip
uv build --wheel --out-dir dist
- name: Smoke versioned release artifacts
shell: bash
run: |
python - <<'PY'
import tomllib
from pathlib import Path
from zipfile import ZipFile
version = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
wheels = sorted(
path for path in Path("dist").glob("opensquilla-*.whl")
)
assert len(wheels) == 1, f"expected one versioned wheel, got {len(wheels)}"
assert wheels[0].name == f"opensquilla-{version}-py3-none-any.whl"
assert not list(Path("dist").glob("OpenSquilla-*portable*.zip")), (
"0.5+ release assets must not include Windows portable zips"
)
with ZipFile(wheels[0]) as archive:
for info in archive.infolist():
if not info.filename.endswith((".bin", ".onnx", ".pkl", ".joblib")):
continue
prefix = archive.read(info)[:80]
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
f"Git LFS pointer leaked into wheel: {info.filename}"
)
PY
- name: Create release assets
shell: bash
run: |
python - <<'PY'
import hashlib
import tomllib
from pathlib import Path
dist = Path("dist")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
version = project["project"]["version"]
wheels = sorted(
path for path in dist.glob("opensquilla-*.whl")
)
assert len(wheels) == 1, f"expected one versioned wheel, got {len(wheels)}"
assert wheels[0].name == f"opensquilla-{version}-py3-none-any.whl"
assets = [wheels[0]]
lines = [
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}"
for path in assets
]
(dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: opensquilla-release-assets-python-${{ env.RELEASE_PROFILE }}
path: |
dist/*.whl
dist/SHA256SUMS
build-desktop-macos:
name: Build macOS Electron installer
runs-on: macos-latest
timeout-minutes: 150
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
- name: Hydrate router assets
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.12.0"
cache: npm
cache-dependency-path: |
opensquilla-webui/package-lock.json
desktop/electron/package-lock.json
- name: Install Web UI dependencies
working-directory: opensquilla-webui
run: npm ci
- name: Install Electron dependencies
working-directory: desktop/electron
run: npm ci
- name: Build signed macOS installer
working-directory: desktop/electron
env:
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
npm run build:web
npm run build:gateway
npm run build
npx electron-builder --mac --publish never
- name: Verify Electron package
working-directory: desktop/electron
run: npm run verify:package
- name: Smoke packaged gateway
working-directory: desktop/electron
env:
OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE: "1"
OPENSQUILLA_GATEWAY_SMOKE_TIMEOUT_MS: "240000"
run: npm run verify:gateway-smoke
- name: Verify RC3-to-candidate macOS upgrade and uninstall preserve profile data
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
candidates=(dist/desktop-electron/OpenSquilla-*-mac-arm64.dmg)
if [[ "${#candidates[@]}" -ne 1 || ! -f "${candidates[0]}" ]]; then
echo "Expected exactly one candidate macOS DMG" >&2
exit 1
fi
.github/scripts/verify-release-macos-upgrade.sh \
"${candidates[0]}" built-candidate
- name: List macOS artifacts
run: find dist/desktop-electron -maxdepth 2 -type f -print
- name: Upload macOS Electron artifacts
uses: actions/upload-artifact@v4
with:
name: opensquilla-electron-macos
path: |
dist/desktop-electron/*.dmg
dist/desktop-electron/*.zip
dist/desktop-electron/*.blockmap
dist/desktop-electron/latest-mac.yml
build-desktop-windows:
name: Build unsigned Windows Electron installer
runs-on: windows-latest
timeout-minutes: 150
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
- name: Hydrate router assets
shell: bash
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.12.0"
cache: npm
cache-dependency-path: |
opensquilla-webui/package-lock.json
desktop/electron/package-lock.json
- name: Install Web UI dependencies
working-directory: opensquilla-webui
run: npm ci
- name: Install Electron dependencies
working-directory: desktop/electron
run: npm ci
- name: Build unsigned Windows installer
working-directory: desktop/electron
shell: bash
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: |
set -euo pipefail
npm run build:web
npm run build:gateway
npm run build
npx electron-builder --win --publish never
- name: Verify Electron package
working-directory: desktop/electron
run: npm run verify:package
- name: Smoke packaged gateway
working-directory: desktop/electron
env:
OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE: "1"
run: npm run verify:gateway-smoke
- name: Verify RC3-to-candidate Windows upgrade and uninstall preserve profile data
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$candidates = @(Get-ChildItem 'dist/desktop-electron/OpenSquilla-*-win-x64.exe' -File)
if ($candidates.Count -ne 1) {
throw "Expected exactly one candidate Windows installer; got $($candidates.Count)."
}
.github/scripts/verify-release-windows-upgrade.ps1 `
-CandidateInstaller $candidates[0].FullName `
-Label built-candidate
- name: List Windows artifacts
shell: bash
run: find dist/desktop-electron -maxdepth 2 -type f -print
- name: Upload Windows Electron artifacts
uses: actions/upload-artifact@v4
with:
name: opensquilla-electron-windows
path: |
dist/desktop-electron/*.exe
dist/desktop-electron/*.blockmap
dist/desktop-electron/latest.yml
publish-release:
name: Publish release assets
needs:
- build-release-assets
- build-desktop-macos
- build-desktop-windows
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
steps:
- name: Checkout release notes
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
uses: actions/checkout@v4
with:
persist-credentials: false
ref: ${{ env.RELEASE_TAG }}
- name: Download release assets
uses: actions/download-artifact@v4
with:
pattern: opensquilla-release-assets-python-${{ env.RELEASE_PROFILE }}
path: dist
merge-multiple: true
- name: Download macOS Electron assets
uses: actions/download-artifact@v4
with:
name: opensquilla-electron-macos
path: dist
- name: Download Windows Electron assets
uses: actions/download-artifact@v4
with:
name: opensquilla-electron-windows
path: dist
- name: Regenerate release checksums
shell: bash
run: |
python - <<'PY'
import hashlib
import os
import re
from pathlib import Path
def desktop_asset_version(version: str) -> str:
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
dist = Path("dist")
tag = os.environ["RELEASE_TAG"]
if tag:
version = tag.removeprefix("v")
else:
wheels = sorted(dist.glob("opensquilla-*-py3-none-any.whl"))
assert len(wheels) == 1, f"expected one wheel when RELEASE_TAG is empty, got {len(wheels)}"
version = wheels[0].name.removeprefix("opensquilla-").removesuffix("-py3-none-any.whl")
desktop_version = desktop_asset_version(version)
asset_names = [
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
"latest-mac.yml",
f"OpenSquilla-{desktop_version}-win-x64.exe",
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
"latest.yml",
f"opensquilla-{version}-py3-none-any.whl",
]
lines = []
for name in asset_names:
path = dist / name
assert path.is_file(), f"missing release asset for checksum: {name}"
lines.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {name}")
(dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
- name: Verify release asset set
shell: bash
run: |
python - <<'PY'
import hashlib
import os
import re
from pathlib import Path
def desktop_asset_version(version: str) -> str:
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
dist = Path("dist")
tag = os.environ["RELEASE_TAG"]
if tag:
version = tag.removeprefix("v")
else:
wheels = sorted(dist.glob("opensquilla-*-py3-none-any.whl"))
assert len(wheels) == 1, f"expected one wheel when RELEASE_TAG is empty, got {len(wheels)}"
version = wheels[0].name.removeprefix("opensquilla-").removesuffix("-py3-none-any.whl")
desktop_version = desktop_asset_version(version)
sha256s = dist / "SHA256SUMS"
asset_names = [
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
"latest-mac.yml",
f"OpenSquilla-{desktop_version}-win-x64.exe",
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
"latest.yml",
f"opensquilla-{version}-py3-none-any.whl",
]
assets = [dist / name for name in asset_names]
assert sha256s.is_file(), "missing SHA256SUMS"
for path in assets:
assert path.is_file(), f"missing release asset: {path.name}"
expected = [
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}"
for path in assets
]
assert sha256s.read_text(encoding="utf-8").splitlines() == expected
PY
- name: Upload aggregate workflow artifact
uses: actions/upload-artifact@v4
with:
name: opensquilla-release-assets-${{ env.RELEASE_PROFILE }}
path: dist/*
- name: Upload to GitHub Release
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
set -euo pipefail
IS_PRERELEASE="$(python - <<'PY'
import os
import re
tag = os.environ["TAG"]
preview = re.search(
r"(?:[-.]?(?:a|alpha|b|beta|rc|pre|preview))[0-9]+$",
tag,
flags=re.IGNORECASE,
)
print("true" if preview else "false")
PY
)"
TITLE="OpenSquilla ${TAG#v}"
NOTES_FILE="docs/releases/${TAG#v}.md"
create_args=("${TAG}" --draft --verify-tag)
if [[ "${IS_PRERELEASE}" == "true" ]]; then
TITLE="$(python - <<'PY'
import os
import re
version = os.environ["TAG"].removeprefix("v")
match = re.fullmatch(r"([0-9]+[.][0-9]+[.][0-9]+)(?:a|b|rc)([0-9]+)", version)
if match:
print(f"OpenSquilla {match.group(1)} Preview {match.group(2)}")
else:
print(f"OpenSquilla {version} Preview")
PY
)"
create_args+=(--prerelease)
fi
create_args+=(--title "${TITLE}")
if [[ -f "${NOTES_FILE}" ]]; then
create_args+=(--notes-file "${NOTES_FILE}")
fi
assert_release_is_safe_draft() {
local release_state
release_state="$(gh release view "${TAG}" --json isDraft,isPrerelease)"
RELEASE_STATE="${release_state}" EXPECTED_PRERELEASE="${IS_PRERELEASE}" python - <<'PY'
import json
import os
state = json.loads(os.environ["RELEASE_STATE"])
expected_prerelease = os.environ["EXPECTED_PRERELEASE"] == "true"
if state.get("isDraft") is not True:
raise SystemExit("Refusing to mutate an existing non-Draft GitHub Release")
if state.get("isPrerelease") is not expected_prerelease:
raise SystemExit(
"Refusing to mutate a GitHub Release with an unexpected prerelease state"
)
PY
}
if gh release view "${TAG}" --json isDraft,isPrerelease >/dev/null 2>&1; then
assert_release_is_safe_draft
else
gh release create "${create_args[@]}"
assert_release_is_safe_draft
fi
if [[ -f "${NOTES_FILE}" ]]; then
gh release edit "${TAG}" --title "${TITLE}" --notes-file "${NOTES_FILE}"
fi
assert_release_is_safe_draft
python - <<'PY'
import json
import os
import subprocess
tag = os.environ["TAG"]
raw = subprocess.check_output(
["gh", "release", "view", tag, "--json", "assets"],
text=True,
)
data = json.loads(raw)
for asset in data.get("assets", []):
name = asset["name"]
managed = (
name == "SHA256SUMS"
or name == "latest-mac.yml"
or name == "latest.yml"
or name.startswith("OpenSquilla-")
or name.startswith("opensquilla-")
or name.endswith(".sha256")
)
if managed:
subprocess.run(
["gh", "release", "delete-asset", tag, name, "--yes"],
check=True,
)
PY
assert_release_is_safe_draft
gh release upload "${TAG}" dist/* --clobber
assert_release_is_safe_draft
- name: Verify GitHub Release assets
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
python - <<'PY'
import json
import os
import re
import subprocess
import sys
def desktop_asset_version(version: str) -> str:
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
tag = os.environ["TAG"]
version = tag.removeprefix("v")
desktop_version = desktop_asset_version(version)
expected = {
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
"latest-mac.yml",
f"OpenSquilla-{desktop_version}-win-x64.exe",
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
"latest.yml",
f"opensquilla-{version}-py3-none-any.whl",
"SHA256SUMS",
}
raw = subprocess.check_output(
[
"gh",
"release",
"view",
tag,
"--json",
"assets,isDraft,isPrerelease",
],
text=True,
)
data = json.loads(raw)
preview = re.search(
r"(?:[-.]?(?:a|alpha|b|beta|rc|pre|preview))[0-9]+$",
tag,
flags=re.IGNORECASE,
)
if data.get("isDraft") is not True:
print("Release asset verification requires a Draft release", file=sys.stderr)
raise SystemExit(1)
if data.get("isPrerelease") is not bool(preview):
print("Release prerelease state does not match the tag", file=sys.stderr)
raise SystemExit(1)
names = {asset["name"] for asset in data.get("assets", [])}
missing = sorted(expected - names)
unexpected = sorted(names - expected)
if missing or unexpected:
print(
"Unexpected GitHub Release assets:",
{
"missing": missing,
"unexpected": unexpected,
"names": sorted(names),
},
file=sys.stderr,
)
raise SystemExit(1)
PY
audit-downloaded-macos-release:
name: Audit downloaded macOS draft assets
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
needs: publish-release
runs-on: macos-latest
timeout-minutes: 75
permissions:
contents: read
steps:
- name: Checkout release verification helpers
uses: actions/checkout@v4
with:
lfs: false
persist-credentials: false
ref: ${{ env.RELEASE_TAG }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js for app.asar verification
uses: actions/setup-node@v4
with:
node-version: "22.12.0"
- name: Download actual draft release assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
set -euo pipefail
state="$(gh release view "${TAG}" --json isDraft)"
RELEASE_STATE="${state}" python - <<'PY'
import json
import os
assert json.loads(os.environ["RELEASE_STATE"])["isDraft"] is True, (
"downloaded release audit may only inspect a Draft release"
)
PY
mkdir -p release-audit
gh release download "${TAG}" --dir release-audit
- name: Verify checksums, signing, notarization, and packaged version
env:
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
set -euo pipefail
cd release-audit
python - <<'PY'
from hashlib import sha256
from pathlib import Path
lines = Path("SHA256SUMS").read_text(encoding="utf-8").splitlines()
assert lines, "SHA256SUMS is empty"
for line in lines:
digest, name = line.split(" ", 1)
path = Path(name)
assert path.is_file(), f"downloaded release asset is missing: {name}"
assert sha256(path.read_bytes()).hexdigest() == digest, (
f"checksum mismatch for downloaded asset: {name}"
)
PY
dmg_candidates=(OpenSquilla-*-mac-arm64.dmg)
zip_candidates=(OpenSquilla-*-mac-arm64.zip)
if [[ "${#dmg_candidates[@]}" -ne 1 || ! -f "${dmg_candidates[0]}" ]]; then
echo "Expected exactly one downloaded macOS DMG" >&2
exit 1
fi
if [[ "${#zip_candidates[@]}" -ne 1 || ! -f "${zip_candidates[0]}" ]]; then
echo "Expected exactly one downloaded macOS ZIP" >&2
exit 1
fi
expected_version="$(python - "${TAG#v}" <<'PY'
import re
import sys
print(re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", sys.argv[1]))
PY
)"
validate_app() {
local app_path plist actual_version asar_dir
app_path="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
plist="${app_path}/Contents/Info.plist"
actual_version="$(/usr/libexec/PlistBuddy \
-c 'Print :CFBundleShortVersionString' "${plist}")"
test "${actual_version}" = "${expected_version}"
asar_dir="$(mktemp -d)"
(
cd "${asar_dir}"
npx --yes @electron/asar@3.4.1 extract-file \
"${app_path}/Contents/Resources/app.asar" package.json
python - "${expected_version}" <<'PY'
import json
import sys
from pathlib import Path
packaged = json.loads(Path("package.json").read_text(encoding="utf-8"))
assert packaged["version"] == sys.argv[1], (
f"app.asar version {packaged['version']!r} != expected {sys.argv[1]!r}"
)
PY
)
python - "${asar_dir}" <<'PY'
import shutil
import sys
shutil.rmtree(sys.argv[1])
PY
codesign --verify --deep --strict --verbose=2 "${app_path}"
spctl -a -vv -t exec "${app_path}"
xcrun stapler validate "${app_path}"
}
extracted="$(mktemp -d)"
ditto -x -k "${zip_candidates[0]}" "${extracted}"
validate_app "${extracted}/OpenSquilla.app"
mounted="$(mktemp -d)"
cleanup_mount() {
hdiutil detach "${mounted}" -quiet >/dev/null 2>&1 || true
}
trap cleanup_mount EXIT
hdiutil attach -nobrowse -readonly -mountpoint "${mounted}" "${dmg_candidates[0]}"
validate_app "${mounted}/OpenSquilla.app"
hdiutil detach "${mounted}" -quiet
- name: Verify downloaded RC3-to-candidate macOS upgrade and uninstall
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
candidates=(release-audit/OpenSquilla-*-mac-arm64.dmg)
if [[ "${#candidates[@]}" -ne 1 || ! -f "${candidates[0]}" ]]; then
echo "Expected exactly one downloaded candidate macOS DMG" >&2
exit 1
fi
.github/scripts/verify-release-macos-upgrade.sh \
"${candidates[0]}" downloaded-asset
audit-downloaded-windows-release:
name: Audit downloaded Windows draft asset
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
needs: publish-release
runs-on: windows-latest
timeout-minutes: 75
permissions:
contents: read
steps:
- name: Checkout release verification helpers
uses: actions/checkout@v4
with:
lfs: false
persist-credentials: false
ref: ${{ env.RELEASE_TAG }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Download actual draft release assets
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
run: |
$ErrorActionPreference = 'Stop'
$state = gh release view $env:TAG --json isDraft | ConvertFrom-Json
if (-not $state.isDraft) {
throw 'Downloaded release audit may only inspect a Draft release.'
}
New-Item -ItemType Directory -Force -Path release-audit | Out-Null
gh release download $env:TAG --dir release-audit
if ($LASTEXITCODE -ne 0) { throw 'Failed to download Draft release assets.' }
- name: Verify downloaded checksums
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$checksumLines = Get-Content -LiteralPath 'release-audit/SHA256SUMS'
if ($checksumLines.Count -eq 0) { throw 'SHA256SUMS is empty.' }
foreach ($line in $checksumLines) {
$parts = $line -split ' ', 2
if ($parts.Count -ne 2) { throw "Malformed checksum line: $line" }
$path = Join-Path 'release-audit' $parts[1]
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Downloaded release asset is missing: $($parts[1])"
}
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant()
if ($actual -ne $parts[0].ToLowerInvariant()) {
throw "Checksum mismatch for downloaded asset: $($parts[1])"
}
}
- name: Verify downloaded RC3-to-candidate Windows upgrade and uninstall
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$candidates = @(Get-ChildItem 'release-audit/OpenSquilla-*-win-x64.exe' -File)
if ($candidates.Count -ne 1) {
throw "Expected exactly one downloaded Windows installer; got $($candidates.Count)."
}
.github/scripts/verify-release-windows-upgrade.ps1 `
-CandidateInstaller $candidates[0].FullName `
-Label downloaded-asset