diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..436060a --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,50 @@ +name: Check and mirror to GitHub + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + checks: + runs-on: ${{ vars.CI_RUNNER_LABEL || 'ubuntu-latest' }} + container: node:22-bookworm + timeout-minutes: 20 + steps: + - name: Prepare CI tools + run: | + apt-get update + apt-get install -y --no-install-recommends python3 git ca-certificates + - name: Checkout + uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Set up Go + uses: https://github.com/actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.26.7' + cache: false + - name: Check code and bundled assets + run: python3 tools/ci/check.py + + github-mirror: + needs: checks + if: github.event_name != 'pull_request' && github.repository == 'fiwdev/Fiw-Gentoo-Dots' + runs-on: ${{ vars.CI_RUNNER_LABEL || 'ubuntu-latest' }} + container: node:22-bookworm + timeout-minutes: 10 + steps: + - name: Prepare mirror tools + run: | + apt-get update + apt-get install -y --no-install-recommends python3 git openssh-client ca-certificates + - name: Checkout + uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Publish branches and tags to GitHub + env: + GH_MIRROR_SSH_KEY: ${{ secrets.GH_MIRROR_SSH_KEY }} + MIRROR_SOURCE_REF: ${{ github.ref }} + MIRROR_SOURCE_SHA: ${{ github.sha }} + run: python3 tools/ci/mirror.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d979b68 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: Checks + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + container: node:22-bookworm + timeout-minutes: 20 + steps: + - name: Prepare CI tools + run: | + apt-get update + apt-get install -y --no-install-recommends python3 git ca-certificates + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.26.7' + cache: false + - name: Check code and bundled assets + run: python3 tools/ci/check.py diff --git a/README.md b/README.md index 2680823..57a84f5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ Go 1.24+ is the source fallback for changed frontend code or other platforms. Saved selections are reused on subsequent launches. See the [snapshot and build instructions](docs/releases.md). +Forgejo is the primary repository; [GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots) +receives branches and tags through the [CI mirror workflow](docs/ci.md) after +the checks pass. + For a preview without building the TUI: ```sh diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..221f0b3 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,63 @@ +# CI and GitHub synchronization + +Push changes to the primary +[Forgejo repository](https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots). +Its `.forgejo/workflows/ci.yml` runs these checks: + +- Publishable files pass the privacy scanner. +- The prebuilt TUI matches `VERSION`, its frontend source and binary checksums. +- Pinned editor assets match their recorded fingerprints. +- Python and Go checks pass using temporary homes and fixtures. + +Successful push or manual runs then synchronize Git branches and tags to +[Fi3w0/Fiw-Gentoo-Dots on GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots). +Pull request runs perform checks without mirroring. GitHub's separate workflow +runs the same checks on received pushes and pull requests. + +The runner uses a Debian container with Python 3.11 and pinned Go 1.26.7. +Remote checkout/setup actions use fixed commit IDs. CI checks the installer +code without installing Gentoo packages, applying live desktop configs or +deploying bootloaders. It verifies the existing release bundle; changing +frontend source requires rebuilding and committing its matching bundle. + +## Forgejo setup + +Repository Actions must be enabled and an available runner must match +`ubuntu-latest`. If your runner has another label, set the Forgejo repository +Actions variable `CI_RUNNER_LABEL` to that label. The runner needs Docker to +execute the workflow's container jobs. See the +[Forgejo 10 Actions guide](https://forgejo.org/docs/v10.0/user/actions/) for +runner and secret settings used by this instance. + +Add the repository Actions secret **`GH_MIRROR_SSH_KEY`** with the complete +private SSH deploy key registered on the GitHub mirror. The initial key is +stored locally at `local/ci/github-mirror`, outside version control. The +corresponding GitHub deploy key is named **Forgejo mirror**, with write access. +GitHub documents [deploy key management](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys). +The workflow exposes the key only to the mirroring step and uses a temporary +mode-600 key file, removed when the process exits. GitHub's Ed25519 host key +is pinned in `tools/ci/github_known_hosts` from its authenticated metadata API. + +The **Run workflow** action retries synchronization after adding a secret or +runner. Pushing another commit also triggers it. + +## Sync behaviour + +The mirror fetches the current source repository and checks that the triggering +ref still points to the commit reviewed by that CI run. Superseded runs skip +publication. Branches and annotated tags keep their original history and IDs. +Pushes are atomic and use ordinary fast-forward rules. Divergent GitHub edits +cause an error for review. GitHub-only refs and refs deleted at the source are +retained. Keep normal development on Forgejo to maintain a consistent mirror. + +For an explicit manual sync with the initial local deploy key: + +```sh +python3 tools/ci/mirror.py --key-file local/ci/github-mirror +``` + +The shared check command is: + +```sh +python3 tools/ci/check.py +``` diff --git a/docs/scripts.md b/docs/scripts.md index 28c3294..93f5969 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -17,6 +17,8 @@ | `tools/check-private.py` | Scans publishable files for personal paths, identifiers and credentials; keeps fingerprinted upstream attribution | Run before committing | | `tools/audit-packages.py` | Compares explicit world/selected sets with main/optional package lists and documented helper exceptions | Read-only package audit | | `tools/build-release.py` | Builds the generic static amd64 TUI bundle, fingerprints and dependency licence notices | Maintainer command before tagging snapshots | +| `tools/ci/check.py` | Checks privacy, bundle/theme fingerprints and portable Python/Go behaviour | Forgejo and GitHub CI; can also run locally | +| `tools/ci/mirror.py` | Copies Forgejo branches/tags to GitHub with a repository deploy key | Forgejo CI after checks; explicit manual sync supported | | `optional/fiw-cachy-patch` | Downloads matching vanilla/CachyOS kernel sources, generates a patch and adjusts the installed guard/name config | Explicit manual root operation; not part of ordinary restoration | | `optional/fiw-limine-splice` | Replaces the old Gentoo snippet block in an existing Limine menu and orders its entries | Explicit manual use with an ESP argument; specific to the earlier snippet design | | `optional/spotify/spotify-patch` | Runs the retained SpotX/Spicetify customization workflow for the Gentoo Spotify client | Explicit optional user operation; stock Spotify is the default | diff --git a/tests/test_ci_mirror.py b/tests/test_ci_mirror.py new file mode 100644 index 0000000..e399d9b --- /dev/null +++ b/tests/test_ci_mirror.py @@ -0,0 +1,52 @@ +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('ci_mirror', REPO / 'tools/ci/mirror.py') +ci_mirror = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ci_mirror) + + +def git(*args): + return subprocess.check_output(['git', *map(str, args)], text=True, stderr=subprocess.DEVNULL).strip() + + +def identity(path): + for key, value in [('user.name', 'CI fixture'), ('user.email', 'ci-fixture'), + ('commit.gpgsign', 'false'), ('tag.gpgsign', 'false')]: + git('-C', path, 'config', key, value) + + +class MirrorTests(unittest.TestCase): + def test_mirror_preserves_tags_and_refs_and_refuses_divergence(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp); work, source, target = root / 'work', root / 'source.git', root / 'target.git' + git('init', '-b', 'main', work); identity(work) + (work / 'README').write_text('first\n') + git('-C', work, 'add', 'README'); git('-C', work, 'commit', '-m', 'first') + git('-C', work, 'branch', 'other'); git('-C', work, 'tag', '-a', 'v1', '-m', 'version1') + git('clone', '--bare', work, source); git('init', '--bare', target) + head = git('-C', work, 'rev-parse', 'HEAD') + ci_mirror.mirror(str(source), str(target), source_ref='refs/tags/v1', source_sha=head) + self.assertEqual(git('-C', source, 'show-ref'), git('-C', target, 'show-ref')) + git('-C', target, 'branch', 'github-only', head) + (work / 'README').write_text('source update\n') + git('-C', work, 'commit', '-am', 'source update'); git('-C', work, 'push', source, 'main') + diverged = root / 'diverged' + git('clone', '--branch', 'main', target, diverged); identity(diverged) + (diverged / 'other').write_text('GitHub edit\n') + git('-C', diverged, 'add', 'other'); git('-C', diverged, 'commit', '-m', 'GitHub edit') + git('-C', diverged, 'push', 'origin', 'main') + before = git('-C', target, 'show-ref') + ci_mirror.mirror(str(source), str(target), source_ref='refs/heads/main', source_sha=head) + self.assertEqual(before, git('-C', target, 'show-ref')) + with self.assertRaises(subprocess.CalledProcessError): + ci_mirror.mirror(str(source), str(target)) + self.assertEqual(before, git('-C', target, 'show-ref')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/ci/check.py b/tools/ci/check.py new file mode 100644 index 0000000..082b275 --- /dev/null +++ b/tools/ci/check.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Check portable code and the shipped assets without applying the installer.""" +import gzip +import hashlib +import importlib.util +import io +import json +import subprocess +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def check_assets(): + spec = importlib.util.spec_from_file_location('launcher', REPO / 'lib/launcher.py') + launcher = importlib.util.module_from_spec(spec) + spec.loader.exec_module(launcher) + root = REPO / 'assets/bin' + metadata = json.loads((root / 'tui.json').read_text()) + if metadata['version'] != (REPO / 'VERSION').read_text().strip(): + raise RuntimeError('TUI version does not match VERSION; rebuild the release bundle.') + if metadata['source_sha256'] != launcher.source_hash(REPO): + raise RuntimeError('TUI source changed; rebuild the release bundle.') + for binary in metadata['binaries'].values(): + path = root / binary['file'] + if path.parent.resolve() != root.resolve(): + raise RuntimeError('Invalid TUI archive path.') + compressed = path.read_bytes() + if sha(compressed) != binary['gzip_sha256']: + raise RuntimeError('TUI archive checksum mismatch.') + size = binary['size'] + if not isinstance(size, int) or not 0 < size <= 64 * 1024 * 1024: + raise RuntimeError('Invalid TUI size.') + with gzip.GzipFile(fileobj=io.BytesIO(compressed)) as stream: + executable = stream.read(size + 1) + if len(executable) != size or sha(executable) != binary['sha256']: + raise RuntimeError('TUI executable checksum mismatch.') + line = binary['gzip_sha256'] + ' ' + binary['file'] + if line not in (root / 'SHA256SUMS').read_text().splitlines(): + raise RuntimeError('SHA256SUMS does not match the TUI archive.') + for name, entry in json.loads((REPO / 'assets/editor-themes.json').read_text()).items(): + directory = REPO / entry['root'] + if not directory.resolve().is_relative_to(REPO): + raise RuntimeError('Invalid editor asset directory.') + actual = {path.relative_to(directory).as_posix(): sha(path.read_bytes()) + for path in sorted(directory.rglob('*')) if path.is_file()} + if actual != entry['sha256']: + raise RuntimeError('Editor asset fingerprints changed: ' + name) + print('Prebuilt TUI and editor asset fingerprints match.', flush=True) + + +def main(): + subprocess.run(['python3', 'tools/check-private.py'], cwd=REPO, check=True) + check_assets() + subprocess.run(['python3', '-m', 'unittest', 'discover', '-s', 'tests'], cwd=REPO, check=True) + subprocess.run(['go', 'test', './...'], cwd=REPO, check=True) + print('Portable CI checks passed; no installer actions were applied.', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tools/ci/github_known_hosts b/tools/ci/github_known_hosts new file mode 100644 index 0000000..90150c2 --- /dev/null +++ b/tools/ci/github_known_hosts @@ -0,0 +1 @@ +github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl diff --git a/tools/ci/mirror.py b/tools/ci/mirror.py new file mode 100644 index 0000000..a5ce6a4 --- /dev/null +++ b/tools/ci/mirror.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Publish Forgejo branches/tags to GitHub without deleting or forcing refs.""" +import argparse +import os +import shlex +import subprocess +import tempfile +from pathlib import Path +from urllib.parse import urlsplit + +REPO = Path(__file__).resolve().parents[2] +SOURCE = 'https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git' +GITHUB_HOST = 'github.com' +GITHUB_REPOSITORY = 'Fi3w0/Fiw-Gentoo-Dots' +TARGET = f'ssh://git@{GITHUB_HOST}/{GITHUB_REPOSITORY}.git' + + +def mirror(source=SOURCE, target=TARGET, key=None, source_ref=None, source_sha=None): + environment = dict(os.environ, GIT_TERMINAL_PROMPT='0') + environment.pop('GH_MIRROR_SSH_KEY', None) + with tempfile.TemporaryDirectory(prefix='fiw-github-mirror-') as temporary: + root = Path(temporary) + remote = urlsplit(target) + if remote.scheme == 'ssh' and remote.hostname == GITHUB_HOST: + if not key or not key.strip(): + raise RuntimeError('Add the GH_MIRROR_SSH_KEY repository secret in Forgejo before mirroring.') + identity = root / 'identity' + identity.write_text(key.strip() + '\n') + identity.chmod(0o600) + environment['GIT_SSH_COMMAND'] = shlex.join([ + 'ssh', '-F', '/dev/null', '-i', str(identity), + '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(REPO / 'tools/ci/github_known_hosts')]) + snapshot = root / 'source.git' + subprocess.run(['git', 'clone', '--bare', '--no-local', source, str(snapshot)], env=environment, check=True) + if source_ref: + if not source_ref.startswith(('refs/heads/', 'refs/tags/')): + raise RuntimeError('Mirror source must be a branch or tag ref.') + subprocess.run(['git', 'check-ref-format', source_ref], env=environment, check=True) + actual = subprocess.run(['git', '-C', str(snapshot), 'rev-parse', '--verify', source_ref + '^{commit}'], env=environment, text=True, capture_output=True) + reviewed = subprocess.run(['git', '-C', str(snapshot), 'rev-parse', '--verify', (source_sha or '') + '^{commit}'], env=environment, text=True, capture_output=True) + if actual.returncode or reviewed.returncode or actual.stdout != reviewed.stdout: + print('Source ref changed or was removed since this CI run; a newer run can publish it.') + return + subprocess.run(['git', '-C', str(snapshot), 'push', '--atomic', target, + 'refs/heads/*:refs/heads/*', 'refs/tags/*:refs/tags/*'], env=environment, check=True) + print('Forgejo branches and tags published to GitHub.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', default=SOURCE) + parser.add_argument('--target', default=TARGET) + parser.add_argument('--key-file', type=Path, help='Local deploy key for an explicit manual sync') + args = parser.parse_args() + key = args.key_file.read_text() if args.key_file else os.environ.get('GH_MIRROR_SSH_KEY') + mirror(args.source, args.target, key, os.environ.get('MIRROR_SOURCE_REF'), os.environ.get('MIRROR_SOURCE_SHA')) + + +if __name__ == '__main__': + try: + main() + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + raise SystemExit(str(error))