From 7a6426846048d040d796bcc3c83baba69737d3af Mon Sep 17 00:00:00 2001 From: "Ben St. Pierre" Date: Wed, 6 May 2026 16:04:34 -0600 Subject: [PATCH] Add checkout-with-cache composite action --- checkout-with-cache/README.md | 32 ++++++++ checkout-with-cache/action.yaml | 109 ++++++++++++++++++++++++++ checkout-with-cache/cache_discover.sh | 34 ++++++++ checkout-with-cache/cache_restore.sh | 35 +++++++++ checkout-with-cache/cache_rotation.sh | 18 +++++ checkout-with-cache/cache_update.sh | 39 +++++++++ checkout-with-cache/is_efs_enabled.sh | 17 ++++ 7 files changed, 284 insertions(+) create mode 100644 checkout-with-cache/README.md create mode 100644 checkout-with-cache/action.yaml create mode 100755 checkout-with-cache/cache_discover.sh create mode 100755 checkout-with-cache/cache_restore.sh create mode 100755 checkout-with-cache/cache_rotation.sh create mode 100755 checkout-with-cache/cache_update.sh create mode 100755 checkout-with-cache/is_efs_enabled.sh diff --git a/checkout-with-cache/README.md b/checkout-with-cache/README.md new file mode 100644 index 0000000..a779844 --- /dev/null +++ b/checkout-with-cache/README.md @@ -0,0 +1,32 @@ +# checkout-with-cache + +Composite action that restores a cached repository `.git` directory from RunsOn EFS before `actions/checkout`, then updates the cache after checkout. This keeps `actions/checkout` in charge of the checkout while letting large repositories fetch deltas instead of cloning all history every run. + +This is useful for large repositories that use `fetch-depth: 0`. It is usually not worth it for small repositories. + +```yaml +jobs: + build: + runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64 + steps: + - uses: runs-on/action/checkout-with-cache@v2 + with: + encryption_key: ${{ secrets.CHECKOUT_CACHE_KEY }} +``` + +The action defaults to `/mnt/efs/${GITHUB_REPOSITORY}/.git` and stores files named `repository--.tar`. If `encryption_key` is set, cache files are encrypted with `openssl enc -aes-256-cbc -pbkdf2`; otherwise they are plain tar files. + +Writes are limited to the repository default branch by default and are skipped for pull request events. The action passes `persist-credentials: false` to `actions/checkout` by default and also removes checkout extraheaders from local Git config before saving `.git`. + +## Inputs + +| Name | Default | Description | +| --- | --- | --- | +| `encryption_key` | `''` | Optional passphrase used to encrypt/decrypt cache tarballs. | +| `write_branches` | repository default branch | Comma-separated list of branches allowed to write cache files. | +| `keep_files_count` | `5` | Number of cache files to keep. | +| `force_checkout` | `false` | Skip cache restore and force a normal checkout. | +| `force_write` | `false` | Write a cache file even when the current SHA already has one. | +| `cache_dir` | `/mnt/efs/${GITHUB_REPOSITORY}/.git` | Directory where cache files are stored. | +| `fetch_depth` | `0` | Value passed to `actions/checkout`. | +| `persist_credentials` | `false` | Value passed to `actions/checkout`. | diff --git a/checkout-with-cache/action.yaml b/checkout-with-cache/action.yaml new file mode 100644 index 0000000..770f9a4 --- /dev/null +++ b/checkout-with-cache/action.yaml @@ -0,0 +1,109 @@ +name: checkout-with-cache +description: 'Restore a RunsOn EFS repository cache before actions/checkout, then update it after checkout' + +inputs: + encryption_key: + description: 'Optional passphrase used to encrypt/decrypt the tarball with openssl AES-256-CBC/PBKDF2' + required: false + default: '' + write_branches: + description: 'Comma-separated list of branches allowed to write to the cache; defaults to the repository default branch' + required: false + default: ${{ github.event.repository.default_branch }} + keep_files_count: + description: 'Number of repository cache files to keep on EFS' + required: false + default: '5' + force_checkout: + description: 'When true, skip restoring from the cache before checkout' + required: false + default: 'false' + force_write: + description: 'When true, write a cache file even when one already exists for the current SHA' + required: false + default: 'false' + cache_dir: + description: 'EFS cache directory. Defaults to /mnt/efs/${GITHUB_REPOSITORY}/.git' + required: false + default: '' + fetch_depth: + description: 'fetch-depth passed to actions/checkout' + required: false + default: '0' + persist_credentials: + description: 'persist-credentials passed to actions/checkout' + required: false + default: 'false' + +outputs: + cache_dir: + description: 'The EFS directory where the cache is stored' + value: ${{ steps.efs.outputs.cache_dir }} + cache_hit: + description: 'Whether the cache was restored from EFS' + value: ${{ steps.efs.outputs.cache_hit }} + latest_file_name: + description: 'The latest file in the cache directory' + value: ${{ steps.efs.outputs.latest_file_name }} + latest_file_path: + description: 'The path to the latest cache file' + value: ${{ steps.efs.outputs.latest_file_path }} + ref: + description: 'The branch, tag, or SHA that was checked out' + value: ${{ steps.checkout.outputs.ref }} + commit: + description: 'The commit SHA that was checked out' + value: ${{ steps.checkout.outputs.commit }} + +runs: + using: composite + steps: + - name: Check if EFS is enabled + id: efs_enabled + shell: bash + env: + EFS_MOUNT_PATH: /mnt/efs + run: bash "${{ github.action_path }}/is_efs_enabled.sh" + + - name: Discover EFS cache + id: efs + shell: bash + env: + EFS_ENABLED: ${{ steps.efs_enabled.outputs.enabled }} + CACHE_DIR: ${{ inputs.cache_dir }} + run: bash "${{ github.action_path }}/cache_discover.sh" + + - name: Restore .git from EFS + if: ${{ steps.efs.outputs.enabled == 'true' && steps.efs.outputs.cache_hit == 'true' && inputs.force_checkout != 'true' }} + shell: bash + env: + ENC_KEY: ${{ inputs.encryption_key }} + CACHE_FILE: ${{ steps.efs.outputs.latest_file_path }} + WORKSPACE: ${{ github.workspace }} + run: bash "${{ github.action_path }}/cache_restore.sh" + + - name: Checkout repository + uses: actions/checkout@v6 + id: checkout + with: + clean: 'true' + fetch-depth: ${{ inputs.fetch_depth }} + persist-credentials: ${{ inputs.persist_credentials }} + + - name: Update EFS cache if necessary + if: ${{ steps.efs.outputs.enabled == 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' && contains(inputs.write_branches, github.ref_name) }} + shell: bash + env: + ENC_KEY: ${{ inputs.encryption_key }} + CACHE_DIR: ${{ steps.efs.outputs.cache_dir }} + WORKSPACE: ${{ github.workspace }} + FORCE_WRITE: ${{ inputs.force_write }} + run: bash "${{ github.action_path }}/cache_update.sh" + + - name: Cleanup EFS cache + if: ${{ steps.efs.outputs.enabled == 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' && contains(inputs.write_branches, github.ref_name) }} + shell: bash + env: + CACHE_DIR: ${{ steps.efs.outputs.cache_dir }} + KEEP_FILES_COUNT: ${{ inputs.keep_files_count }} + run: bash "${{ github.action_path }}/cache_rotation.sh" diff --git a/checkout-with-cache/cache_discover.sh b/checkout-with-cache/cache_discover.sh new file mode 100755 index 0000000..9e27cbd --- /dev/null +++ b/checkout-with-cache/cache_discover.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +GITHUB_OUTPUT=${GITHUB_OUTPUT:-/dev/null} +CACHE_DIR=${CACHE_DIR:-} +EFS_ENABLED=${EFS_ENABLED:-false} + +if [[ -z "${CACHE_DIR}" ]]; then + CACHE_DIR="/mnt/efs/${GITHUB_REPOSITORY}/.git" +fi + +latest_file="" +latest_path="" +if [[ "${EFS_ENABLED}" == "true" && -d "${CACHE_DIR}" ]]; then + CACHE_DIR=$(realpath "${CACHE_DIR}") + latest_file=$(find "${CACHE_DIR}" -maxdepth 1 -type f -name 'repository-*.tar' -exec basename {} \; | sort -t '-' -k3,3nr | head -n1 || true) + if [[ -n "${latest_file}" ]]; then + latest_path="${CACHE_DIR}/${latest_file}" + fi +fi + +if [[ -n "${latest_file}" ]]; then + cache_hit=true +else + cache_hit=false +fi + +{ + echo "enabled=${EFS_ENABLED}" + echo "cache_hit=${cache_hit}" + echo "cache_dir=${CACHE_DIR}" + echo "latest_file_name=${latest_file}" + echo "latest_file_path=${latest_path}" +} | tee -a "${GITHUB_OUTPUT}" diff --git a/checkout-with-cache/cache_restore.sh b/checkout-with-cache/cache_restore.sh new file mode 100755 index 0000000..72bb9f7 --- /dev/null +++ b/checkout-with-cache/cache_restore.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENC_KEY=${ENC_KEY:-} +CACHE_FILE=${CACHE_FILE:?CACHE_FILE is required} +WORKSPACE=${WORKSPACE:-${GITHUB_WORKSPACE:-}} + +if [[ -z "${WORKSPACE}" ]]; then + echo "Checkout cache restore skipped: WORKSPACE is not set" + exit 0 +fi + +echo "Restoring checkout cache from ${CACHE_FILE} into ${WORKSPACE}" +mkdir -p "${WORKSPACE}" + +restore_ok=false +if [[ -n "${ENC_KEY}" ]]; then + if openssl enc -aes-256-cbc -pbkdf2 -d -pass env:ENC_KEY -in "${CACHE_FILE}" | tar -xf - -C "${WORKSPACE}"; then + restore_ok=true + fi +else + if tar -xf "${CACHE_FILE}" -C "${WORKSPACE}"; then + restore_ok=true + fi +fi + +if [[ "${restore_ok}" != "true" || ! -d "${WORKSPACE}/.git" ]]; then + echo "Checkout cache restore failed or did not contain .git; falling back to a normal checkout" + rm -rf "${WORKSPACE}/.git" + exit 0 +fi + +echo "Reconciling restored checkout cache" +git -C "${WORKSPACE}" update-index --refresh || true +git -C "${WORKSPACE}" reset --hard HEAD || true diff --git a/checkout-with-cache/cache_rotation.sh b/checkout-with-cache/cache_rotation.sh new file mode 100755 index 0000000..9490049 --- /dev/null +++ b/checkout-with-cache/cache_rotation.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +CACHE_DIR=${CACHE_DIR:-} +KEEP_FILES_COUNT=${KEEP_FILES_COUNT:-5} + +if [[ -z "${CACHE_DIR}" || ! -d "${CACHE_DIR}" ]]; then + exit 0 +fi + +mapfile -t files < <(find "${CACHE_DIR}" -maxdepth 1 -type f -name 'repository-*.tar' -exec basename {} \; | sort -t '-' -k3,3nr) +if (( ${#files[@]} <= KEEP_FILES_COUNT )); then + exit 0 +fi + +for file in "${files[@]:KEEP_FILES_COUNT}"; do + rm -vf "${CACHE_DIR}/${file}" +done diff --git a/checkout-with-cache/cache_update.sh b/checkout-with-cache/cache_update.sh new file mode 100755 index 0000000..02a9b31 --- /dev/null +++ b/checkout-with-cache/cache_update.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENC_KEY=${ENC_KEY:-} +CACHE_DIR=${CACHE_DIR:-} +WORKSPACE=${WORKSPACE:-${GITHUB_WORKSPACE:-}} +FORCE_WRITE=${FORCE_WRITE:-false} + +if [[ -z "${CACHE_DIR}" || -z "${WORKSPACE}" ]]; then + echo "Checkout cache save skipped: CACHE_DIR or WORKSPACE is not set" + exit 0 +fi + +mkdir -p "${CACHE_DIR}" + +sha="$(git -C "${WORKSPACE}" rev-parse HEAD)" +match="$(find "${CACHE_DIR}" -maxdepth 1 -type f -name "repository-${sha}-*.tar" -exec basename {} \; | sort -t '-' -k3,3nr | head -n1 || true)" +if [[ -n "${match}" && "${FORCE_WRITE}" != "true" ]]; then + echo "Checkout cache for ${sha} already exists; skipping save" + exit 0 +fi + +while IFS= read -r key; do + [[ -n "${key}" ]] || continue + git -C "${WORKSPACE}" config --local --unset-all "${key}" || true +done < <(git -C "${WORKSPACE}" config --local --name-only --get-regexp '^http\..*\.extraheader$' || true) + +file_location="${CACHE_DIR}/repository-${sha}-$(date +%s).tar" +tmp_file="${file_location}.tmp" +rm -f "${tmp_file}" + +echo "Saving checkout cache to ${file_location}" +if [[ -n "${ENC_KEY}" ]]; then + tar -C "${WORKSPACE}" -cf - .git | openssl enc -aes-256-cbc -salt -pbkdf2 -pass env:ENC_KEY -out "${tmp_file}" +else + tar -C "${WORKSPACE}" -cf "${tmp_file}" .git +fi + +mv "${tmp_file}" "${file_location}" diff --git a/checkout-with-cache/is_efs_enabled.sh b/checkout-with-cache/is_efs_enabled.sh new file mode 100755 index 0000000..d599b67 --- /dev/null +++ b/checkout-with-cache/is_efs_enabled.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +GITHUB_OUTPUT=${GITHUB_OUTPUT:-/dev/null} +EFS_MOUNT_PATH=${EFS_MOUNT_PATH:-/mnt/efs} + +enabled=false +if [[ "${RUNNER_OS:-}" == "Linux" ]] && [[ -n "${RUNS_ON_RUNNER_NAME:-}" ]]; then + if [[ -n "${RUNS_ON_EFS_ID:-}" ]] || [[ -d "${EFS_MOUNT_PATH}" && -w "${EFS_MOUNT_PATH}" ]]; then + enabled=true + fi +fi + +{ + echo "enabled=${enabled}" + echo "mount_path=${EFS_MOUNT_PATH}" +} | tee -a "${GITHUB_OUTPUT}"