From eb1d89b26583eb8a7786228678867afa85bf7726 Mon Sep 17 00:00:00 2001 From: Daniel Podwysocki <48068081+danielpodwysocki@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:17:14 +0200 Subject: [PATCH] set up an action for restic backups of github orgs This sets up a basic action for performing the backups of our GH organization and rotating them out. I've validated it practically already, here are the cases I ran through: - I can see the backups being taken - I can see the private repos getting backed up properly - I can see the backup size is sane/non-empty - I can see the rotation is working as-intended on the `--keep-last` flag. This repo is public - I will stash the final integration details on the internal ticket and internal repos. --- README.md | 70 +++++++++++++++++++++++++++++ action.yml | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 README.md create mode 100644 action.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e839ef --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# github-restic-backup + +The GitHub action can back up all repositories in a namespace (organization's or user's). +It encrypts them using `restic` before uploading them to your target storage - eg. AWS. + +Restic also provides: +- deduplication +- cleaning up any backups past a retention period you specify + + +For how to pass in your S3 credentials, restic password and set the rotation period, see inputs in action.yml +For how restic handles backups, rotation, etc. see the excellent upstream docs: https://restic.net/ + + +## Note on security + +As with any 3rd party action, we recommend pinning this action to a commit, to ensure integrity, eg: + +``` +juno-fx/github-restic-backup@ +``` + +The above is more secure than: +``` +juno-fx/github-restic-backup@main +``` + + +You can get the latest commit sha by clicking on "History" in the GitHub web ui - or running `git rev-parse HEAD` on a local checkout. + + + + +## Usage example + +A simple example of how you might use this action: + +``` +name: Daily Github repo backup to S3 +on: + push: + workflow_dispatch: + schedule: + - cron: "0 4 * * *" + +jobs: + backup: + runs-on: ubuntu-latest + steps: + - name: Back up the Github organization + uses: juno-fx/github-restic-backup@ + with: + github_namespace: juno-fx + github_access_token: ${{ secrets.GIT_PASS }} + restic_password: ${{ secrets.GH_BACKUP_RESTIC_PASSWORD }} + restic_repository: s3:https://s3.us-east-1.amazonaws.com/ + restic_image_tag: latest + s3_access_key_id: ${{ secrets.GH_BACKUP_AWS_ACCESS_KEY_ID }} + s3_secret_access_key: ${{ secrets.GH_BACKUP_AWS_SECRET_ACCESS_KEY }} + validate_private_repos_presence: "true" + restic_keep_last: 2 + restic_keep_daily: 7 + +``` + +### Github Actions caveats + + +Note that the approach above is an example - it does have one pitfall. +As with all scheduled jobs, if you offboard the user running it, it will become suspended. Keep that in mind and monitor it appropriately if you choose to reuse this example. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..94b4045 --- /dev/null +++ b/action.yml @@ -0,0 +1,126 @@ +name: "Back up a GitHub org/namespace with restic" +description: "An action to perform and manage retention of encrypted backups for a GitHub organization/namespace, using restic (https://restic.net/)." +inputs: + github_namespace: + description: "Organization or user namespace to backup (eg. 'juno-fx' backs up all repos in the juno-fx org)" + required: true + github_access_token: + description: "GitHub access token to use for the backup - must be able to read all target repos in the org/namespace" + required: true + restic_password: + description: "Restic password to use for the backup" + required: true + restic_image_tag: + description: "Restic image tag to use for the backup (default: 'latest')" + default: "latest" + restic_repository: + description: "Restic repository URL to use for the backup, eg. s3:https://s3.us-east-1.amazonaws.com/juno-example-bucket .Upstream docs can be found here: https://restic.readthedocs.io/en/latest/index.html" + required: false + s3_access_key_id: + description: "S3 access key ID to use for the restic backup" + required: true + s3_secret_access_key: + description: "S3 secret access key to use for the restic backup" + required: true + restic_keep_last: + description: "How many last backups to keep. When one of the restic_keep inputs is set, we keep them indefinitely" + required: false + restic_keep_daily: + description: "How many daily backups to keep. When one of the restic_keep inputs is set, we keep them indefinitely" + required: false + restic_keep_weekly: + description: "How many weekly backups to keep. When one of the restic_keep inputs is set, we keep them indefinitely" + required: false + restic_keep_monthly: + description: "How many monthly backups to keep. When one of the restic_keep inputs is set, we keep them indefinitely" + required: false + validate_private_repos_presence: + description: "If true, the action will fail if there are no private repos in the org/namespace. This sanity-checks your token has the correct permissions." + default: "false" + required: false + +runs: + using: "composite" + steps: + - name: Gather all repos + env: + GITHUB_NAMESPACE: ${{ inputs.github_namespace }} + GITHUB_TOKEN: ${{ inputs.github_access_token }} + shell: bash + run: | + if [ "${{ inputs.validate_private_repos_presence }}" != "false" ]; then + private_repos=$(gh repo list $GITHUB_NAMESPACE --limit 1000 --json isPrivate --jq 'map(select(.isPrivate)) | length') + if [ "$private_repos" -eq 0 ]; then + echo "No private repositories found in the organization/namespace. Please check your access token permissions." + exit 1 + fi + fi + mkdir github_backup -p + cd github_backup + rm -rf ./* + gh repo list $GITHUB_NAMESPACE --limit 1000 | while read -r repo _; do + if [ ! -d ${repo} ] + then + gh repo clone "${repo}" "${repo}" + fi + done + - name: Run restic backup + env: + RESTIC_PASSWORD: ${{ inputs.restic_password }} + RESTIC_REPOSITORY: "${{ inputs.restic_repository }}" + AWS_ACCESS_KEY_ID: ${{ inputs.s3_access_key_id }} + AWS_SECRET_ACCESS_KEY: ${{ inputs.s3_secret_access_key }} + shell: bash + run: | + # if listing snapshots fails, init the repo + docker run --rm \ + -e RESTIC_PASSWORD \ + -e RESTIC_REPOSITORY \ + -e AWS_ACCESS_KEY_ID \ + -e AWS_SECRET_ACCESS_KEY \ + restic/restic:${{ inputs.restic_image_tag }} snapshots || + docker run --rm \ + -e RESTIC_PASSWORD \ + -e RESTIC_REPOSITORY \ + -e AWS_ACCESS_KEY_ID \ + -e AWS_SECRET_ACCESS_KEY \ + restic/restic:${{ inputs.restic_image_tag }} init + docker run --rm \ + -e RESTIC_PASSWORD \ + -e RESTIC_REPOSITORY \ + -e AWS_ACCESS_KEY_ID \ + -e AWS_SECRET_ACCESS_KEY \ + -v "${PWD}/github_backup:/backup" \ + restic/restic:${{ inputs.restic_image_tag }} backup /backup + + - name: Run restic prune, cleaning up old backups + if: ${{ inputs.restic_keep_last || inputs.restic_keep_daily || inputs.restic_keep_weekly || inputs.restic_keep_monthly }} + env: + RESTIC_PASSWORD: ${{ inputs.restic_password }} + RESTIC_REPOSITORY: ${{ inputs.restic_repository }} + AWS_ACCESS_KEY_ID: ${{ inputs.s3_access_key_id }} + AWS_SECRET_ACCESS_KEY: ${{ inputs.s3_secret_access_key }} + shell: bash + run: | + restic_flags="" + if [ -n "${{ inputs.restic_keep_last }}" ]; then + restic_flags+=" --keep-last ${{ inputs.restic_keep_last }}" + fi + if [ -n "${{ inputs.restic_keep_daily }}" ]; then + restic_flags+=" --keep-daily ${{ inputs.restic_keep_daily }}" + fi + if [ -n "${{ inputs.restic_keep_weekly }}" ]; then + restic_flags+=" --keep-weekly ${{ inputs.restic_keep_weekly }}" + fi + if [ -n "${{ inputs.restic_keep_monthly }}" ]; then + restic_flags+=" --keep-monthly ${{ inputs.restic_keep_monthly }}" + fi + + # default grouping includes the host - which changes from CI run to CI run. + # We only group by paths, otherwise we'd never rotate the backups per the flags + docker run --rm \ + -e RESTIC_PASSWORD \ + -e RESTIC_REPOSITORY \ + -e AWS_ACCESS_KEY_ID \ + -e AWS_SECRET_ACCESS_KEY \ + restic/restic:${{ inputs.restic_image_tag }} forget --group-by "paths" --prune $restic_flags