diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..d45ef9f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-party notices + +This file lists third-party material present in the Nav Center source repository. The Nav Center app bundle itself contains no third-party code (atsim is a review snapshot only). + +## Nav Center + +Nav Center is licensed under the MIT License. See [LICENSE](LICENSE). + +## atsim (`vendor/atsim`) + +- Upstream repository: https://github.com/austinkennethtucker/cli +- Snapshot commit: `cc37c5b1e3a4f7dfe17d9f043eb18021ff6faef4` +- Copied: 2026-09-15 +- Package version: 0.1.0 + +This snapshot is not built, bundled, or executed by Nav Center. + +The upstream pyproject.toml declares MIT licensing. No standalone LICENSE file was present in the package or upstream repository root at this commit. + +License text: PENDING UPSTREAM CONFIRMATION + +Distribution builds refuse to proceed while this marker remains. + +## @opencode-ai/sdk + +`@opencode-ai/sdk` is present in the snapshot's package manifest and lockfile. It is not installed and not distributed. diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index c7441ca..fbdae28 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -10,12 +10,12 @@ This inventory describes source dependencies, not a complete binary SBOM. Review | Ruby, Pandoc, Chrome, Poppler | Optional/native runtime helpers, discovered on the user's machine | Not bundled by this tooling change; validate versions and licenses before bundling | | Codex app-server | Optional independently installed/signed-in tool | Not bundled; no access to auth storage for tests | | External atsim | Existing optional executable integration | Copied source does not activate it | -| `vendor/atsim` | Unchanged review snapshot at `cc37c5b1e3a4f7dfe17d9f043eb18021ff6faef4`; 11 upstream file hashes | Upstream declares MIT, standalone attribution/license notice missing. Resolve before distribution | +| `vendor/atsim` | Unchanged review snapshot at `cc37c5b1e3a4f7dfe17d9f043eb18021ff6faef4`; 11 upstream file hashes; not built, bundled, or shipped | See [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md). `PENDING UPSTREAM CONFIRMATION` is a distribution gate: `--distribution` refuses to build while that marker remains | | Snapshot JS helper | `@opencode-ai/sdk` in the copied package/lock; not installed or integrated | Review lock and transitive licenses/security explicitly with a snapshot update | | Development binaries | Gitleaks, actionlint, zizmor, SwiftLint from official pinned releases | Versions and archive SHA-256 in `scripts/tool-versions.json`; not shipped in the app | | GitHub Actions | Full SHA references in `.github/workflows` | Weekly Dependabot PRs; human review of changes and permissions | | SonarQube Cloud | Optional source/analysis service, maintained Swift only | OSS onboarding and account permissions must be verified; not a package dependency | -`python3 -B scripts/verify-vendor.py` checks both hashes and the exact snapshot file inventory. A matching manifest is integrity evidence, not upstream security or license approval. Never let dependency bots rewrite the snapshot implicitly. +`python3 -B scripts/verify-vendor.py` checks both hashes and the exact snapshot file inventory, and requires [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md) to name `vendor/atsim` and the snapshot commit from `vendor/atsim/UPSTREAM.md`. While that notice contains `PENDING UPSTREAM CONFIRMATION`, the command reports the open distribution gate and still exits successfully. A matching manifest is integrity evidence, not upstream security or license approval. Never let dependency bots rewrite the snapshot implicitly. The prepared protected release workflow exports GitHub's SPDX dependency graph through [`scripts/export-sbom.py`](../scripts/export-sbom.py) and includes this file with the final candidate. The asynchronous GitHub API reports the repository graph at generation time, not an inventory guaranteed to match the release SHA. Its metadata records retrieval time, package count, scope and digest. GitHub may omit copied sources, system frameworks, and dynamically discovered tools; those limitations must remain visible. Retain the manifest, lockfiles, source revision, toolchain, final DMG checksum, signing/notarization evidence, and later provenance with an authorized release. [GitHub's current SBOM API](https://docs.github.com/en/rest/dependency-graph/sboms). diff --git a/docs/PUBLIC_RELEASE_CHECKLIST.md b/docs/PUBLIC_RELEASE_CHECKLIST.md index c205312..97dc207 100644 --- a/docs/PUBLIC_RELEASE_CHECKLIST.md +++ b/docs/PUBLIC_RELEASE_CHECKLIST.md @@ -43,6 +43,7 @@ Excluded: 5. Confirm the bundle identifier and signing/notarization plan. 6. Add release screenshots only after checking them for private data. 7. Create the GitHub repository only after the extracted tree is clean. +8. Confirm the atsim notice in `THIRD_PARTY_NOTICES.md`: replace `PENDING UPSTREAM CONFIRMATION` with the upstream-confirmed license text before any distribution build. ## Release Artifact Contract diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 45d3010..73799e2 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -13,6 +13,8 @@ NAV_CENTER_VERSION=0.1.0-beta.1 NAV_CENTER_BUILD=1 \ The name ends in `-unsigned.dmg`. This mode never invokes signing or notarization. Both packaging modes use `swift build -c release`; ordinary `scripts/build-and-run.sh build` defaults to debug. Set `NAV_CENTER_DIST_DIR` to an absolute temporary directory for isolated checks. Neither mode stops running app instances. Existing DMG, checksum, or notary-result outputs are refused rather than overwritten. +`scripts/build-and-run.sh` stages `LICENSE` and `THIRD_PARTY_NOTICES.md` into the app bundle at `Contents/Resources`. `scripts/package-beta-dmg.sh` copies both files to the DMG root beside the app and the Applications symlink. `--distribution` refuses to build while `THIRD_PARTY_NOTICES.md` contains `PENDING UPSTREAM CONFIRMATION`. `--local` still builds an unsigned image when that marker is present and prints the unsigned warning. + ## Version and architecture Set `NAV_CENTER_VERSION` to a numeric `major.minor.patch` with an optional prerelease suffix, and `NAV_CENTER_BUILD` to a positive integer. The app embeds the numeric version in `CFBundleShortVersionString`, the build number in `CFBundleVersion`, and the complete prerelease version in `NavCenterVersion`. Use a new build number for a new build. diff --git a/scripts/build-and-run.sh b/scripts/build-and-run.sh index 5d7611d..6a51bec 100755 --- a/scripts/build-and-run.sh +++ b/scripts/build-and-run.sh @@ -71,6 +71,15 @@ stage_icon() { } stage_app() { + if [[ ! -f "$ROOT_DIR/LICENSE" ]]; then + echo "missing LICENSE: $ROOT_DIR/LICENSE" >&2 + exit 1 + fi + if [[ ! -f "$ROOT_DIR/THIRD_PARTY_NOTICES.md" ]]; then + echo "missing THIRD_PARTY_NOTICES.md: $ROOT_DIR/THIRD_PARTY_NOTICES.md" >&2 + exit 1 + fi + swift build --package-path "$ROOT_DIR" -c "$CONFIGURATION" --product "$EXECUTABLE_NAME" swift build --package-path "$ROOT_DIR" -c "$CONFIGURATION" --product "$CTL_NAME" local build_binary @@ -84,6 +93,8 @@ stage_app() { cp "$ctl_build_binary" "$CTL_BINARY" chmod +x "$APP_BINARY" chmod +x "$CTL_BINARY" + cp "$ROOT_DIR/LICENSE" "$APP_RESOURCES/LICENSE" + cp "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$APP_RESOURCES/THIRD_PARTY_NOTICES.md" stage_icon local env_plist="" diff --git a/scripts/package-beta-dmg.sh b/scripts/package-beta-dmg.sh index 95df54c..b72d456 100755 --- a/scripts/package-beta-dmg.sh +++ b/scripts/package-beta-dmg.sh @@ -36,6 +36,11 @@ for output in "$DMG_PATH" "$DMG_PATH.sha256" "$DMG_PATH.notary.json"; do [[ ! -e "$output" && ! -L "$output" ]] || { echo "Refusing to overwrite release output: $output" >&2; exit 1; } done +if [[ "$MODE" == --distribution ]] && grep -q "PENDING UPSTREAM CONFIRMATION" "$ROOT_DIR/THIRD_PARTY_NOTICES.md"; then + echo "Refusing distribution build: THIRD_PARTY_NOTICES.md contains PENDING UPSTREAM CONFIRMATION." >&2 + exit 1 +fi + NAV_CENTER_VERSION="$VERSION" NAV_CENTER_BUILD="$BUILD_NUMBER" NAV_CENTER_BUILD_CONFIGURATION=release \ NAV_CENTER_INCLUDE_WORKSPACE_ENV=0 "$ROOT_DIR/scripts/build-and-run.sh" build @@ -43,6 +48,8 @@ STAGING_DIR="$(mktemp -d "$DIST_DIR/.dmg-stage.XXXXXX")" trap 'rm -rf "$STAGING_DIR"' EXIT cp -R "$APP_BUNDLE" "$STAGING_DIR/" ln -s /Applications "$STAGING_DIR/Applications" +cp "$ROOT_DIR/LICENSE" "$STAGING_DIR/LICENSE" +cp "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$STAGING_DIR/THIRD_PARTY_NOTICES.md" STAGED_APP="$STAGING_DIR/Nav Center.app" for binary in NavCenterApp navcenterctl; do [[ "$(lipo -archs "$STAGED_APP/Contents/MacOS/$binary")" == "$ARCH" ]] || { echo "Unexpected binary architecture: $binary" >&2; exit 1; } diff --git a/scripts/tests/test_release_scripts.py b/scripts/tests/test_release_scripts.py index befc5ef..57d64f8 100644 --- a/scripts/tests/test_release_scripts.py +++ b/scripts/tests/test_release_scripts.py @@ -28,8 +28,12 @@ if name == "codesign": event += ":verify" if "--verify" in args else ":sign" if name == "gitleaks": event += ":" + args[0] if name == "git": event += ":status" if "status" in args else ":rev-parse" +payload = {"tool": name, "args": args, "event": event} +if name == "hdiutil" and args[:1] == ["create"] and "-srcfolder" in args: + source = pathlib.Path(args[args.index("-srcfolder") + 1]) + payload["srcfolder"] = sorted(child.name for child in source.iterdir()) with open(os.environ["RELEASE_TRACE"], "a") as stream: - stream.write(json.dumps({"tool": name, "args": args, "event": event}) + "\n") + stream.write(json.dumps(payload) + "\n") if os.environ.get("FAIL_EVENT") == event: sys.exit(9) if name == "git" and "--is-shallow-repository" in args: print(os.environ.get("RELEASE_SHALLOW", "false")) if name == "git" and "status" in args and os.environ.get("RELEASE_DIRTY"): print(" M synthetic.swift") @@ -157,6 +161,8 @@ def setUp(self): (self.root / "scripts").mkdir() (self.root / "Resources").mkdir() (self.root / "Resources/AppIcon.png").write_bytes(b"synthetic icon source") + (self.root / "LICENSE").write_text("Synthetic Nav Center license.\n") + (self.root / "THIRD_PARTY_NOTICES.md").write_text("Synthetic third-party notices.\n") for script in (REPO / "scripts").glob("*.sh"): shutil.copy2(script, self.root / "scripts" / script.name) self.bin = self.root / "built" @@ -409,6 +415,124 @@ def test_workflow_upload_follows_required_gates_and_uses_least_privilege(self): self.assertIn("${{ runner.temp }}/nav-center-current-secrets.json", reports) self.assertIn("${{ runner.temp }}/nav-center-history-secrets.json", reports) + def test_build_stages_license_and_notices_into_resources(self): + self.assert_ok(self.run_script("build-and-run.sh", "build")) + resources = self.dist / "Nav Center.app/Contents/Resources" + self.assertEqual((resources / "LICENSE").read_text(), (self.root / "LICENSE").read_text()) + self.assertEqual((resources / "THIRD_PARTY_NOTICES.md").read_text(), (self.root / "THIRD_PARTY_NOTICES.md").read_text()) + + def test_build_fails_before_swift_when_license_or_notices_missing(self): + for name in ("LICENSE", "THIRD_PARTY_NOTICES.md"): + with self.subTest(name=name): + target = self.root / name + backup = target.read_text() + target.unlink() + result = self.run_script("build-and-run.sh", "build") + self.assertNotEqual(result.returncode, 0) + self.assertIn(name, result.stderr) + self.assertEqual(self.events(), []) + target.write_text(backup) + self.trace.unlink(missing_ok=True) + + def test_local_package_places_license_and_notices_beside_app_in_image(self): + notices = self.root / "THIRD_PARTY_NOTICES.md" + notices.write_text(notices.read_text() + "License text: PENDING UPSTREAM CONFIRMATION\n") + self.assert_ok(self.run_script("package-beta-dmg.sh", "--local")) + created = [event for event in self.events() if event["tool"] == "hdiutil" and event["args"][0] == "create"] + self.assertEqual(len(created), 1) + for name in ("Nav Center.app", "Applications", "LICENSE", "THIRD_PARTY_NOTICES.md"): + self.assertIn(name, created[0]["srcfolder"]) + + def test_distribution_refuses_pending_notice_placeholder(self): + (self.root / "THIRD_PARTY_NOTICES.md").write_text("License text: PENDING UPSTREAM CONFIRMATION\n") + result = self.run_script("package-beta-dmg.sh", "--distribution", extra=self.credentials()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("PENDING UPSTREAM CONFIRMATION", result.stderr) + self.assertFalse(any(event["tool"] in ("swift", "codesign", "xcrun", "hdiutil") for event in self.events())) + + +class VendorNoticeTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="nav-center-vendor-notice-tests-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def write_snapshot(self, commit): + vendor = self.root / "vendor/atsim" + vendor.mkdir(parents=True) + payload = b"synthetic snapshot\n" + (vendor / "snapshot.txt").write_bytes(payload) + manifest = {"snapshot.txt": hashlib.sha256(payload).hexdigest()} + (vendor / "UPSTREAM-SHA256.json").write_text(json.dumps(manifest) + "\n") + (vendor / "UPSTREAM.md").write_text( + "- Repository: https://github.com/austinkennethtucker/cli\n" + "- Source directory: `atsim/`\n" + f"- Commit: `{commit}`\n" + "- Copied: 2026-09-15\n" + "- Package version: 0.1.0\n" + ) + scripts = self.root / "scripts" + scripts.mkdir(exist_ok=True) + shutil.copy2(REPO / "scripts/verify-vendor.py", scripts / "verify-vendor.py") + + def run_verify(self, root=None): + script_root = self.root if root is None else root + return subprocess.run( + [sys.executable, "-B", str(script_root / "scripts/verify-vendor.py")], + cwd=script_root, capture_output=True, text=True, + ) + + def test_notices_reference_snapshot_commit_and_path(self): + real = subprocess.run( + [sys.executable, "-B", str(REPO / "scripts/verify-vendor.py")], + cwd=REPO, capture_output=True, text=True, + ) + self.assertEqual(real.returncode, 0, real.stderr + real.stdout) + upstream = (REPO / "vendor/atsim/UPSTREAM.md").read_text() + commit = re.search(r"(?m)^- Commit: `([0-9a-f]{40})`", upstream).group(1) + notices = (REPO / "THIRD_PARTY_NOTICES.md").read_text() + self.assertIn("vendor/atsim", notices) + self.assertIn(commit, notices) + self.assertNotIn("Copyright", notices.split("## @opencode-ai/sdk", 1)[0].split("## atsim", 1)[1]) + + synthetic_commit = "0123456789abcdef0123456789abcdef01234567" + self.write_snapshot(synthetic_commit) + notice_path = self.root / "THIRD_PARTY_NOTICES.md" + notice_path.write_text(f"vendor/atsim\n{synthetic_commit}\n") + accepted = self.run_verify() + self.assertEqual(accepted.returncode, 0, accepted.stderr + accepted.stdout) + + notice_path.write_text(f"{synthetic_commit}\n") + missing_path = self.run_verify() + self.assertNotEqual(missing_path.returncode, 0) + self.assertIn("vendor/atsim", missing_path.stderr) + + notice_path.write_text("vendor/atsim\n") + missing_commit = self.run_verify() + self.assertNotEqual(missing_commit.returncode, 0) + self.assertIn("upstream commit", missing_commit.stderr) + + notice_path.unlink() + missing_file = self.run_verify() + self.assertNotEqual(missing_file.returncode, 0) + self.assertIn("THIRD_PARTY_NOTICES.md", missing_file.stderr) + + def test_verify_vendor_reports_pending_confirmation_without_failing(self): + commit = "0123456789abcdef0123456789abcdef01234567" + self.write_snapshot(commit) + notices = self.root / "THIRD_PARTY_NOTICES.md" + pending_line = "atsim notice is pending upstream confirmation and remains a distribution gate." + notices.write_text(f"vendor/atsim\n{commit}\nLicense text: PENDING UPSTREAM CONFIRMATION\n") + pending = self.run_verify() + self.assertEqual(pending.returncode, 0, pending.stderr + pending.stdout) + self.assertIn(pending_line, pending.stdout) + + notices.write_text(f"vendor/atsim\n{commit}\n") + confirmed = self.run_verify() + self.assertEqual(confirmed.returncode, 0, confirmed.stderr + confirmed.stdout) + self.assertNotIn(pending_line, confirmed.stdout) + self.assertNotIn("pending upstream confirmation", confirmed.stdout) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-vendor.py b/scripts/verify-vendor.py index 393e49e..adf6adb 100644 --- a/scripts/verify-vendor.py +++ b/scripts/verify-vendor.py @@ -2,9 +2,11 @@ """Check the reviewed ATS snapshot without installing or executing it.""" import hashlib import json +import re from pathlib import Path -root = Path(__file__).resolve().parents[1] / "vendor/atsim" +repo = Path(__file__).resolve().parents[1] +root = repo / "vendor/atsim" manifest = json.loads((root / "UPSTREAM-SHA256.json").read_text()) for relative, expected in manifest.items(): path = root / relative @@ -16,4 +18,19 @@ allowed = set(manifest) | {"UPSTREAM.md", "UPSTREAM-SHA256.json"} if actual != allowed: raise SystemExit(f"Snapshot file inventory changed: {sorted(actual ^ allowed)}") +notices_path = repo / "THIRD_PARTY_NOTICES.md" +if not notices_path.is_file(): + raise SystemExit("Missing THIRD_PARTY_NOTICES.md") +notices = notices_path.read_text() +upstream = (root / "UPSTREAM.md").read_text() +commit_match = re.search(r"(?m)^- Commit: `([0-9a-f]{40})`", upstream) +if commit_match is None: + raise SystemExit("UPSTREAM.md does not record an upstream commit SHA") +commit = commit_match.group(1) +if "vendor/atsim" not in notices: + raise SystemExit("THIRD_PARTY_NOTICES.md does not mention vendor/atsim") +if commit not in notices: + raise SystemExit("THIRD_PARTY_NOTICES.md does not contain the upstream commit recorded in UPSTREAM.md") print(f"Verified all {len(manifest)} upstream files; snapshot attribution remains a distribution gate.") +if "PENDING UPSTREAM CONFIRMATION" in notices: + print("atsim notice is pending upstream confirmation and remains a distribution gate.")