From fbfab069d237b469ed35f5854275dd04365c7386 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 22 Sep 2026 19:47:29 -0400 Subject: [PATCH] Generate the privacy page from the app repo's PRIVACY.md The policy existed twice: PRIVACY.md in submersion-app/submersion and a hand-maintained privacy/index.html here. Nothing kept them in step, and they had drifted badly. The app repo's policy was rewritten to disclose Dropbox, S3, iCloud, Garmin, Adobe and roughly fifteen coordinate services, to correct claims about encryption that is off by default, and to disclose that dive buddy photos, phone numbers and certification scans leave the device. This page was still serving the 2026-03-06 text, which names Google Drive as the only destination. That gap matters beyond tidiness: the Play listing links this page, and Google checks the Data safety declaration against the hosted policy. A page contradicting the declaration is a policy violation. PRIVACY.md is now the single source. - tools/render_legal.py converts the Markdown and replaces only the region between the legal:meta and legal:body markers, so the head, navigation, intro, back link and footer stay hand-owned. Dependency-free so the Actions job needs no install step, and deliberately limited to the constructs the policy actually uses. - The renderer keeps this page's existing conventions: h2 ids matching the old hand-written anchors so inbound links still resolve, tables wrapped in legal__table, and `**Term** - description` bullets rendered as `Term: description`. - sync-legal.yml regenerates on a daily schedule, on manual dispatch, on a repository_dispatch the app repo can send, and on template changes. It commits only when the output differs, so the push trigger cannot loop. - On a pull request the same workflow runs --check instead of writing, so a hand edit inside the generated region fails the PR with an explanation rather than being silently reverted by the next sync. The page in this commit is the current policy, regenerated and verified against the live PRIVACY.md. Terms of Service has the same duplication. The renderer already handles it; wiring terms/index.html needs only its markers and a second workflow step. --- .github/workflows/sync-legal.yml | 104 +++++++++++ privacy/index.html | 223 +++++++++++++++++------ tools/render_legal.py | 297 +++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+), 56 deletions(-) create mode 100644 .github/workflows/sync-legal.yml create mode 100644 tools/render_legal.py diff --git a/.github/workflows/sync-legal.yml b/.github/workflows/sync-legal.yml new file mode 100644 index 0000000..aff7be8 --- /dev/null +++ b/.github/workflows/sync-legal.yml @@ -0,0 +1,104 @@ +name: Sync legal pages + +# The policy text has one home: PRIVACY.md in submersion-app/submersion. This +# workflow regenerates privacy/index.html from it so the site cannot drift out +# of step with the app's own policy, which is what happened before: the app +# repo's policy was rewritten while the published page kept serving a version +# months out of date. A store listing that links a policy contradicting the +# app's Data safety declaration is a policy violation, so the drift was not +# cosmetic. +# +# Triggers: +# schedule a daily floor, so a missed dispatch still converges +# workflow_dispatch manual "sync it now" +# repository_dispatch the app repo can push a `legal-updated` event to make +# a policy change land here within a minute +# push template or renderer changes regenerate immediately +# +# The push trigger cannot loop: the sync job commits only when the rendered +# output differs, so the commit it makes produces a run that finds no change +# and stops. + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + repository_dispatch: + types: [legal-updated] + push: + branches: [main] + paths: + - tools/render_legal.py + - privacy/index.html + - .github/workflows/sync-legal.yml + pull_request: + paths: + - tools/render_legal.py + - privacy/index.html + - .github/workflows/sync-legal.yml + +# Source of the policy text. Kept here rather than inline so the two jobs +# cannot disagree about where it comes from. +env: + POLICY_URL: https://raw.githubusercontent.com/submersion-app/submersion/main/PRIVACY.md + +permissions: + contents: read + +jobs: + # On a pull request, verify rather than write. This is what stops someone + # hand-editing the generated region of the page: the edit would be silently + # reverted by the next sync, so the PR fails instead and says why. + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch the policy from the app repository + run: curl -fsSL --retry 3 "$POLICY_URL" -o /tmp/PRIVACY.md + + - name: Verify the page matches the policy + run: | + if ! python3 tools/render_legal.py \ + --source /tmp/PRIVACY.md \ + --target privacy/index.html \ + --check; then + echo "::error::privacy/index.html does not match PRIVACY.md in the app repo." + echo "::error::Edit PRIVACY.md there, not the generated region of this page." + echo "::error::To refresh locally: python3 tools/render_legal.py --source --target privacy/index.html" + exit 1 + fi + + sync: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Fetch the policy from the app repository + run: curl -fsSL --retry 3 "$POLICY_URL" -o /tmp/PRIVACY.md + + - name: Render the page + run: | + python3 tools/render_legal.py \ + --source /tmp/PRIVACY.md \ + --target privacy/index.html + + - name: Commit if the page changed + run: | + if git diff --quiet -- privacy/index.html; then + echo "Policy page already current; nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add privacy/index.html + git commit -m "Sync the privacy policy page with PRIVACY.md + + Regenerated by .github/workflows/sync-legal.yml from + submersion-app/submersion. Edit PRIVACY.md there rather than the + generated region of this page." + git push diff --git a/privacy/index.html b/privacy/index.html index b4e6e22..781defb 100644 --- a/privacy/index.html +++ b/privacy/index.html @@ -70,15 +70,17 @@

Privacy Policy.

Submersion is a local-first dive log. Your data stays on your device unless you choose to back it up to a service you control.

- + + + "] + return out + + +def parse(markdown: str) -> tuple[str, list[str]]: + """Split the document into its meta line and its rendered body blocks.""" + lines = markdown.replace("\r\n", "\n").split("\n") + + app = last_updated = "" + body: list[str] = [] + i = 0 + paragraph: list[str] = [] + bullets: list[str] = [] + + def flush_paragraph() -> None: + nonlocal paragraph + if paragraph: + body.append(f"

{render_inline(' '.join(paragraph))}

") + paragraph = [] + + def flush_bullets() -> None: + nonlocal bullets + if bullets: + # extend(), not `body += ...`: an augmented assignment would make + # `body` local to this closure and shadow the list being built. + body.append("") + bullets = [] + + def flush() -> None: + flush_paragraph() + flush_bullets() + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + if not stripped: + flush() + i += 1 + continue + + # The document's own H1 is dropped: the page supplies its title in + # hand-written markup outside the generated region. + if stripped.startswith("# "): + flush() + i += 1 + continue + + meta = re.match(r"^\*\*(App|Last Updated):\*\*\s*(.+)$", stripped) + if meta: + flush() + if meta.group(1) == "App": + app = meta.group(2).strip() + else: + last_updated = meta.group(2).strip() + i += 1 + continue + + heading = re.match(r"^(#{2,4})\s+(.*)$", stripped) + if heading: + flush() + level = len(heading.group(1)) + text = render_inline(heading.group(2)) + # Only H2 carries an id: those are the sections the page's + # anchors and any inbound deep links point at. + if level == 2: + body.append(f'

{text}

') + else: + body.append(f"{text}") + i += 1 + continue + + if stripped.startswith("|"): + flush() + rows = [] + while i < len(lines) and lines[i].strip().startswith("|"): + rows.append(lines[i]) + i += 1 + if len(rows) >= 2: + body += render_table(rows) + continue + + bullet = re.match(r"^[-*]\s+(.*)$", stripped) + if bullet: + flush_paragraph() + item = bullet.group(1) + i += 1 + # A bullet may wrap onto following indented lines. + while i < len(lines) and lines[i].startswith((" ", "\t")) and lines[i].strip(): + if re.match(r"^\s*[-*]\s+", lines[i]): + break + item += " " + lines[i].strip() + i += 1 + bullets.append(item) + continue + + flush_bullets() + paragraph.append(stripped) + i += 1 + + flush() + + meta_line = " · ".join( + part for part in (f"App: {app}" if app else "", f"Last updated: {last_updated}" if last_updated else "") if part + ) + return meta_line, body + + +def splice(page: str, begin: str, end: str, replacement: str) -> str: + """Replace the text between two markers, keeping the markers.""" + start = page.find(begin) + stop = page.find(end) + if start == -1 or stop == -1: + raise SystemExit( + f"marker not found in target page: {begin if start == -1 else end}\n" + "The page must carry the legal:meta and legal:body markers; see tools/render_legal.py." + ) + if stop < start: + raise SystemExit(f"markers out of order in target page: {end} precedes {begin}") + return page[: start + len(begin)] + replacement + page[stop:] + + +def render(markdown: str, page: str) -> str: + meta_line, body = parse(markdown) + + meta_html = ( + "\n" + + META_INDENT + + f'\n' + + META_INDENT + ) + body_html = "\n" + "\n".join(BODY_INDENT + line for line in body) + "\n" + BODY_INDENT + + page = splice(page, META_BEGIN, META_END, meta_html) + page = splice(page, BODY_BEGIN, BODY_END, body_html) + return page + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, type=Path, help="Markdown policy file") + parser.add_argument("--target", required=True, type=Path, help="HTML page to update in place") + parser.add_argument( + "--check", + action="store_true", + help="Exit 1 if the page is out of date instead of writing it", + ) + args = parser.parse_args() + + markdown = args.source.read_text(encoding="utf-8") + page = args.target.read_text(encoding="utf-8") + updated = render(markdown, page) + + if updated == page: + print(f"{args.target}: up to date") + return 0 + + if args.check: + print(f"{args.target}: OUT OF DATE, run tools/render_legal.py without --check", file=sys.stderr) + return 1 + + args.target.write_text(updated, encoding="utf-8") + print(f"{args.target}: updated from {args.source}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())