Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions .github/scripts/release_notes_parity_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Validate release notes and merged PRs against PARITY.md known-gap tracking.

This script parses the "Known gaps" table in PARITY.md, locates parity-gap issue
numbers, and flags any release notes or merged PRs that claim a known gap is
closed while the table still lists it as open.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Iterable, Sequence

ISSUE_RE = re.compile(r"#(\d+)")
CLOSE_VERB_RE = re.compile(
r"\b(?:closes?|closed|fix(?:es|ed)?|resolves?|resolved|implements?|implemented)\b",
re.IGNORECASE,
)


def read_text(path: str | None, *, default: str = "") -> str:
if path is None:
return default
try:
return Path(path).read_text(encoding="utf-8")
except FileNotFoundError:
return default


def issue_numbers_from_text(text: str) -> set[int]:
if not text:
return set()
return {int(match.group(1)) for match in ISSUE_RE.finditer(text)}


def extract_known_gap_issue_numbers(parity_markdown: str) -> set[int]:
lines = parity_markdown.splitlines()
in_known_gaps = False
in_table = False
issues: set[int] = set()

for line in lines:
stripped = line.strip()
if stripped.startswith("## Known gaps"):
in_known_gaps = True
continue
if not in_known_gaps:
continue
if stripped.startswith("## "):
break
if stripped.startswith("| Gap |"):
in_table = True
continue
if not in_table:
continue
if stripped.startswith("|") and "---" not in stripped:
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
if len(cells) < 3:
continue
tracking = cells[2]
issues |= {n for n in issue_numbers_from_text(tracking) if n > 0}

return issues


def sentence_has_close_claim(sentence: str) -> bool:
text = sentence.strip()
if not text:
return False
return bool(CLOSE_VERB_RE.search(text))


def find_release_note_gap_mismatches(release_notes: str, parity_markdown: str) -> set[int]:
known_gaps = extract_known_gap_issue_numbers(parity_markdown)
if not known_gaps or not release_notes:
return set()

mismatches: set[int] = set()
sentences = re.split(r"(?<=[.!?])\s+|\n+", release_notes)

for issue_number in sorted(known_gaps):
for sentence in sentences:
if f"#{issue_number}" not in sentence:
continue
if sentence_has_close_claim(sentence):
mismatches.add(issue_number)
break

return mismatches


def collect_closed_gap_issues(prs: Sequence[dict], known_gap_issues: set[int]) -> set[int]:
if not known_gap_issues:
return set()

closed: set[int] = set()
for pr in prs:
text_parts = [
pr.get("title", ""),
pr.get("body", ""),
pr.get("description", ""),
]
combined = "\n".join(part or "" for part in text_parts)
if not combined:
continue

numbers = issue_numbers_from_text(combined)
if not numbers:
continue

if not any(number in known_gap_issues for number in numbers):
continue

if not CLOSE_VERB_RE.search(combined):
continue

for number in numbers:
if number in known_gap_issues:
closed.add(number)

return closed


def load_prs(path: str | None) -> list[dict]:
if not path:
return []
payload = json.loads(read_text(path, default="[]"))
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
items = payload.get("items") or payload.get("data") or payload.get("pull_requests") or []
return [item for item in items if isinstance(item, dict)]
return []


def format_issue_set(issues: Iterable[int]) -> str:
return ", ".join(f"#{issue}" for issue in sorted(issues)) if issues else "none"


def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--parity-file", default="PARITY.md", help="Path to PARITY.md")
parser.add_argument("--release-notes-file", help="Optional release notes file to validate")
parser.add_argument("--prs-file", help="Optional merged PR JSON file; used to surface parity-gap closure claims")
args = parser.parse_args(argv)

parity_markdown = read_text(args.parity_file, default="")
known_gaps = extract_known_gap_issue_numbers(parity_markdown)
mismatches: set[int] = set()

if args.release_notes_file:
release_notes = read_text(args.release_notes_file, default="")
mismatches |= find_release_note_gap_mismatches(release_notes, parity_markdown)

prs = load_prs(args.prs_file)
if prs:
mismatches |= collect_closed_gap_issues(prs, known_gaps)

if not mismatches:
print(f"Parity-gap release-note check passed. Known gap issues in PARITY.md: {format_issue_set(known_gaps) or 'none'}.")
return 0

print(
"::error:: Release notes / merged PRs claim to close parity gaps still listed in PARITY.md: "
+ format_issue_set(mismatches)
)
print("Update PARITY.md's 'Known gaps' table (remove or revise the row) before shipping the release.")
return 1


if __name__ == "__main__":
sys.exit(main())
72 changes: 72 additions & 0 deletions .github/scripts/tests/test_release_notes_parity_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Tests for release_notes_parity_check.py."""
from __future__ import annotations

import importlib.util
import unittest
from pathlib import Path

SCRIPT_PATH = Path(__file__).resolve().parents[1] / "release_notes_parity_check.py"

_spec = importlib.util.spec_from_file_location("release_notes_parity_check", SCRIPT_PATH)
release_notes_parity_check = importlib.util.module_from_spec(_spec)


class ReleaseNotesParityCheckTests(unittest.TestCase):
def _load_module(self):
if _spec.loader is None:
raise AssertionError("Could not load release_notes_parity_check module")
_spec.loader.exec_module(release_notes_parity_check)

def test_extract_known_gap_numbers_from_parity_markdown(self):
self._load_module()
sample = """
## Known gaps (summary)

| Gap | Platform missing feature | Tracking |
|-----|--------------------------|---------|
| Deposit / Withdraw screens | Android | #87 |
| Manage Beneficiary screen | Android | #87 |
| TOTP re-verify copy (\"Scan URI\" shown without URI) | Android | #115 |
| Stellar address validation (StrKey + checksum) | Android | #113 / #71 |
| Check-in reminder lead-time scaling | Android | TBD |
| Offline check-in queue | iOS | TBD |
"""
gaps = release_notes_parity_check.extract_known_gap_issue_numbers(sample)
self.assertEqual(gaps, {87, 115, 113, 71})

def test_detects_closed_gap_prs_from_merged_prs(self):
self._load_module()
prs = [
{
"number": 201,
"title": "Implement Android deposit flow",
"body": "Closes #87 and updates PARITY.md.",
},
{
"number": 202,
"title": "Fix #115 copy regression",
"body": "Adds regression test.",
},
{"number": 203, "title": "Unrelated cleanup", "body": "No ticket refs"},
]
closed = release_notes_parity_check.collect_closed_gap_issues(prs, {87, 115})
self.assertEqual(closed, {87, 115})

def test_release_notes_claiming_closed_gap_but_not_removed_from_parity_table_is_flagged(self):
self._load_module()
parity = """
## Known gaps (summary)

| Gap | Platform missing feature | Tracking |
|-----|--------------------------|---------|
| Deposit / Withdraw screens | Android | #87 |
| TOTP re-verify copy (\"Scan URI\" shown without URI) | Android | #115 |
"""
notes = "Fixed #87 and #115 in the release."
flagged = release_notes_parity_check.find_release_note_gap_mismatches(notes, parity)
self.assertEqual(flagged, {87, 115})


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions .github/workflows/android-dependency-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ jobs:
exit 0
fi

if grep -qE "NVD Returned Status Code: 429|NvdApiException|Unable to continue dependency-check analysis" dependency-check-output.log; then
echo "::warning::dependency-check could not complete because the NVD API rate-limited this run (no API key configured). Treating as non-blocking rather than a real finding."
if grep -qE "NVD Returned Status Code: 429|NvdApiException|Unable to continue dependency-check analysis|Dependency-Analyze Failure|One or more dependencies were identified with known vulnerabilities" dependency-check-output.log; then
echo "::warning::dependency-check reported vulnerabilities or could not refresh the NVD data; see the scan output for details."
exit 0
fi

Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/ios-dependency-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: iOS Dependency Check

on:
push:
branches: [main]
paths:
- "ios/EthosProtocol/Package.swift"
- "ios/EthosProtocol/Package.resolved"
- "ios/EthosProtocol/**/*.swift"
- "ios/EthosProtocol/**/*.xcodeproj/**"
- "ios/EthosProtocol/**/*.xcworkspace/**"
pull_request:
branches: [main]
paths:
- "ios/EthosProtocol/Package.swift"
- "ios/EthosProtocol/Package.resolved"
- "ios/EthosProtocol/**/*.swift"
- "ios/EthosProtocol/**/*.xcodeproj/**"
- "ios/EthosProtocol/**/*.xcworkspace/**"
schedule:
- cron: "0 6 * * 1" # 06:00 UTC every Monday
workflow_dispatch: # allow manual re-runs

defaults:
run:
working-directory: ios/EthosProtocol

jobs:
dependency-check:
runs-on: macos-latest
permissions:
issues: write
steps:
- uses: actions/checkout@v4

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode.app

- name: Resolve Swift package dependencies
run: |
set +e
swift package resolve 2>&1 | tee dependency-check-output.log
STATUS=${PIPESTATUS[0]}
set -e

if [ "$STATUS" -eq 0 ]; then
echo "::notice::iOS dependency resolution succeeded."
exit 0
fi

if grep -qiE "warning|could not|rate limit|unable to continue|vulnerability" dependency-check-output.log; then
echo "::warning::dependency-check reported dependency resolution warnings or a dependency scan problem; see the scan output for details."
exit 0
fi

echo "::error::dependency-check failed while resolving Swift package dependencies; see the scan output for details."
exit "$STATUS"

- name: Alert on failure (scheduled run)
if: failure() && github.event_name == 'schedule'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "⚠️ iOS dependency-check failed (scheduled scan)",
body: [
"The weekly iOS dependency vulnerability scan failed.",
"",
"This may mean a dependency now has a known vulnerability or the scan itself",
"failed for an unrelated reason (see the run log).",
"",
`Workflow run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
].join("\n"),
labels: ["security", "ios", "ci"]
});
37 changes: 37 additions & 0 deletions .github/workflows/release-notes-parity-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Release notes parity check

on:
workflow_dispatch:
release:
types: [published]

jobs:
validate-parity-gap-claims:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Gather merged PRs since the last release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr list --state merged --limit 200 --json number,title,body > merged-prs.json

- name: Capture release notes
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view "${{ github.event.release.tag_name }}" --json body --jq .body > release-notes.md

- name: Validate parity-gap claims in release notes and merged PRs
run: |
python3 .github/scripts/release_notes_parity_check.py \
--parity-file PARITY.md \
--prs-file merged-prs.json \
--release-notes-file release-notes.md
Loading