Add Forgejo CI and GitHub mirroring after checks
Some checks are pending
Check and mirror to GitHub / checks (push) Waiting to run
Check and mirror to GitHub / github-mirror (push) Blocked by required conditions

This commit is contained in:
fi3w0 2026-10-03 23:28:26 +02:00
parent fc93e5ce7f
commit ca8c5de013
9 changed files with 332 additions and 0 deletions

50
.forgejo/workflows/ci.yml Normal file
View file

@ -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

31
.github/workflows/ci.yml vendored Normal file
View file

@ -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

View file

@ -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

63
docs/ci.md Normal file
View file

@ -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
```

View file

@ -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 |

52
tests/test_ci_mirror.py Normal file
View file

@ -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()

65
tools/ci/check.py Normal file
View file

@ -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()

View file

@ -0,0 +1 @@
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl

64
tools/ci/mirror.py Normal file
View file

@ -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))