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
6 changes: 6 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ jobs:
- name: oxfmt
run: pnpm format:check

- name: Dependency notices
working-directory: .
run: |
pnpm notices:check
python3 -m unittest discover -s scripts -p 'test_notices.py'

- name: oxlint
run: pnpm lint

Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Build gravityd
run: cargo build --release -p gravityd
run: |
pnpm notices:check --check-toolchain
cargo build --locked --release -p gravityd

- name: Package gravityd
env:
Expand All @@ -85,6 +87,8 @@ jobs:
stage="stage/gravityd-$VERSION"
mkdir -p dist "$stage"
cp target/release/gravityd "$stage/gravityd"
cp LICENSE "$stage/"
cp third-party/DAEMON_NOTICES.txt "$stage/THIRD_PARTY_NOTICES.txt"
cp ops/gravityd.example.toml "$stage/"
cp scripts/install-gravityd.sh "$stage/"
chmod +x "$stage/install-gravityd.sh"
Expand Down Expand Up @@ -214,6 +218,14 @@ jobs:
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"

- name: Verify packaged notices
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
python3 scripts/check-packaged-notices.py \
--app apps/desktop/src-tauri/target/release/bundle/macos/Gravity.app \
--daemon "dist/gravityd-$VERSION-aarch64-apple-darwin.tar.gz"

- uses: actions/upload-artifact@v4
with:
name: release-${{ steps.version.outputs.version }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ apps/desktop/src-tauri/binaries/
.gravity/
.mcp.json
.claude/settings.local.json
__pycache__/
4 changes: 4 additions & 0 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"bundle": {
"active": true,
"createUpdaterArtifacts": false,
"resources": {
"../../../third-party/DESKTOP_NOTICES.txt": "THIRD_PARTY_NOTICES.txt",
"../../../LICENSE": "LICENSE"
},
"externalBin": [
"binaries/gravityd"
],
Expand Down
1 change: 1 addition & 0 deletions apps/marketing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ <h2 id="download-title">Put your team to work.</h2>
<div class="shell footer-content">
<span>&copy; <span data-year>2026</span> Gravity</span>
<span>Built for the work that keeps going.</span>
<a href="/THIRD_PARTY_NOTICES.txt">Third-party notices</a>
</div>
</footer>

Expand Down
2,339 changes: 2,339 additions & 0 deletions apps/marketing/public/THIRD_PARTY_NOTICES.txt

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions crates/gravityd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ fn flag_value(args: &[String], flag: &str) -> Option<PathBuf> {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) == Some("--third-party-notices") {
print!(
"{}",
include_str!("../../../third-party/DAEMON_NOTICES.txt")
);
return Ok(());
}
let config_path = flag_value(&args, "--config");
let negotiate_port = args.iter().any(|arg| arg == "--negotiate-port");

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"private": true,
"license": "MIT",
"scripts": {
"verify": "bash scripts/verify.sh"
"verify": "bash scripts/verify.sh",
"notices:generate": "python3 scripts/notices.py --generate",
"notices:check": "python3 scripts/notices.py"
}
}
43 changes: 43 additions & 0 deletions scripts/check-packaged-notices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Verify actual app resources, embedded sidecar notices and daemon tar contents."""

import argparse
import pathlib
import subprocess
import tarfile

ROOT = pathlib.Path(__file__).resolve().parents[1]


def require_equal(actual, expected, label):
if actual != expected:
raise ValueError("Packaged notice mismatch: " + label)


def check(app, daemon):
desktop_notice = (ROOT / "third-party/DESKTOP_NOTICES.txt").read_bytes()
daemon_notice = (ROOT / "third-party/DAEMON_NOTICES.txt").read_bytes()
license_text = (ROOT / "LICENSE").read_bytes()
resources = app / "Contents/Resources"
require_equal((resources / "THIRD_PARTY_NOTICES.txt").read_bytes(), desktop_notice, "app")
require_equal((resources / "LICENSE").read_bytes(), license_text, "app MIT license")
embedded = subprocess.check_output([app / "Contents/MacOS/gravityd", "--third-party-notices"])
require_equal(embedded, daemon_notice, "sidecar")
with tarfile.open(daemon) as archive:
for filename, expected in [("THIRD_PARTY_NOTICES.txt", daemon_notice), ("LICENSE", license_text)]:
entries = [m for m in archive.getmembers() if pathlib.PurePosixPath(m.name).name == filename]
if len(entries) != 1 or not entries[0].isfile():
raise ValueError("Missing or ambiguous daemon archive notice: " + filename)
stream = archive.extractfile(entries[0])
if stream is None:
raise ValueError("Unreadable daemon archive notice: " + filename)
require_equal(stream.read(), expected, filename)
print("App resources, embedded sidecar and daemon archive notices verified.")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--app", required=True, type=pathlib.Path)
parser.add_argument("--daemon", required=True, type=pathlib.Path)
args = parser.parse_args()
check(args.app, args.daemon)
252 changes: 252 additions & 0 deletions scripts/notices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Generate notices from frozen installs; check committed inputs offline on CI."""

import argparse
import hashlib
from html.parser import HTMLParser
import json
import pathlib
import re
import subprocess
import tomllib
import urllib.request

ROOT = pathlib.Path(__file__).resolve().parents[1]
TARGET = "aarch64-apple-darwin"
EXCLUDED = {"other-platform", "build tool (not shipped)"}
INVENTORY = ROOT / "third-party/inventory.json"
OVERRIDES = ROOT / "third-party/overrides"
OUTPUTS = {
"daemon": "third-party/DAEMON_NOTICES.txt",
"desktop": "third-party/DESKTOP_NOTICES.txt",
"marketing": "apps/marketing/public/THIRD_PARTY_NOTICES.txt",
}
INPUTS = [
"Cargo.lock", "Cargo.toml", "crates/gravityd/Cargo.toml", "crates/bus/Cargo.toml",
"apps/desktop/src-tauri/Cargo.lock", "apps/desktop/src-tauri/Cargo.toml",
"apps/desktop/src-tauri/tauri.conf.json", "apps/desktop/package.json",
"apps/desktop/pnpm-lock.yaml", "apps/desktop/vite.config.ts",
"apps/marketing/package.json", "apps/marketing/pnpm-lock.yaml",
"apps/marketing/src/main.ts", "apps/marketing/src/worker.ts",
".github/workflows/release.yml", "scripts/prepare-sidecar.sh",
"scripts/notices.py", "third-party/README.md",
]


def digest(data):
return hashlib.sha256(data).hexdigest()


def notice_text(data):
return "\n".join(line.rstrip() for line in data.decode("utf-8").splitlines()).rstrip() + "\n"


def run(*args):
return subprocess.check_output(args, cwd=ROOT, text=True)


def input_hashes():
files = [ROOT / name for name in INPUTS] + sorted(OVERRIDES.rglob("*"))
return {str(p.relative_to(ROOT)): digest(p.read_bytes()) for p in files if p.is_file()}


def license_files(root):
# Include nested vendored notices (not just the package's top-level license).
pattern = re.compile(r"^(licen[sc]e|copying|notice|copyright)(?:$|[-_.])", re.I)
return sorted(p for p in root.rglob("*") if p.is_file()
and "node_modules" not in p.relative_to(root).parts
and pattern.match(p.name)
and p.suffix.lower() not in {".rs", ".js", ".mjs", ".map", ".ts"})


class LicenseHTML(HTMLParser):
def __init__(self):
super().__init__()
self.text = []

def handle_data(self, data):
self.text.append(data)


def toolchain_package():
version = run("rustc", "--version").strip()
release = version.split()[1]
root = pathlib.Path(run("rustc", "--print", "sysroot").strip()) / "share/doc/rust"
files = [root / "COPYRIGHT-library.html"]
files += [root / "licenses" / (name + ".txt") for name in
["MIT", "Apache-2.0", "Unicode-3.0", "LLVM-exception", "BSD-2-Clause"]]
if not all(file.is_file() for file in files):
raise ValueError("Install the matching official rustc component to collect standard library notices")
source = f"https://static.rust-lang.org/dist/rustc-{release}-src.tar.xz"
copyright_source = f"https://static.rust-lang.org/dist/rustc-{release}-{TARGET}.tar.xz"
with urllib.request.urlopen(source + ".sha256", timeout=30) as response:
checksum = response.read().decode().split()[0]
return {"ecosystem": "Rust toolchain", "name": "standard-library", "version": version,
"license": "MIT OR Apache-2.0; upstream component exceptions below",
"components": {"daemon": "runtime", "desktop": "runtime"},
"source": source, "checksum": checksum, "copyright_source": copyright_source,
"path": root, "files": files}


def rust_packages(manifest, component):
data = json.loads(run("cargo", "metadata", "--locked", "--format-version", "1",
"--manifest-path", manifest))
filtered = json.loads(run("cargo", "metadata", "--locked", "--format-version", "1",
"--filter-platform", TARGET, "--manifest-path", manifest))
selected = {node["id"] for node in filtered["resolve"]["nodes"]}
runtime = run("cargo", "tree", "--locked", "--target", TARGET,
"--manifest-path", manifest, "--edges", "normal,no-proc-macro",
"--prefix", "none", "--format", "{p}").splitlines()
runtime = {line.removesuffix(" (*)") for line in runtime}
lock = tomllib.loads((ROOT / manifest).with_name("Cargo.lock").read_text())
checksums = {(p["name"], p["version"]): p.get("checksum") for p in lock["package"]}
for p in data["packages"]:
if p["source"] is None:
continue
if not p["source"].startswith("registry+"):
raise ValueError("Review non-registry source: " + p["name"])
scope = "other-platform"
if p["id"] in selected:
scope = "runtime" if f"{p['name']} v{p['version']}" in runtime else "build/test"
yield {
"ecosystem": "Rust", "name": p["name"], "version": p["version"],
"license": p["license"], "components": {component: scope},
"source": f"https://static.crates.io/crates/{p['name']}/{p['name']}-{p['version']}.crate",
"checksum": checksums[p["name"], p["version"]],
"path": pathlib.Path(p["manifest_path"]).parent,
}


def js_packages():
data = json.loads(run("pnpm", "--dir", "apps/desktop", "licenses", "list", "--prod", "--json"))
for license_name, rows in data.items():
for row in rows:
for version, path in zip(row["versions"], row["paths"], strict=True):
name = row["name"]
scope = "build/types" if name.startswith("@types/") or name in {"csstype", "@posthog/types"} else "runtime dependency closure"
yield {"ecosystem": "JavaScript", "name": name, "version": version,
"license": license_name, "components": {"desktop": scope},
"source": f"https://registry.npmjs.org/{name}/-/{name.split('/')[-1]}-{version}.tgz",
"path": pathlib.Path(path)}
path = (ROOT / "apps/desktop/node_modules/vite").resolve()
package = json.loads((path / "package.json").read_text())
yield {"ecosystem": "JavaScript", "name": "vite", "version": package["version"],
"license": package["license"], "components": {"desktop": "generated runtime helper"},
"source": f"https://registry.npmjs.org/vite/-/vite-{package['version']}.tgz", "path": path}
# Vite injects its modulepreload helper; the other marketing tools are not shipped.
marketing = json.loads(run("pnpm", "--dir", "apps/marketing", "licenses", "list", "--json"))
for license_name, rows in marketing.items():
for row in rows:
for version, path in zip(row["versions"], row["paths"], strict=True):
name = row["name"]
scope = "generated runtime helper" if name == "vite" else "build tool (not shipped)"
yield {"ecosystem": "JavaScript", "name": name, "version": version,
"license": license_name, "components": {"marketing": scope},
"source": f"https://registry.npmjs.org/{name}/-/{name.split('/')[-1]}-{version}.tgz",
"path": pathlib.Path(path)}


def generate():
overrides = json.loads((OVERRIDES / "index.json").read_text())
packages = {}
for p in [*rust_packages("Cargo.toml", "daemon"),
*rust_packages("apps/desktop/src-tauri/Cargo.toml", "desktop"), *js_packages(),
toolchain_package()]:
key = (p["ecosystem"], p["name"], p["version"])
if key in packages:
packages[key]["components"].update(p["components"])
else:
packages[key] = p
sections = {component: [] for component in OUTPUTS}
records = []
for key, p in sorted(packages.items()):
root = p.pop("path")
files = p.pop("files", None)
included = any(scope not in EXCLUDED for scope in p["components"].values())
texts = []
p["notices"] = []
if included:
for file in files if files is not None else license_files(root):
data = file.read_bytes()
name = str(file.relative_to(root))
text = notice_text(data)
if file.suffix == ".html":
parser = LicenseHTML()
parser.feed(text)
text = re.sub(r"\n{3,}", "\n\n", notice_text("".join(parser.text).encode()))
texts.append(f"--- {name} ---\n" + text)
p["notices"].append({"path": name, "sha256": digest(data)})
for extra in overrides.get(p["name"] + "@" + p["version"], []):
data = (OVERRIDES / extra["file"]).read_bytes()
texts.append(f"--- {extra['url']} ---\n" + notice_text(data))
p["notices"].append({"url": extra["url"], "sha256": digest(data),
"upstream_sha256": extra["upstream_sha256"]})
if not texts:
raise ValueError("Missing authentic notice text: " + " ".join(key))
header = ("\n" + "=" * 72 + f"\n{p['ecosystem']}: {p['name']} {p['version']}\n"
+ f"Declared license: {p['license']}\nSource: {p['source']}\n")
if p.get("checksum"):
header += "Source archive SHA-256: " + p["checksum"] + "\n"
if p["license"] == "MPL-2.0":
header += ("Unmodified source is available at the Source URL above under MPL-2.0.\n"
"You may obtain and modify that source under the MPL-2.0 terms below.\n")
if p["ecosystem"] == "Rust toolchain":
header += ("Official library copyright inventory: " + p["copyright_source"] + "\n"
"Includes upstream platform/build dependencies conservatively.\n"
"Unmodified third-party sources are available from each versioned crate URL below.\n"
"MPL components are available in source form under their included MPL terms.\n")
for component in OUTPUTS:
scope = p["components"].get(component)
scopes = [scope] if scope and scope not in EXCLUDED else []
daemon_scope = p["components"].get("daemon")
if component == "desktop" and daemon_scope and daemon_scope not in EXCLUDED:
scopes.append("bundled daemon: " + daemon_scope)
if scopes:
sections[component].append(header + "Scope: " + "; ".join(scopes) + "\n\n" + "\n".join(texts))
records.append(p)
outputs = {}
for component, name in OUTPUTS.items():
content = (f"GRAVITY {component.upper()} THIRD-PARTY NOTICES\n\n"
"Gravity's MIT license is separate from these upstream terms.\n"
f"Native inventory target: {TARGET}.\n"
"Runtime dependency closures are conservative; tree-shaken code may be absent.\n"
"Build/test notices are included conservatively for generated code; their\n"
"presence does not mean the tools themselves are distributed.\n"
"Source URLs identify the exact unmodified dependency versions.\n"
"See third-party/README.md in the Gravity source for scope and maintenance.\n"
+ "".join(sections[component]))
path = ROOT / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content.rstrip() + "\n")
outputs[name] = digest(path.read_bytes())
INVENTORY.write_text(json.dumps({"target": TARGET, "inputs": input_hashes(),
"outputs": outputs, "packages": records}, indent=2) + "\n")
print(f"Generated {len(records)} inventory records and {len(outputs)} notice bundles.")


def check(check_toolchain=False):
inventory = json.loads(INVENTORY.read_text())
if inventory["inputs"] != input_hashes():
raise ValueError("Notice inputs changed. Review scope and run pnpm notices:generate.")
for name, expected in inventory["outputs"].items():
if digest((ROOT / name).read_bytes()) != expected:
raise ValueError("Notice bundle changed: " + name)
if set(inventory["outputs"]) != set(OUTPUTS.values()):
raise ValueError("Notice output inventory is incomplete")
for p in inventory["packages"]:
if any(scope not in EXCLUDED for scope in p["components"].values()) and not p["notices"]:
raise ValueError("Missing notices: " + p["name"])
if check_toolchain and p["ecosystem"] == "Rust toolchain" and p["version"] != run("rustc", "--version").strip():
raise ValueError("Rust toolchain changed. Review and regenerate standard library notices.")
print("Dependency notice inputs and bundles match the reviewed inventory.")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--generate", action="store_true")
parser.add_argument("--check-toolchain", action="store_true")
args = parser.parse_args()
if args.generate:
generate()
else:
check(args.check_toolchain)
Loading
Loading