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())