Add Forgejo CI and GitHub mirroring after checks
This commit is contained in:
parent
fc93e5ce7f
commit
ca8c5de013
9 changed files with 332 additions and 0 deletions
50
.forgejo/workflows/ci.yml
Normal file
50
.forgejo/workflows/ci.yml
Normal 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
31
.github/workflows/ci.yml
vendored
Normal 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
|
||||
|
|
@ -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
63
docs/ci.md
Normal 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
|
||||
```
|
||||
|
|
@ -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
52
tests/test_ci_mirror.py
Normal 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
65
tools/ci/check.py
Normal 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()
|
||||
1
tools/ci/github_known_hosts
Normal file
1
tools/ci/github_known_hosts
Normal file
|
|
@ -0,0 +1 @@
|
|||
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
|
||||
64
tools/ci/mirror.py
Normal file
64
tools/ci/mirror.py
Normal 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))
|
||||
Loading…
Add table
Reference in a new issue