diff --git a/README.md b/README.md index 8b38556..9d45729 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,27 @@ Cache Action ============ -A Gitea Action that caches build files under `~/.cache/` using a key file hash. This action uses `actions/cache@v5` under the hood and automatically saves the cache when the job completes successfully. +A Gitea Action that caches build files under `~/.cache/` using a key file hash. Implements the Gitea artifact cache API directly with curl, tar, and zstd — no external actions required. ## Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| | `path` | Files/directories to cache | No | `~/.cache/` | -| `key` | Explicit cache key | No | Auto-generated from hash | -| `key-file` | File to hash for cache key generation | No | Auto-detects lockfiles | -| `restore-keys` | Fallback prefix-matched keys | No | `{os}-cache-` | +| `key-prefix` | Prefix for the cache key | Yes | — | +| `key-file` | File to hash for the cache key | Yes | — | +| `restore-keys` | Fallback prefix-matched keys | No | — | ## Outputs | Output | Description | |--------|-------------| | `cache-hit` | `true` if exact key match found, `false` otherwise | +| `cache-primary-key` | The primary key used for cache lookup | +| `cache-matched-key` | The key of the cache entry that was restored | ## Quick Start -### Basic Usage - -```yaml -name: Build with Cache -on: push - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: Actions/Cache@main - - run: echo "Cache restored, proceeding with build..." -``` - ### Rust Example ```yaml @@ -52,6 +39,7 @@ jobs: - uses: actions/checkout@v4 - uses: Actions/Cache@main with: + key-prefix: 'rust' key-file: 'Cargo.lock' path: | ~/.cache/ @@ -77,6 +65,7 @@ jobs: - uses: Actions/Cache@main id: cache with: + key-prefix: 'rust' key-file: 'Cargo.lock' - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' @@ -87,14 +76,11 @@ jobs: ## How It Works -1. **Cache Key Generation**: If no explicit `key` is provided, the action auto-generates one by: - - Hashing the specified `key-file` if provided - - Otherwise, hashing all detected lockfiles (package-lock.json, Cargo.lock, go.mod, etc.) - - Falls back to hashing all non-git files if no lockfiles found +1. **Cache Key Generation**: Combines `key-prefix` with the SHA-256 hash of `key-file` (e.g. `rust-`). -2. **Cache Restore**: Uses `actions/cache@v5` to restore the cache from `~/.cache/` +2. **Cache Restore**: Calls the Gitea artifact cache API (`/_apis/artifactcache/cache`) to find a matching entry, downloads the archive, and extracts it. -3. **Cache Save**: Automatically saves the cache when the job completes successfully (handled by `actions/cache@v5`) +3. **Cache Save**: Runs on job completion (`if: always()`), creates a zstd-compressed tar archive, reserves a cache entry via the API, uploads it, and commits it. Skipped if there was a cache hit on the primary key. ## Cache Scope @@ -102,6 +88,6 @@ The cache is scoped to the key, version, and branch. The default branch cache is ## Remarks -- Requires Gitea Actions runner version >= 2.327.1 for `actions/cache@v5` - The cache server must be enabled on your Gitea instance +- Requires `curl`, `tar`, and `sha256sum` on the runner (zstd is optional, falls back to gzip) - Maximum cache size per repository is 10GB diff --git a/action.yaml b/action.yaml index 0416098..b003e6c 100644 --- a/action.yaml +++ b/action.yaml @@ -5,13 +5,12 @@ inputs: description: 'A list of files, directories, or wildcard patterns to cache. Defaults to ~/.cache/' required: false default: '~/.cache/' - key: - description: 'An explicit key for a cache entry. Defaults to runner.os-cache-{hash}' - required: false + key-prefix: + description: 'Prefix for the cache key, combined with key-file hash' + required: true key-file: - description: 'The file to hash for generating the cache key. Used if key is not provided.' - required: false - default: '' + description: 'The file to hash for generating the cache key' + required: true restore-keys: description: 'An ordered list of prefix-matched keys for restoring stale cache' required: false @@ -20,29 +19,227 @@ outputs: cache-hit: description: 'A boolean value to indicate an exact match was found for the key' value: ${{ steps.restore-cache.outputs.cache-hit }} + cache-primary-key: + description: 'The primary key used for cache lookup' + value: ${{ steps.restore-cache.outputs.cache-primary-key }} + cache-matched-key: + description: 'The key of the cache entry that was restored' + value: ${{ steps.restore-cache.outputs.cache-matched-key }} runs: using: "composite" steps: - name: Generate cache key id: gen-key - if: inputs.key == '' shell: bash run: | - if [ -n "${{ inputs.key-file }}" ]; then - KEY_HASH=$(sha256sum "${{ inputs.key-file }}" | awk '{print $1}') - else - KEY_HASH=$(find . -type f \( -name "*.csproj" -o -name "*.sln" -o -name "package.json" -o -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "Cargo.lock" -o -name "requirements.txt" -o -name "Pipfile.lock" -o -name "go.mod" -o -name "go.sum" -o -name "pom.xml" -o -name "build.gradle*" -o -name "Gemfile.lock" -o -name "composer.lock" \) -exec sha256sum {} + 2>/dev/null | sha256sum | awk '{print $1}') - if [ "$KEY_HASH" = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ]; then - KEY_HASH=$(find . -type f -not -path './.git/*' -not -path './.cache/*' | sort | xargs sha256sum 2>/dev/null | sha256sum | awk '{print $1}') - fi - fi - echo "cache-key=${{ runner.os }}-cache-${KEY_HASH}" >> $GITHUB_OUTPUT + KEY_HASH=$(sha256sum "${{ inputs.key-file }}" | awk '{print $1}') + echo "cache-key=${{ inputs.key-prefix }}-${KEY_HASH}" >> $GITHUB_OUTPUT - name: Restore cache id: restore-cache - uses: actions/cache@v5 - with: - path: ${{ inputs.path }} - key: ${{ inputs.key != '' && inputs.key || steps.gen-key.outputs.cache-key }} - restore-keys: ${{ inputs.restore-keys != '' && inputs.restore-keys || format('{0}-cache-', runner.os) }} + shell: bash + env: + CACHE_KEY: ${{ steps.gen-key.outputs.cache-key }} + RESTORE_KEYS: ${{ inputs.restore-keys }} + CACHE_PATH: ${{ inputs.path }} + run: | + CACHE_URL="${ACTIONS_CACHE_URL:-${ACTIONS_RUNTIME_URL:-}}" + TOKEN="${ACTIONS_RUNTIME_TOKEN:-}" + if [ -z "$CACHE_URL" ] || [ -z "$TOKEN" ]; then + echo "::warning::Cache environment variables not set. Skipping cache restore." + echo "cache-hit=false" >> $GITHUB_OUTPUT + echo "cache-primary-key=${CACHE_KEY}" >> $GITHUB_OUTPUT + echo "cache-matched-key=" >> $GITHUB_OUTPUT + exit 0 + fi + + CACHE_URL="${CACHE_URL%/}/" + API_BASE="${CACHE_URL}_apis/artifactcache/" + + EXPANDED_PATH=$(eval echo "$CACHE_PATH") + PATHS_HASH=$(echo "$EXPANDED_PATH" | sha256sum | awk '{print $1}') + CACHE_VERSION="zstd-${PATHS_HASH}" + + PRIMARY_KEY="${CACHE_KEY}" + + KEYS="$PRIMARY_KEY" + if [ -n "$RESTORE_KEYS" ]; then + while IFS= read -r rk; do + if [ -n "$rk" ]; then + KEYS="${KEYS},${rk}" + fi + done <<< "$RESTORE_KEYS" + fi + + ENCODED_KEYS=$(echo "$KEYS" | sed 's/,/%2C/g') + + echo "Looking up cache with keys: ${KEYS}" + + HTTP_CODE=$(mktemp) + CACHE_ENTRY=$(mktemp) + curl -sL -w "%{http_code}" -o "$CACHE_ENTRY" \ + -H "Authorization: Bearer ${TOKEN}" \ + "${API_BASE}cache?keys=${ENCODED_KEYS}&version=${CACHE_VERSION}" \ + 2>/dev/null > "$HTTP_CODE" || true + + STATUS=$(cat "$HTTP_CODE") + rm -f "$HTTP_CODE" + + if [ "$STATUS" = "204" ] || [ "$STATUS" = "404" ] || [ ! -s "$CACHE_ENTRY" ]; then + echo "Cache not found for keys: ${KEYS}" + rm -f "$CACHE_ENTRY" + echo "cache-hit=false" >> $GITHUB_OUTPUT + echo "cache-primary-key=${PRIMARY_KEY}" >> $GITHUB_OUTPUT + echo "cache-matched-key=" >> $GITHUB_OUTPUT + exit 0 + fi + + ARCHIVE_LOCATION=$(grep -o '"archiveLocation":"[^"]*"' "$CACHE_ENTRY" | head -1 | cut -d'"' -f4) + MATCHED_KEY=$(grep -o '"cacheKey":"[^"]*"' "$CACHE_ENTRY" | head -1 | cut -d'"' -f4) + rm -f "$CACHE_ENTRY" + + if [ -z "$ARCHIVE_LOCATION" ]; then + echo "Cache not found (no archive location)" + echo "cache-hit=false" >> $GITHUB_OUTPUT + echo "cache-primary-key=${PRIMARY_KEY}" >> $GITHUB_OUTPUT + echo "cache-matched-key=" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Cache found with key: ${MATCHED_KEY}" + + EXACT_HIT="false" + if [ "$PRIMARY_KEY" = "$MATCHED_KEY" ]; then + EXACT_HIT="true" + fi + + TMPDIR=$(mktemp -d) + ARCHIVE_PATH="${TMPDIR}/cache.tar.zst" + + echo "Downloading cache archive..." + curl -sL -o "$ARCHIVE_PATH" "$ARCHIVE_LOCATION" + + echo "Extracting cache..." + mkdir -p "$EXPANDED_PATH" + + if command -v zstd &>/dev/null; then + tar -I zstd -xf "$ARCHIVE_PATH" -C "/" 2>/dev/null || true + else + tar -xf "$ARCHIVE_PATH" -C "/" 2>/dev/null || true + fi + + rm -rf "$TMPDIR" + + echo "cache-hit=${EXACT_HIT}" >> $GITHUB_OUTPUT + echo "cache-primary-key=${PRIMARY_KEY}" >> $GITHUB_OUTPUT + echo "cache-matched-key=${MATCHED_KEY}" >> $GITHUB_OUTPUT + + - name: Save cache + if: always() + shell: bash + env: + CACHE_KEY: ${{ steps.gen-key.outputs.cache-key }} + CACHE_PATH: ${{ inputs.path }} + CACHE_HIT: ${{ steps.restore-cache.outputs.cache-hit }} + run: | + CACHE_URL="${ACTIONS_CACHE_URL:-${ACTIONS_RUNTIME_URL:-}}" + TOKEN="${ACTIONS_RUNTIME_TOKEN:-}" + + if [ -z "$CACHE_URL" ] || [ -z "$TOKEN" ]; then + echo "::warning::Cache environment variables not set. Skipping cache save." + exit 0 + fi + + CACHE_URL="${CACHE_URL%/}/" + API_BASE="${CACHE_URL}_apis/artifactcache/" + + EXPANDED_PATH=$(eval echo "$CACHE_PATH") + PATHS_HASH=$(echo "$EXPANDED_PATH" | sha256sum | awk '{print $1}') + CACHE_VERSION="zstd-${PATHS_HASH}" + + PRIMARY_KEY="${CACHE_KEY}" + + if [ "$CACHE_HIT" = "true" ]; then + echo "Cache hit on primary key, skipping save." + exit 0 + fi + + echo "Checking if cache directory has content..." + if [ ! -d "$EXPANDED_PATH" ] || [ -z "$(ls -A "$EXPANDED_PATH" 2>/dev/null)" ]; then + echo "Cache directory is empty, skipping save." + exit 0 + fi + + TMPDIR=$(mktemp -d) + REL_PATH="$(echo "$EXPANDED_PATH" | sed 's|^/||')" + + ARCHIVE_PATH="${TMPDIR}/cache.tar.zst" + COMPRESS_METHOD="zstd" + + if command -v zstd &>/dev/null; then + tar -I zstd -cf "$ARCHIVE_PATH" -C "/" "$REL_PATH" 2>/dev/null || COMPRESS_METHOD="none" + else + COMPRESS_METHOD="none" + fi + + if [ "$COMPRESS_METHOD" = "none" ]; then + echo "zstd not available, trying gzip..." + ARCHIVE_PATH="${TMPDIR}/cache.tar.gz" + tar -czf "$ARCHIVE_PATH" -C "/" "$REL_PATH" 2>/dev/null || { + echo "Failed to create archive" + rm -rf "$TMPDIR" + exit 0 + } + fi + + ARCHIVE_SIZE=$(stat -c%s "$ARCHIVE_PATH" 2>/dev/null || stat -f%z "$ARCHIVE_PATH" 2>/dev/null || echo "0") + + echo "Reserving cache with key: ${PRIMARY_KEY} (size: ${ARCHIVE_SIZE} bytes)" + + RESERVE_RESPONSE=$(mktemp) + curl -sL -o "$RESERVE_RESPONSE" -w "%{http_code}" \ + -X POST \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"key\":\"${PRIMARY_KEY}\",\"version\":\"${CACHE_VERSION}\",\"cacheSize\":${ARCHIVE_SIZE}}" \ + "${API_BASE}caches" 2>/dev/null > /dev/null || echo "000" + + CACHE_ID=$(grep -o '"cacheId":[0-9]*' "$RESERVE_RESPONSE" | head -1 | cut -d':' -f2) + rm -f "$RESERVE_RESPONSE" + + if [ -z "$CACHE_ID" ]; then + echo "No cache ID returned, cache may already exist or reservation failed" + rm -rf "$TMPDIR" + exit 0 + fi + + echo "Uploading cache (ID: ${CACHE_ID})..." + UPLOAD_STATUS=$(curl -sL -o /dev/null -w "%{http_code}" \ + -X PATCH \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${ARCHIVE_PATH}" \ + "${API_BASE}caches/${CACHE_ID}" 2>/dev/null || echo "000") + + if [ "$UPLOAD_STATUS" != "200" ]; then + echo "Upload failed with status: ${UPLOAD_STATUS}" + rm -rf "$TMPDIR" + exit 0 + fi + + echo "Committing cache..." + COMMIT_STATUS=$(curl -sL -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"size\":${ARCHIVE_SIZE}}" \ + "${API_BASE}caches/${CACHE_ID}" 2>/dev/null || echo "000") + + rm -rf "$TMPDIR" + + if [ "$COMMIT_STATUS" = "200" ]; then + echo "Cache saved successfully" + else + echo "Commit failed with status: ${COMMIT_STATUS}" + fi