feat: mirror main to GitHub and publish images to GHCR

mirror/github-mirror.sh publishes main and its tags to a read-only
GitHub mirror, adding a notice under the README title, with a systemd
timer to run it hourly. A GitHub Actions workflow builds the images on
the mirror and pushes them to ghcr.io. The docs point prebuilt images
there, and CONTRIBUTING no longer invites issues and pull requests.

upstream-status.sh now fetches without tags, so upstream's version tags
stay out of local clones.
This commit is contained in:
2026-10-15 16:48:04 -07:00
parent 3c3bb5dfbf
commit 2d1d8cce46
13 changed files with 309 additions and 111 deletions
+7
View File
@@ -34,6 +34,13 @@ architecture notes are in `CLAUDE.md`; feature and deployment docs are in
- `.gitea/`;
- migrations upstream doesn't have.
## GitHub mirror
`mirror/` publishes `main` to a read-only GitHub mirror from the operator's
host, never from a session; see `docs/08-development/github-mirror.md`. GitHub
runs `.github/workflows`, the forge `.gitea/workflows`; keep the two image
workflows in step.
## CI
Workflows are in `.gitea/workflows`. Jobs that run docker builds use the
+1 -1
View File
@@ -13,7 +13,7 @@ JWT_SECRET=replace-with-a-secure-random-string
# Built from source by default. To pull published images instead, set the
# registry path they live under (ending in /), then:
# docker compose pull && docker compose up -d
# IMAGE_PREFIX=registry.example.com/owner/
# IMAGE_PREFIX=ghcr.io/branderson/
# IMAGE_TAG=latest
# "Latest release" API URL to check for updates (GitHub or Gitea); off when unset
# APP_UPDATE_URL=https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest
+69
View File
@@ -0,0 +1,69 @@
# Builds the app, session manager and streamer images on the GitHub mirror and
# pushes them to GitHub's container registry, under the repository's owner.
# The forge runs .gitea/workflows/images.yml instead; Gitea ignores this
# directory while .gitea/workflows exists.
name: Images
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: app
image: dotcom-arcade
context: .
- name: session-manager
image: dotcom-arcade-session-manager
context: streaming/session-manager
- name: streamer
image: dotcom-arcade-streamer
context: streaming/streamer
steps:
- uses: actions/checkout@v4
- name: Lowercase the owner
id: owner
shell: bash
run: echo "name=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.owner.outputs.name }}/${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch
- uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
provenance: false
+15 -105
View File
@@ -1,44 +1,11 @@
# Contributing to Dotcom Arcade
Thanks for your interest in contributing! We welcome bug fixes, features, documentation improvements, and questions.
Dotcom Arcade is developed on a private forge and published to GitHub as a
read-only mirror, so issues and pull requests aren't accepted there. You're
welcome to fork it under the [GPL-3.0](LICENSE).
## What is Dotcom Arcade?
A self-hosted web application for browsing and playing games from [Flashpoint Archive](https://flashpointarchive.org/).
**Tech Stack:** Node.js 20+, Express, React 18, TypeScript, SQLite, TanStack Query, Tailwind CSS
## Table of Contents
- [Where to Start](#where-to-start)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Running Tests](#running-tests)
- [Making Changes](#making-changes)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [Code Style](#code-style)
- [Reporting Bugs](#reporting-bugs)
- [License](#license)
## Where to Start
- Browse issues labeled [`good first issue`](../../labels/good%20first%20issue) or [`help wanted`](../../labels/help%20wanted)
- Check the [project documentation](docs/) for architecture and patterns
## Getting Started
```bash
# Fork the repository on GitHub, then clone your fork
git clone https://github.com/YOUR_USERNAME/flashpoint-web.git
cd flashpoint-web
# Add upstream remote
git remote add upstream https://github.com/ORIGINAL_OWNER/flashpoint-web.git
# Keep your fork synced
git fetch upstream
git rebase upstream/master
```
It's built on [flashpoint-web](https://github.com/darkraise/flashpoint-web).
Fixes to code the two still share may belong upstream.
## Development Setup
@@ -47,6 +14,7 @@ git rebase upstream/master
```
backend/ # Express REST API + game content serving (:3100)
frontend/ # React + Vite application (:5173)
streaming/ # Session manager and streamer for games browsers can't run
docs/ # Documentation
```
@@ -69,6 +37,7 @@ The app (UI and API) runs on `http://localhost:80`.
### Option 2: Manual Setup (Recommended for Development)
**Prerequisites:**
- Node.js 20+
- Flashpoint Archive installed locally
@@ -99,80 +68,21 @@ cd backend && npm test -- --coverage
cd frontend && npm test
```
## Making Changes
### Branch Naming
```
feat/add-playlist-export
fix/game-search-pagination
refactor/auth-service
docs/update-api-reference
```
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <description>
feat(games): add advanced search filters
fix(auth): resolve token refresh race condition
docs: update deployment guide
refactor(api): simplify error handling
```
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`
### Workflow
1. Create a branch from `master`
2. Make your changes
3. Run `npm run typecheck` and `npm test`
4. Update documentation if needed
5. Submit a pull request
## Submitting a Pull Request
### Before Submitting
```bash
git fetch upstream
git rebase upstream/master
npm run typecheck
npm run format
npm test
```
### PR Guidelines
- Keep PRs focused and reasonably sized
- Reference related issues (`Fixes #123`)
- Include a clear description of changes
- Update documentation for new features
## Code Style
See [CLAUDE.md](CLAUDE.md) for detailed coding standards. Key points:
- **TypeScript:** No `any` types, use `unknown` or proper interfaces
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before parameterized
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme tokens
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before
parameterized
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme
tokens
- **General:** Validate inputs, handle errors properly, clean up resources
Run `npm run format` before committing.
## Reporting Bugs
Before reporting, search [existing issues](../../issues) to avoid duplicates.
Include:
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Node version, browser)
- Screenshots or logs if applicable
Run `npm run format` before committing. Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org/).
## License
This project is licensed under [GPL-3.0](LICENSE). By contributing, you agree that your contributions will be licensed under the same license.
This project is licensed under [GPL-3.0](LICENSE). By contributing, you agree
that your contributions will be licensed under the same license.
+3
View File
@@ -135,6 +135,9 @@ PGID=1000
docker compose up -d --build
```
To pull prebuilt images instead of building them, see
[prebuilt images](docs/09-deployment/docker-deployment.md#prebuilt-images).
Open `http://<server>/` and create the admin account. On first start the app
indexes the collection, which takes a few minutes on a full Ultimate install.
Under **Settings → App** you can set the site name and tagline.
+1
View File
@@ -16,6 +16,7 @@ the Dotcom Arcade application.
6. **[Debugging Guide](./debugging.md)** - Debugging tips and troubleshooting
7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions
8. **[Upstream Sync](./upstream-sync.md)** - Merging changes from flashpoint-web
9. **[GitHub Mirror](./github-mirror.md)** - Publishing to GitHub and GHCR
## Quick Start
+28
View File
@@ -0,0 +1,28 @@
# GitHub mirror
Dotcom Arcade is developed on a private forge. `mirror/github-mirror.sh`
publishes its `main` and the tags on `main` to
[github.com/branderson/dotcom-arcade](https://github.com/branderson/dotcom-arcade),
hourly, from a host that can push to GitHub. Issues and pull requests are off
there. Actions stays on, since it builds the images that `ghcr.io/branderson/`
serves (see [publishing images](../09-deployment/docker-deployment.md#publishing-images)).
The mirror's `main` is the forge's plus one commit, which adds a read-only notice
under the README's title. Every forge commit is in the mirror unchanged, and
each sync fast-forwards. If the README loses its `# ` title, the sync stops
instead of publishing without the notice.
## Setup
1. Set up the timer as described at the top of
`mirror/dotcom-arcade-github-mirror.service`. Give it a deploy key with write
access to the GitHub repository.
2. Run `github-mirror.sh --dry-run` to build what a sync would publish without
pushing it.
3. Once the first sync has pushed, run `github-mirror.sh --settings` (needs
`gh`). It turns off issues, pull requests, projects, wiki and discussions.
4. The first image build creates its packages as private. In each package's
settings on GitHub, set its visibility to public.
The timer runs a copy of the script, not the checkout's. Reinstall it after
reviewing a change to `mirror/`.
+15 -4
View File
@@ -28,18 +28,29 @@ All three are built from this repository by `docker compose up -d --build`.
### Prebuilt images
To pull published images instead of building, set `IMAGE_PREFIX` in `.env` to
the registry path they live under, ending in `/`, and optionally `IMAGE_TAG`:
Images are published to GitHub's container registry. To pull them instead of
building, set `IMAGE_PREFIX` in `.env`, ending in `/`, and optionally
`IMAGE_TAG`. `latest` is the newest release, `1.2` the newest of a minor
version, and `main` follows the `main` branch.
```sh
IMAGE_PREFIX=registry.example.com/owner/
IMAGE_PREFIX=ghcr.io/branderson/
IMAGE_TAG=latest
```
Then run `docker compose pull && docker compose up -d`.
Then run `docker compose pull && docker compose up -d`. Images from another
registry work the same way, with its path as the prefix.
### Publishing images
Two workflows build the same images with the same tags:
- `.github/workflows/images.yml` runs on the
[GitHub mirror](../08-development/github-mirror.md) and pushes to
`ghcr.io/<owner>/` with the workflow's own token. It needs no setup.
- `.gitea/workflows/images.yml` runs on the forge. Gitea ignores
`.github/workflows` while `.gitea/workflows` exists.
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
pushes them to the Gitea instance's container registry, under the repository's
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
+1
View File
@@ -126,6 +126,7 @@ for Flashpoint Ultimate.
- [Debugging](08-development/debugging.md) - Debugging tips
- [Common Pitfalls](08-development/common-pitfalls.md) - Known issues
- [Upstream Sync](08-development/upstream-sync.md) - Merging changes from flashpoint-web
- [GitHub Mirror](08-development/github-mirror.md) - Publishing to GitHub and GHCR
### 09. Deployment
@@ -0,0 +1,26 @@
# User unit for the GitHub mirror, on a host that can push to GitHub. Never in
# a dejima session: a sandbox may push only to its own forge.
#
# install -m 755 mirror/github-mirror.sh ~/.local/bin/dotcom-arcade-github-mirror
# cp mirror/dotcom-arcade-github-mirror.service mirror/dotcom-arcade-github-mirror.timer ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now dotcom-arcade-github-mirror.timer
#
# A copy, not a link into the checkout: the timer holds a key that can push to
# GitHub, and a session working in this checkout can edit the script.
# Reinstall it after reviewing an update.
#
# ~/.config/dotcom-arcade/github-mirror.env sets MIRROR_SOURCE, the forge
# clone URL, and whatever else mirror/github-mirror.sh documents. The GitHub
# key, a deploy key with write access to the repository, belongs behind an ssh
# Host alias named in MIRROR_TARGET, so it is not also offered to the forge:
#
# MIRROR_SOURCE=git@forge.example.com:owner/dotcom-arcade.git
# MIRROR_TARGET=git@github-dotcom-arcade:branderson/dotcom-arcade.git
[Unit]
Description=Mirror Dotcom Arcade's main and release tags to GitHub
[Service]
Type=oneshot
EnvironmentFile=%h/.config/dotcom-arcade/github-mirror.env
ExecStart=%h/.local/bin/dotcom-arcade-github-mirror
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Mirror Dotcom Arcade's main and release tags to GitHub, hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Publishes main and its release tags to a read-only mirror on GitHub.
#
# MIRROR_SOURCE=<forge clone URL> mirror/github-mirror.sh sync
# ... mirror/github-mirror.sh --dry-run build and print, push nothing
# mirror/github-mirror.sh --settings configure the GitHub repo (needs gh)
# mirror/github-mirror.sh --transform DIR rewrite a checkout in place
#
# Runs on a host that can push to GitHub, never in a dejima session: a sandbox
# may push only to its own forge.
#
# The GitHub copy is not byte-for-byte. Its main is one extra commit on top of
# the forge's main, whose tree is the forge's with the rewrites in transform()
# applied, and whose parents are the forge commit and the previous mirror
# commit. So every forge SHA is in the mirror unchanged, and each sync is a
# fast-forward for anyone tracking it. Tags are pushed as they are, pointing at
# the untransformed forge commits.
#
# Environment:
# MIRROR_SOURCE forge clone URL (required to sync)
# MIRROR_REPO GitHub repository, default branderson/dotcom-arcade
# MIRROR_TARGET push URL, default git@github.com:$MIRROR_REPO.git
# MIRROR_STATE working bare repository, default ~/.cache/dotcom-arcade-github-mirror.git
set -euo pipefail
MIRROR_REPO="${MIRROR_REPO:-branderson/dotcom-arcade}"
MIRROR_TARGET="${MIRROR_TARGET:-git@github.com:$MIRROR_REPO.git}"
MIRROR_STATE="${MIRROR_STATE:-${XDG_CACHE_HOME:-$HOME/.cache}/dotcom-arcade-github-mirror.git}"
say() { echo "github-mirror: $*"; }
die() { say "$*" >&2; exit 1; }
WORK=""
cleanup() {
[ -n "$WORK" ] || return 0
git -C "$MIRROR_STATE" worktree remove --force "$WORK" 2>/dev/null || true
rm -rf "$WORK"
}
# Rewrites a checkout for GitHub. Each rewrite is checked, so a README that
# drifted away from what this expects stops the mirror instead of publishing
# the forge's version. Running it twice changes nothing.
transform() {
local readme="$1/README.md"
# perl -i would write a symlink's target back as the README, publishing it.
[ -f "$readme" ] && [ ! -L "$readme" ] || die "README.md is missing or a symlink in $1"
# The first thing a visitor to a mirror does is open an issue or a pull
# request. Under the title.
grep -q '^> \*\*This is a read-only mirror' "$readme" || perl -0pi -e '
s{^(# [^\n]*)\n}{$1\n\n> **This is a read-only mirror.** Dotcom Arcade is developed on a private\n> forge, and issues and pull requests are not accepted here.\n}m
' "$readme"
grep -q '^> \*\*This is a read-only mirror' "$readme" \
|| die "README.md: no title to put the mirror notice under"
}
# Issues, pull requests, wiki and the like off; Actions stays on, since it
# publishes the images. Settings rather than content, so a sync does not
# reapply them; run once after creating the repository.
settings() {
command -v gh >/dev/null || die "--settings needs the gh CLI"
gh api -X PATCH "repos/$MIRROR_REPO" --silent \
-F has_issues=false -F has_pull_requests=false -F has_projects=false \
-F has_wiki=false -F has_discussions=false
}
sync() {
[ -n "${MIRROR_SOURCE:-}" ] || die "MIRROR_SOURCE is not set"
local git=(git -C "$MIRROR_STATE")
trap cleanup EXIT
[ -d "$MIRROR_STATE" ] || git init -q --bare "$MIRROR_STATE"
"${git[@]}" fetch -q --no-tags --prune "$MIRROR_SOURCE" \
'+refs/heads/main:refs/source/main' '+refs/tags/*:refs/source/tags/*'
local src prev=""
src="$("${git[@]}" rev-parse refs/source/main)"
# An absent main (exit 2) is a first run; anything else is a real failure.
local rc=0
git ls-remote -q --exit-code "$MIRROR_TARGET" refs/heads/main >/dev/null || rc=$?
if [ "$rc" -eq 0 ]; then
"${git[@]}" fetch -q --no-tags "$MIRROR_TARGET" '+refs/heads/main:refs/mirror/main'
prev="$("${git[@]}" rev-parse refs/mirror/main)"
elif [ "$rc" -ne 2 ]; then
die "cannot read $MIRROR_TARGET"
fi
WORK="$(mktemp -d)"
"${git[@]}" worktree add -q --detach "$WORK" "$src"
transform "$WORK"
git -C "$WORK" add -A
local tree
tree="$(git -C "$WORK" write-tree)"
if [ -n "$prev" ] && [ "$("${git[@]}" rev-parse "$prev^1")" = "$src" ] \
&& [ "$("${git[@]}" rev-parse "$prev^{tree}")" = "$tree" ]; then
say "already at ${src:0:12}"
else
# The forge commit's committer and date, so the same input always
# makes the same commit.
local name email date commit
IFS=$'\t' read -r name email date < <("${git[@]}" log -1 --format='%cn%x09%ce%x09%cI' "$src")
commit="$(GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" GIT_AUTHOR_DATE="$date" \
GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" GIT_COMMITTER_DATE="$date" \
"${git[@]}" commit-tree "$tree" -p "$src" ${prev:+-p "$prev"} \
-m "Mirror ${src:0:12} for GitHub" \
-m "README header added by mirror/github-mirror.sh.")"
"${git[@]}" update-ref refs/mirror/next "$commit"
[ -n "$DRY_RUN" ] || "${git[@]}" push -q "$MIRROR_TARGET" "$commit:refs/heads/main"
say "main is ${src:0:12} as ${commit:0:12}"
fi
# Only tags on main: one on another branch would publish unreleased work.
local ref
local refspecs=()
while IFS= read -r ref; do
if "${git[@]}" merge-base --is-ancestor "$ref" "$src"; then
refspecs+=("$ref:refs/tags/${ref#refs/source/tags/}")
say "tag ${ref#refs/source/tags/}"
fi
done < <("${git[@]}" for-each-ref --format='%(refname)' refs/source/tags/)
[ "${#refspecs[@]}" -eq 0 ] || [ -n "$DRY_RUN" ] \
|| "${git[@]}" push -q "$MIRROR_TARGET" "${refspecs[@]}"
}
DRY_RUN=""
case "${1:-}" in
"") sync ;;
--dry-run) DRY_RUN=1; sync ;;
--settings) settings ;;
--transform) [ -n "${2:-}" ] || die "--transform needs a directory"; transform "$2" ;;
*) sed -n '4,7p' "$0" >&2; exit 2 ;;
esac
+1 -1
View File
@@ -9,7 +9,7 @@ BRANCH=master
UPSTREAM=refs/remotes/upstream/$BRANCH
cd "$(git rev-parse --show-toplevel)"
git fetch -q "$URL" "+$BRANCH:$UPSTREAM"
git fetch -q --no-tags "$URL" "+$BRANCH:$UPSTREAM"
base=$(git merge-base HEAD "$UPSTREAM")
count=$(git rev-list --count "$base..$UPSTREAM")