Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# GitHub Personal Access Token (optional but recommended for higher API rate limits)
# Get one from: https://github.com/settings/tokens
GITHUB_TOKEN=your_github_token_here

# Comma-separated list of GitHub users/organizations to backup
# Example: SCHEDULED_USERS=octocat,github,spring-projects
SCHEDULED_USERS=octocat
81 changes: 81 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: CI

on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]

jobs:
test:
name: Run Unit Tests
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'

- name: Run tests
run: mvn clean test

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: target/surefire-reports/
retention-days: 30

build:
name: Build Application
runs-on: ubuntu-latest
needs: test

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'

- name: Build with Maven
run: mvn clean package -DskipTests

- name: Upload JAR artifact
uses: actions/upload-artifact@v4
with:
name: gh-backup-jar
path: target/*.jar
retention-days: 30

docker-build:
name: Docker Build Test
runs-on: ubuntu-latest
needs: test

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: gh-backup:test
cache-from: type=gha
cache-to: type=gha,mode=max
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Build stage
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
# Skip tests during Docker build to speed up image creation
# Tests should be run in CI/CD pipeline before building the image
RUN mvn clean package -DskipTests
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are skipped during the Docker build with -DskipTests. While this speeds up the build, it means the Docker image could contain code that hasn't been tested. Consider running tests during the build to ensure code quality, or document why tests are skipped (e.g., if tests require external dependencies not available during build).

Suggested change
RUN mvn clean package -DskipTests
RUN mvn clean package

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 721d02a. Added comments documenting that tests should be run in CI/CD pipeline before building the Docker image, and that tests are skipped to speed up image creation.


# Runtime stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# Install git (required for cloning repositories)
RUN apk add --no-cache git

# Copy the built jar from build stage
COPY --from=build /app/target/gh-backup-1.0.0.jar gh-backup.jar

# Create backup directory
RUN mkdir -p /backups

# Set default environment variables
ENV BACKUP_DIRECTORY=/backups
ENV GITHUB_TOKEN=""
ENV SCHEDULED_USERS=""

# Expose port 8080 - only used if running in web mode with -Dspring.profiles.active=web
# The default daemon mode does not use this port
Comment on lines +28 to +29
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Dockerfile comment says port 8080 is used when running with -Dspring.profiles.active=web, but docker-entrypoint.sh currently hard-codes -Dspring.profiles.active=daemon, making web mode unreachable unless the user overrides the entrypoint. Either adjust the entrypoint to allow overriding the profile (e.g., via SPRING_PROFILES_ACTIVE/an env var) or update this comment to reflect the actual way to run web mode from the image.

Suggested change
# Expose port 8080 - only used if running in web mode with -Dspring.profiles.active=web
# The default daemon mode does not use this port
# Expose port 8080 for optional web mode.
# The default image entrypoint runs in daemon mode, so this port is only used if the
# container is started in web mode by overriding the default startup behavior.

Copilot uses AI. Check for mistakes.
EXPOSE 8080

# Create entrypoint script
COPY docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh

# Default to daemon mode with scheduled backups
ENTRYPOINT ["/app/docker-entrypoint.sh"]
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# gh-backup

![CI](https://github.com/dmccoystephenson/gh-backup/workflows/CI/badge.svg)

A tool built with Spring Boot to backup public GitHub repositories for specified users or organizations. Available as both a command-line tool and a web application.

## Features

- **Web UI** for easy backup management through your browser
- **Daemon Mode** with Docker support for automatic daily backups
- Backup all public repositories from one or more GitHub users/organizations
- Clone new repositories or update existing ones
- **Interactive mode** for easier management and status viewing
Expand Down Expand Up @@ -32,6 +35,71 @@ The executable JAR will be created at `target/gh-backup-1.0.0.jar`

## Usage

### Daemon Mode with Docker (Automatic Daily Backups)

The easiest way to run automatic daily backups is using Docker. This will spin up a background service that backs up your configured repositories every 24 hours.

#### Quick Start with Docker Compose

1. Create a `.env` file (or copy from `.env.example`):
```bash
cp .env.example .env
```

2. Edit `.env` to configure your backups:
```bash
GITHUB_TOKEN=your_github_token_here
SCHEDULED_USERS=octocat,github,spring-projects
```

3. Start the daemon service:
```bash
docker-compose up -d
```

The service will:
- Run the first backup immediately
- Continue running in the background
- Automatically backup every 24 hours
- Persist backups to the `./backups` directory on your host machine

4. View logs:
```bash
docker-compose logs -f
```

5. Stop the service:
```bash
docker-compose down
```

#### Using Docker directly

Build the image:
```bash
docker build -t gh-backup .
```

Run the daemon:
```bash
docker run -d \
-e GITHUB_TOKEN=your_token \
-e SCHEDULED_USERS=octocat,github \
-v $(pwd)/backups:/backups \
--name gh-backup-daemon \
gh-backup
```

#### Running without Docker

You can also run daemon mode directly with Java:
```bash
java -Dspring.profiles.active=daemon \
-Dbackup.scheduled.users=octocat,github \
-Dbackup.directory=/path/to/backups \
-jar target/gh-backup-1.0.0.jar
```

### Web UI Mode (Recommended)

Start the web server:
Expand Down
21 changes: 21 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
services:
gh-backup-daemon:
build: .
container_name: gh-backup-daemon
restart: unless-stopped
environment:
# Set your GitHub personal access token here for higher API rate limits
- GITHUB_TOKEN=${GITHUB_TOKEN:-}

# Comma-separated list of GitHub users/organizations to backup
# Example: SCHEDULED_USERS=octocat,github,spring-projects
- SCHEDULED_USERS=${SCHEDULED_USERS:-}

# Optional: Custom backup directory inside container
- BACKUP_DIRECTORY=/backups
volumes:
# Mount a local directory to persist backups
- ./backups:/backups
# Keep container running
stdin_open: true
tty: true
18 changes: 18 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
set -e

# Build Java command with environment variables
JAVA_OPTS="-Dspring.profiles.active=daemon"

# Set backup directory if provided (defaults to /backups in Dockerfile ENV)
if [ -n "$BACKUP_DIRECTORY" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}"
fi

# Set scheduled users if provided
if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}"
fi

# Execute the application
exec java $JAVA_OPTS -jar gh-backup.jar "$@"
Comment on lines +5 to +18
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entrypoint builds JAVA_OPTS as a string and then executes exec java $JAVA_OPTS ... unquoted. This causes word-splitting and enables option injection if BACKUP_DIRECTORY or SCHEDULED_USERS contains spaces or starts with something like -D.../-X.... It also breaks valid paths containing spaces. Build the argument list safely (e.g., using set -- java ... and appending quoted -D...=$VAR args, then exec "$@" -jar ...) so env values are treated as data, not additional flags.

Suggested change
JAVA_OPTS="-Dspring.profiles.active=daemon"
# Set backup directory if provided (defaults to /backups in Dockerfile ENV)
if [ -n "$BACKUP_DIRECTORY" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}"
fi
# Set scheduled users if provided
if [ -n "$SCHEDULED_USERS" ]; then
JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}"
fi
# Execute the application
exec java $JAVA_OPTS -jar gh-backup.jar "$@"
set -- java "-Dspring.profiles.active=daemon"
# Set backup directory if provided (defaults to /backups in Dockerfile ENV)
if [ -n "$BACKUP_DIRECTORY" ]; then
set -- "$@" "-Dbackup.directory=${BACKUP_DIRECTORY}"
fi
# Set scheduled users if provided
if [ -n "$SCHEDULED_USERS" ]; then
set -- "$@" "-Dbackup.scheduled.users=${SCHEDULED_USERS}"
fi
# Execute the application
set -- "$@" -jar gh-backup.jar "$@"
exec "$@"

Copilot uses AI. Check for mistakes.
95 changes: 95 additions & 0 deletions src/main/java/com/github/backup/ScheduledBackupService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.github.backup;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;

/**
* Service that runs scheduled backups at configured intervals.
* Enabled when backup.mode=daemon.
*/
@Service
@ConditionalOnProperty(name = "backup.mode", havingValue = "daemon")
public class ScheduledBackupService {

private static final Logger log = LoggerFactory.getLogger(ScheduledBackupService.class);
private static final int SEPARATOR_LENGTH = 80;

private final BackupService backupService;
private final List<String> scheduledUsers;
private final long backupIntervalMs;
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public ScheduledBackupService(BackupService backupService,
@Value("${backup.scheduled.users:}") String scheduledUsersConfig,
@Value("${backup.scheduled.interval.ms:86400000}") long backupIntervalMs) {
this.backupService = backupService;
this.backupIntervalMs = backupIntervalMs;
// Parse comma-separated list of users/orgs
this.scheduledUsers = scheduledUsersConfig.isBlank()
? List.of()
: Arrays.stream(scheduledUsersConfig.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}

@PostConstruct
public void init() {
if (scheduledUsers.isEmpty()) {
log.warn("No users/organizations configured for scheduled backups.");
log.warn("Set the 'backup.scheduled.users' property with a comma-separated list.");
log.warn("Example: backup.scheduled.users=octocat,github");
} else {
log.info("GitHub Backup Daemon Mode");
log.info("========================");
log.info("Scheduled backups enabled for: {}", String.join(", ", scheduledUsers));
log.info("Backup interval: {} hours", backupIntervalMs / 3600000.0);
log.info("First backup will run immediately, then every {} hours.", backupIntervalMs / 3600000.0);
}
}

/**
* Runs backup for all configured users/organizations.
* Executes on startup and then at the configured interval after each completion.
* Uses fixedDelay to ensure the next backup starts only after the previous one completes.
*/
@Scheduled(fixedDelayString = "${backup.scheduled.interval.ms:86400000}", initialDelay = 0)
public void runScheduledBackup() {
if (scheduledUsers.isEmpty()) {
return;
}

String timestamp = LocalDateTime.now().format(dateTimeFormatter);
log.info("");
log.info("{}", "=".repeat(SEPARATOR_LENGTH));
log.info("Starting scheduled backup at {}", timestamp);
log.info("{}", "=".repeat(SEPARATOR_LENGTH));

for (String userOrOrg : scheduledUsers) {
try {
backupService.backupUserRepositories(userOrOrg);
} catch (IOException e) {
log.error("Error backing up {}: {}", userOrOrg, e.getMessage());
}
}

timestamp = LocalDateTime.now().format(dateTimeFormatter);
log.info("");
log.info("{}", "=".repeat(SEPARATOR_LENGTH));
log.info("Scheduled backup completed at {}", timestamp);
log.info("Next backup will run {} hours after this backup completes.", backupIntervalMs / 3600000.0);
log.info("{}", "=".repeat(SEPARATOR_LENGTH));
log.info("");
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/github/backup/SchedulingConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.github.backup;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
* Configuration to enable Spring scheduling only in daemon mode.
* This prevents scheduling from being activated in CLI and web modes.
*/
@Configuration
@EnableScheduling
@Profile("daemon")
public class SchedulingConfiguration {
}
11 changes: 11 additions & 0 deletions src/main/resources/application-daemon.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Daemon mode configuration
spring.main.web-application-type=none
backup.mode=daemon

# Comma-separated list of GitHub users/organizations to backup automatically
# Example: backup.scheduled.users=octocat,github,spring-projects
backup.scheduled.users=

# Backup interval in milliseconds (default: 86400000 = 24 hours)
# Example: 3600000 = 1 hour, 43200000 = 12 hours
backup.scheduled.interval.ms=86400000
Loading