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
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
name: CI

# Runs on every push and PR. Three things must hold:
# Runs on every push and PR. Four things must hold:
# 1. generated files match their sources (Tokens.swift, Symbols.swift, Sources/Pantry/Models, NetRelish.xcodeproj)
# 2. both product flavors build — AppStore and Direct (DIRECT_BUILD)
# 3. the tests pass (hosted NetRelishTests + unhosted GeneratorTests)
# 4. the non-negotiable-5 gate still enforces what it claims to

# Read-only token. Every job here checks out, regenerates, builds or tests —
# nothing writes back to the repository. Without this block the workflow
Expand Down Expand Up @@ -87,3 +88,16 @@ jobs:
with:
name: design-kit-snapshots
path: design-kit-snapshots/*.png

gate-tests:
name: CodeQL gate self-test
# Ubuntu, and not xcode-27, on purpose. This tests scripts/codeql_gate.py,
# which is pure Python and touches no Swift — and it MUST keep running while
# the CodeQL workflow cannot build this project on the macOS image. A gate
# whose only proof of correctness lives inside the job that is currently red
# is not proven at all.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 (node24)
- name: The gate enforces what it claims to
run: python3 -m unittest discover -s scripts/tests -v
142 changes: 18 additions & 124 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,132 +151,26 @@ jobs:
category: "/language:swift"
output: .codeql-results

# analyze uploads findings; it never fails the job on them. Without this
# step the gate is a report, not a gate — the CodeQL check can sit green
# with an alert on it, so requiring it in branch protection would still let
# a fifth endpoint merge. Gating repo-side works too (code scanning's
# check-failure severity, or a ruleset), but that is a console toggle a
# fork, a clone or a forgetful afternoon loses. This travels with the repo.
# analyze uploads findings; it never fails the job on them, and there is
# no `fail-on` input. Without this step the gate is a report, not a gate —
# the CodeQL check can sit green with an alert on it, so requiring it in
# branch protection would still let a fifth endpoint merge. Gating
# repo-side works too (code scanning's check-failure severity, or a
# ruleset), but that is a console toggle a fork, a clone or a forgetful
# afternoon loses. This travels with the repo.
#
# The logic is in scripts/codeql_gate.py, not inline here. It has had
# twenty-eight defects — five before it was extracted, and twenty-three more found
# by review of the branch that extracted it — every one in its
# enforcement rather than its design, and every one failing OPEN. A
# heredoc cannot be tested, so the first five were caught by a human
# reading it, or not caught at all. It now has a suite in scripts/tests/
# that the `CodeQL gate self-test` job in ci.yml runs on every push,
# including while THIS job cannot build the project. The module header
# lists all twenty-eight; read it before adding anything here.
#
# Only the gate fails the build. netrelish/remote-capable-url-read has two
# legitimate baseline hits; failing on it would teach everyone to ignore a
# red CodeQL job, which is the opposite of what the split was for.
- name: Fail if the gate fired
run: |
python3 - <<'PY'
import glob, json, pathlib, re, sys
from urllib.parse import unquote, urlparse

GATE = "netrelish/outbound-network-call"

# An approved exception is a comment on the flagged line or the one
# directly above it, citing the ADR that approved it:
#
# // NETRELISH-ALLOW-ENDPOINT: adr-0007 — RFC 3161 timestamping
#
# It lives beside the code rather than in a list, so it shows up in
# every diff that touches the call, cannot drift away from what it
# exempts, and dies with the line. The ADR reference is required
# because CLAUDE.md already says an uncovered decision gets one, and
# an exception with no written reason is how a rule rots.
#
# Dismissing the alert in the Security tab deliberately does NOT
# satisfy this: that is invisible from the repository.
MARKER = re.compile(r"NETRELISH-ALLOW-ENDPOINT:\s*(adr-\d{4})")

def exemption(line):
"""The marker, but only in a // comment and naming a real ADR.

Two bypasses this closes. Searching raw source matched the token
inside a string literal, so a line like
let s = "NETRELISH-ALLOW-ENDPOINT: adr-0001" was an exemption.
And checking only the adr-NNNN SHAPE meant adr-9999 passed with
no written decision existing at all, which made the ADR
requirement decoration rather than a control.

Still not a Swift parser: a // inside a string literal ahead of
the marker would fool it. That is far narrower than either hole
above, and closing it properly needs parsed source, not text.
"""
_, sep, comment = line.partition("//")
if not sep:
return None
found = MARKER.search(comment)
if not found:
return None
number = found.group(1)[len("adr-"):]
if not list(pathlib.Path("docs/adr").glob(number + "-*.md")):
print("::error::" + found.group(1)
+ " is cited but no docs/adr/" + number + "-*.md exists")
return None
return found.group(1)

def source_path(uri):
"""The checkout-relative path for a SARIF artifactLocation.

SARIF gives a URI, not a filename. CodeQL usually emits a
repo-relative one, but a file:///... form (often with a
uriBaseId) is equally legal and pathlib.Path would treat the
whole thing as a literal relative name. The source would then
never open, no marker would be found, and a VALID exemption
would block the build with a misleading message.

Fails closed either way, but closed-and-wrong is still wrong.
"""
parsed = urlparse(uri)
raw = unquote(parsed.path) if parsed.scheme == "file" else unquote(uri)
path = pathlib.Path(raw)
if path.is_absolute():
try:
path = path.relative_to(pathlib.Path.cwd())
except ValueError:
pass # genuinely outside the checkout; leave it absolute
return path

paths = sorted(glob.glob(".codeql-results/*.sarif"))
if not paths:
# A missing SARIF must never read as "clean" — that is precisely
# the false assurance this whole query pack exists to prevent.
print("::error::no SARIF in .codeql-results; the gate did not run")
sys.exit(1)

approved, blocking = [], []
for path in paths:
with open(path) as f:
sarif = json.load(f)
for run in sarif.get("runs", []):
for result in run.get("results", []):
if result.get("ruleId") != GATE:
continue
where = result["locations"][0]["physicalLocation"]
uri = where["artifactLocation"]["uri"]
line = where.get("region", {}).get("startLine", 1)

source, found = source_path(uri), None
if source.is_file():
body = source.read_text(errors="replace").splitlines()
for candidate in body[max(0, line - 2):line]:
found = found or exemption(candidate)
else:
print("::error::cannot read " + str(source) + " (from SARIF uri "
+ uri + "); an exemption there could not be checked")

entry = (str(source), line, result["message"]["text"], found)
(approved if found else blocking).append(entry)

for uri, line, _, found in approved:
print(f"::notice file={uri},line={line}::approved endpoint ({found})")
for uri, line, message, _ in blocking:
print(f"::error file={uri},line={line}::{message}")

if approved:
print(f"\n{len(approved)} approved exception(s), re-printed every run so they stay visible.")

if blocking:
print(f"\n{GATE} fired {len(blocking)} time(s) with no approved exception. "
"CLAUDE.md non-negotiable 5 permits page loads, StoreKit, RFC 3161 "
"timestamping and direct-build licence activation, and nothing else.")
sys.exit(1)

print(f"{GATE}: clean across {len(paths)} SARIF file(s).")
PY
run: python3 scripts/codeql_gate.py .codeql-results
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ AuthKey_*.p8

# logs
*.log

# Python bytecode from scripts/ and its tests
__pycache__/
*.pyc
4 changes: 2 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ Two builds ship from one codebase (`#if DIRECT_BUILD`):
| Build | Signs with | Distributed by | Updates by |
| :-- | :-- | :-- | :-- |
| App Store | Apple Distribution | App Store Connect | the App Store |
| Direct | Developer ID | *undecided* | email — by design, see ADR 0006 |
| Direct | Developer ID | *undecided* | email — by design, see ADR 0007 |

The direct build has **no updater, deliberately**: no Sparkle, no appcast,
nothing that polls. A new version is announced by email to the address that
bought the licence, and About links to the download page. Decided by Ryan on
2026-09-23; the reasoning, and what would overturn it, are in
`docs/adr/0006-direct-build-updates.md`.
`docs/adr/0007-direct-build-updates.md`.
Comment thread
Copilot marked this conversation as resolved.

The constraint that drove it is CLAUDE.md §5, which lists every call the app
may make and ends "Add an endpoint and you've broken this rule." An appcast
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ADR 0006 — The direct build announces updates; it does not fetch them
# ADR 0007 — The direct build announces updates; it does not fetch them

**Status:** accepted (Ryan, 2026-09-23) · **Date:** 2026-09-23 · **PR:** docs: ADR for the direct-build updater

Expand Down
Loading
Loading