diff --git a/AGENTS.md b/AGENTS.md index 55181417..d7ce1f8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,34 @@ acceptance. In particular, paid-file recovery must not send another payment, and app cleanup must preserve wallets, persistent data and uninstall decisions. Do not mark the new paid-file incident resolved merely because the earlier Framework LND startup incident was closed. + +## Gitea and ngit mirror parity + +Nostr Git (`ngit`) is the canonical contribution and review platform. Gitea +(`origin`) mirrors accepted code on `main` and release tags. Both are required +publication mirrors; duplicate PRs and proposal branches on Gitea are not required. +For every change, including fixes and release preparation: + +- Review and merge once. Push the exact same resulting commits to both mirrors; + never independently squash, rebase or merge the same change on each platform. +- Open new contributions and PRs on ngit; review and merge there, then mirror the + exact accepted main commits to Gitea. Record the ngit proposal and resulting + merge commit in the release ledger. Existing Gitea PRs must be reviewed and + explicitly linked to their ngit replacement or accepted result before closing; + do not abandon contributions or mark unmerged changes as merged. PR numbers, + reviews and discussions remain platform-specific; matching Git refs does not + prove their synchronization. +- Push main and release tags to both mirrors. Preserve commit history + and annotated tag objects/signatures. Do not resolve drift by force pushing, + deleting remote refs, or rewriting published history without explicit approval. +- After publishing source, run `python3 scripts/check-git-mirrors.py --local`. + Include each additional shared branch or release tag with repeated `--ref` + arguments (full `refs/heads/...` or `refs/tags/...` names). +- Before OTA, catalog or ISO publication, require matching reviewed local and + remote main and release tag refs, and record ngit PR dispositions in the + release acceptance ledger. A failed push, unavailable mirror, missing ref or + mismatch blocks publication; never describe a partial push as synchronized. + Run `--all` for a complete advertised branch/tag audit; a main-only pass must + never be described as full historical mirror parity. Proposal-only branches + may intentionally differ. Existing unrelated drift + must be inventoried explicitly rather than silently overwritten. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab6cdb0b..dbdf38f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,12 +64,49 @@ App submissions must: ## Pull requests -1. Open one focused PR per behavior or documentation change. +Contributions, PRs and reviews live on **ngit**. Clone the canonical repository: + +```text +nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy +``` + +Gitea is a conventional Git mirror of accepted `main` commits and release tags. +You do not need to open a duplicate Gitea PR. Existing Gitea contributions will +be reviewed and linked to their ngit replacement or accepted result before +closure. + +1. Open one focused ngit PR per behavior or documentation change. 2. Explain what changed, why it changed, and how it was verified. 3. Include screenshots for UI changes. 4. Link relevant issues or docs. 5. Keep generated catalog changes in sync with manifest changes. +### Maintainer publication gate + +Merge once through the ngit contribution workflow, then push the exact same +accepted commits to Gitea. Do not independently merge or squash on each mirror. +Publish identical release tag objects, including annotations and signatures. +After pushing main, verify: + +```bash +python3 scripts/check-git-mirrors.py --local +``` + +Before publishing release artifacts, also check the actual release tag: + +```bash +python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0 +``` + +Use the release's actual tag name. Missing refs, inaccessible mirrors or differing +object IDs block publication. Record the ngit PR disposition and resulting merge +commit in the release acceptance ledger. Resolve drift deliberately; do not +force-push or delete published history without explicit approval. + +The checker is read-only. `--all` audits every advertised branch and tag; ngit +proposal branches may intentionally differ from Gitea. A main-only pass proves +only main parity, and no Git ref check verifies PR discussions or review state. + Suggested commit format: ```text diff --git a/scripts/check-git-mirrors.py b/scripts/check-git-mirrors.py new file mode 100644 index 00000000..3f123160 --- /dev/null +++ b/scripts/check-git-mirrors.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Read-only check of advertised Git refs; does not inspect PR metadata.""" +import argparse +import re +import subprocess +import sys + + +def git(*args): + result = subprocess.run(['git', *args], capture_output=True, text=True, + timeout=120) + if result.returncode: + # Transport errors can contain credential-bearing remote URLs. + raise ValueError('Git lookup failed; check mirror access privately') + return result.stdout + + +def parse_refs(output): + return {ref: sha for sha, ref in (line.split() for line in output.splitlines()) + if ref.startswith(('refs/heads/', 'refs/tags/'))} + + +def compare(left, right, refs): + failures = [] + for ref in sorted(refs): + if ref not in left or ref not in right: + failures.append(f'{ref}: missing from at least one side') + elif left[ref] != right[ref]: + failures.append(f'{ref}: different object IDs') + return failures + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--remotes', nargs=2, default=['origin', 'ngit']) + parser.add_argument('--ref', action='append', default=[], + help='additional full branch/tag ref; main is always checked') + parser.add_argument('--all', action='store_true', help='audit all advertised branches/tags') + parser.add_argument('--local', action='store_true', help='also require local refs to match') + args = parser.parse_args() + try: + configured = set(git('remote').splitlines()) + if any(remote not in configured for remote in args.remotes): + raise ValueError('Both arguments must name configured remotes') + refs = {'refs/heads/main', *args.ref} + for ref in refs: + if not re.match(r'^refs/(heads|tags)/', ref): + raise ValueError('Use full refs/heads/... or refs/tags/... names') + git('check-ref-format', ref) + left, right = [parse_refs(git('ls-remote', remote)) for remote in args.remotes] + if args.all: + refs.update(left) + refs.update(right) + # Compare both annotated tag objects and their peeled target commits. + refs.update(ref + '^{}' for ref in list(refs) + if ref + '^{}' in left or ref + '^{}' in right) + failures = compare(left, right, refs) + if args.local: + local = parse_refs(git('show-ref', '--dereference')) + failures += ['local: ' + error for error in compare(left, local, refs)] + if failures: + print('\n'.join(failures), file=sys.stderr) + return 1 + print(f'PASS: {len(refs)} refs match on both mirrors' + + (' and locally' if args.local else '') + + '; PR metadata not checked.') + return 0 + except (ValueError, subprocess.TimeoutExpired, OSError): + print('FAIL: unable to validate refs; check arguments and mirror access privately.', + file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/tests/test_git_mirrors.py b/scripts/tests/test_git_mirrors.py new file mode 100644 index 00000000..a37d728c --- /dev/null +++ b/scripts/tests/test_git_mirrors.py @@ -0,0 +1,73 @@ +"""Exercise mirror gate against disposable local Git remotes.""" +import pathlib +import subprocess +import sys +import tempfile +import unittest + +CHECK = pathlib.Path(__file__).resolve().parents[1] / 'check-git-mirrors.py' + + +class MirrorTests(unittest.TestCase): + def test_publication_drift_and_annotated_tags(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + work = root / 'work' + work.mkdir() + + def git(*args): + return subprocess.run(['git', *args], cwd=work, check=True, + capture_output=True, text=True).stdout + + def check(*args, passes=True): + result = subprocess.run([sys.executable, str(CHECK), *args], + cwd=work, capture_output=True, text=True) + self.assertEqual(result.returncode, 0 if passes else 1, + result.stdout + result.stderr) + + git('init', '-b', 'main') + git('config', 'user.name', 'Mirror test') + git('config', 'user.email', 'fixture@example.invalid') + git('config', 'commit.gpgsign', 'false') + git('config', 'tag.gpgsign', 'false') + for remote in ('origin', 'ngit'): + git('init', '--bare', str(root / remote)) + git('remote', 'add', remote, str(root / remote)) + git('commit', '--allow-empty', '-m', 'first') + check(passes=False) # Empty mirrors cannot pass. + git('push', 'origin', 'main') + check(passes=False) # Partial publication. + git('push', 'ngit', 'main') + check('--local') + git('commit', '--allow-empty', '-m', 'second') + check() # Remote equality alone does not imply local publication. + check('--local', passes=False) + git('push', 'origin', 'main') + check(passes=False) + git('push', 'ngit', 'main') + git('tag', '-a', 'v-test', '-m', 'original') + git('push', 'origin', 'refs/tags/v-test') + check('--ref', 'refs/tags/v-test', passes=False) + git('push', 'ngit', 'refs/tags/v-test') + check('--local', '--ref', 'refs/tags/v-test') + # Same commit, different annotation must fail too. + git('tag', '-f', '-a', 'v-test', '-m', 'different annotation') + check('--local', '--ref', 'refs/tags/v-test', passes=False) + git('push', '--force', 'ngit', 'refs/tags/v-test') + check('--all', passes=False) + git('tag', '-d', 'v-test') + git('fetch', 'origin', 'refs/tags/v-test:refs/tags/v-test') + git('push', '--force', 'ngit', 'refs/tags/v-test') + git('push', 'origin', 'HEAD:refs/heads/extra') + check('--local') + check('--all', passes=False) + git('push', 'ngit', 'HEAD:refs/heads/extra') + check('--all') + check('--ref', 'main', passes=False) + check('--remotes', 'origin', 'missing', passes=False) + git('remote', 'set-url', 'ngit', str(root / 'does-not-exist')) + check(passes=False) + + +if __name__ == '__main__': + unittest.main()