mirror of
https://code.forgejo.org/actions/cache.git
synced 2026-08-10 05:21:12 +00:00
Merge pull request #1 from Cula-Technologies/gcs-cache-support
Add GCS cache backend with GitHub fallback
This commit is contained in:
commit
7c01473a9b
24 changed files with 365269 additions and 283771 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
|
@ -1 +0,0 @@
|
|||
* @actions/actions-cache
|
||||
18
.github/workflows/auto-tag.yml
vendored
Normal file
18
.github/workflows/auto-tag.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Auto-tag main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Force-update v1 tag
|
||||
run: |
|
||||
git tag -f v1
|
||||
git push -f origin v1
|
||||
23
.github/workflows/close-inactive-issues.yml
vendored
23
.github/workflows/close-inactive-issues.yml
vendored
|
|
@ -1,23 +0,0 @@
|
|||
name: Close inactive issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 8 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 200
|
||||
days-before-issue-close: 5
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 200 days with no activity. Leave a comment to avoid closing this issue in 5 days."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 5 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
21
.github/workflows/issue-opened-workflow.yml
vendored
21
.github/workflows/issue-opened-workflow.yml
vendored
|
|
@ -1,21 +0,0 @@
|
|||
name: Assign issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
run-action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get current oncall
|
||||
id: oncall
|
||||
run: |
|
||||
echo "CURRENT=$(curl --request GET 'https://api.pagerduty.com/oncalls?include[]=users&schedule_ids[]=P5VG2BX&earliest=true' --header 'Authorization: Token token=${{ secrets.PAGERDUTY_TOKEN }}' --header 'Accept: application/vnd.pagerduty+json;version=2' --header 'Content-Type: application/json' | jq -r '.oncalls[].user.name')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: add_assignees
|
||||
run: |
|
||||
curl -X POST -H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN}}" https://api.github.com/repos/${{github.repository}}/issues/${{ github.event.issue.number}}/assignees -d '{"assignees":["${{steps.oncall.outputs.CURRENT}}"]}'
|
||||
43
.github/workflows/licensed.yml
vendored
43
.github/workflows/licensed.yml
vendored
|
|
@ -1,43 +0,0 @@
|
|||
name: License check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-cached-dependency-records:
|
||||
runs-on: ubuntu-latest
|
||||
name: Check licenses
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.1.7'
|
||||
|
||||
- name: Install licensed tool
|
||||
run: |
|
||||
cd "$RUNNER_TEMP"
|
||||
curl -Lfs -o licensed.tar.gz https://github.com/licensee/licensed/archive/refs/tags/v5.0.4.tar.gz
|
||||
tar -xzf licensed.tar.gz
|
||||
cd licensed-5.0.4
|
||||
bundle install
|
||||
|
||||
- name: Check cached dependency records
|
||||
run: |
|
||||
cd ${{ github.workspace }}
|
||||
BUNDLE_GEMFILE=$RUNNER_TEMP/licensed-5.0.4/Gemfile bundle exec $RUNNER_TEMP/licensed-5.0.4/exe/licensed status
|
||||
25
.github/workflows/pr-opened-workflow.yml
vendored
25
.github/workflows/pr-opened-workflow.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Assign pull request reviewer
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
run-action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get current oncall
|
||||
id: oncall
|
||||
run: |
|
||||
echo "CURRENT=$(curl --request GET 'https://api.pagerduty.com/oncalls?include[]=users&schedule_ids[]=P5VG2BX&earliest=true' --header 'Authorization: Token token=${{ secrets.PAGERDUTY_TOKEN }}' --header 'Accept: application/vnd.pagerduty+json;version=2' --header 'Content-Type: application/json' | jq -r '.oncalls[].user.name')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Request Review
|
||||
run: |
|
||||
curl -X POST -H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN}}" https://api.github.com/repos/${{github.repository}}/pulls/${{ github.event.pull_request.number}}/requested_reviewers -d '{"reviewers":["${{steps.oncall.outputs.CURRENT}}"]}'
|
||||
|
||||
- name: Add Assignee
|
||||
run: |
|
||||
curl -X POST -H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN}}" https://api.github.com/repos/${{github.repository}}/issues/${{ github.event.pull_request.number}}/assignees -d '{"assignees":["${{steps.oncall.outputs.CURRENT}}"]}'
|
||||
20
.github/workflows/publish-immutable-actions.yml
vendored
20
.github/workflows/publish-immutable-actions.yml
vendored
|
|
@ -1,20 +0,0 @@
|
|||
name: Publish immutable action
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checking out
|
||||
uses: actions/checkout@v5
|
||||
- name: Publish
|
||||
id: publish
|
||||
uses: actions/publish-immutable-action@0.0.3
|
||||
30
.github/workflows/release-new-action-version.yml
vendored
30
.github/workflows/release-new-action-version.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
name: Release new action version
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
TAG_NAME:
|
||||
description: 'Tag name that the major tag will point to'
|
||||
required: true
|
||||
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.inputs.TAG_NAME || github.event.release.tag_name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update_tag:
|
||||
name: Update the major tag to include the ${{ github.event.inputs.TAG_NAME || github.event.release.tag_name }} changes
|
||||
environment:
|
||||
name: releaseNewActionVersion
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update the ${{ env.TAG_NAME }} tag
|
||||
id: update-major-tag
|
||||
uses: actions/publish-action@v0.3.0
|
||||
with:
|
||||
source-tag: ${{ env.TAG_NAME }}
|
||||
slack-webhook: ${{ secrets.SLACK_WEBHOOK }}
|
||||
276
.github/workflows/workflow.yml
vendored
276
.github/workflows/workflow.yml
vendored
|
|
@ -1,276 +0,0 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- releases/**
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- releases/**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Build and unit test
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Setup Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Prettier Format Check
|
||||
run: npm run format-check
|
||||
- name: ESLint Check
|
||||
run: npm run lint
|
||||
- name: Build & Test
|
||||
run: npm run test
|
||||
|
||||
# End to end save and restore
|
||||
test-save:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Generate files in working directory
|
||||
shell: bash
|
||||
run: __tests__/create-cache-files.sh ${{ runner.os }} test-cache
|
||||
- name: Generate files outside working directory
|
||||
shell: bash
|
||||
run: __tests__/create-cache-files.sh ${{ runner.os }} ~/test-cache
|
||||
- name: Save cache
|
||||
uses: ./
|
||||
with:
|
||||
key: test-${{ runner.os }}-${{ github.run_id }}
|
||||
path: |
|
||||
test-cache
|
||||
~/test-cache
|
||||
|
||||
test-restore:
|
||||
needs: test-save
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore cache
|
||||
uses: ./
|
||||
with:
|
||||
key: test-${{ runner.os }}-${{ github.run_id }}
|
||||
path: |
|
||||
test-cache
|
||||
~/test-cache
|
||||
- name: Verify cache files in working directory
|
||||
shell: bash
|
||||
run: __tests__/verify-cache-files.sh ${{ runner.os }} test-cache
|
||||
- name: Verify cache files outside working directory
|
||||
shell: bash
|
||||
run: __tests__/verify-cache-files.sh ${{ runner.os }} ~/test-cache
|
||||
|
||||
# End to end with proxy
|
||||
test-proxy-save:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:latest
|
||||
options: --cap-add=NET_ADMIN
|
||||
services:
|
||||
squid-proxy:
|
||||
image: ubuntu/squid:latest
|
||||
ports:
|
||||
- 3128:3128
|
||||
env:
|
||||
http_proxy: http://squid-proxy:3128
|
||||
https_proxy: http://squid-proxy:3128
|
||||
steps:
|
||||
- name: Wait for proxy to be ready
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Waiting for squid proxy to be ready..."
|
||||
echo "Resolving squid-proxy hostname:"
|
||||
getent hosts squid-proxy || echo "DNS resolution failed"
|
||||
for i in $(seq 1 30); do
|
||||
if (echo > /dev/tcp/squid-proxy/3128) 2>/dev/null; then
|
||||
echo "Proxy is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i: Proxy not ready, waiting..."
|
||||
sleep 2
|
||||
done
|
||||
echo "Proxy failed to become ready"
|
||||
exit 1
|
||||
env:
|
||||
http_proxy: ""
|
||||
https_proxy: ""
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y iptables curl
|
||||
- name: Verify proxy is working
|
||||
run: |
|
||||
echo "Testing proxy connectivity..."
|
||||
curl -s -o /dev/null -w "%{http_code}" --proxy http://squid-proxy:3128 http://github.com || true
|
||||
echo "Proxy verification complete"
|
||||
- name: Block direct traffic (enforce proxy usage)
|
||||
run: |
|
||||
# Get the squid-proxy container IP
|
||||
PROXY_IP=$(getent hosts squid-proxy | awk '{ print $1 }')
|
||||
echo "Proxy IP: $PROXY_IP"
|
||||
|
||||
# Allow loopback traffic
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
|
||||
# Allow traffic to the proxy container
|
||||
iptables -A OUTPUT -d $PROXY_IP -j ACCEPT
|
||||
|
||||
# Allow established connections
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Allow DNS (needed for initial resolution)
|
||||
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
|
||||
|
||||
# Block all other outbound traffic (HTTP/HTTPS)
|
||||
iptables -A OUTPUT -p tcp --dport 80 -j REJECT
|
||||
iptables -A OUTPUT -p tcp --dport 443 -j REJECT
|
||||
|
||||
# Log the iptables rules for debugging
|
||||
iptables -L -v -n
|
||||
- name: Verify direct HTTPS is blocked
|
||||
run: |
|
||||
echo "Testing that direct HTTPS requests fail..."
|
||||
if curl --noproxy '*' -s --connect-timeout 5 https://github.com > /dev/null 2>&1; then
|
||||
echo "ERROR: Direct HTTPS request succeeded - blocking is not working!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: Direct HTTPS request was blocked as expected"
|
||||
fi
|
||||
|
||||
echo "Testing that HTTPS through proxy succeeds..."
|
||||
if curl --proxy http://squid-proxy:3128 -s --connect-timeout 10 https://github.com > /dev/null 2>&1; then
|
||||
echo "SUCCESS: HTTPS request through proxy succeeded"
|
||||
else
|
||||
echo "ERROR: HTTPS request through proxy failed!"
|
||||
exit 1
|
||||
fi
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Generate files
|
||||
run: __tests__/create-cache-files.sh proxy test-cache
|
||||
- name: Save cache
|
||||
uses: ./
|
||||
with:
|
||||
key: test-proxy-${{ github.run_id }}
|
||||
path: test-cache
|
||||
|
||||
test-proxy-restore:
|
||||
needs: test-proxy-save
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:latest
|
||||
options: --cap-add=NET_ADMIN
|
||||
services:
|
||||
squid-proxy:
|
||||
image: ubuntu/squid:latest
|
||||
ports:
|
||||
- 3128:3128
|
||||
env:
|
||||
http_proxy: http://squid-proxy:3128
|
||||
https_proxy: http://squid-proxy:3128
|
||||
steps:
|
||||
- name: Wait for proxy to be ready
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Waiting for squid proxy to be ready..."
|
||||
echo "Resolving squid-proxy hostname:"
|
||||
getent hosts squid-proxy || echo "DNS resolution failed"
|
||||
for i in $(seq 1 30); do
|
||||
if (echo > /dev/tcp/squid-proxy/3128) 2>/dev/null; then
|
||||
echo "Proxy is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i: Proxy not ready, waiting..."
|
||||
sleep 2
|
||||
done
|
||||
echo "Proxy failed to become ready"
|
||||
exit 1
|
||||
env:
|
||||
http_proxy: ""
|
||||
https_proxy: ""
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y iptables curl
|
||||
- name: Verify proxy is working
|
||||
run: |
|
||||
echo "Testing proxy connectivity..."
|
||||
curl -s -o /dev/null -w "%{http_code}" --proxy http://squid-proxy:3128 http://github.com || true
|
||||
echo "Proxy verification complete"
|
||||
- name: Block direct traffic (enforce proxy usage)
|
||||
run: |
|
||||
# Get the squid-proxy container IP
|
||||
PROXY_IP=$(getent hosts squid-proxy | awk '{ print $1 }')
|
||||
echo "Proxy IP: $PROXY_IP"
|
||||
|
||||
# Allow loopback traffic
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
|
||||
# Allow traffic to the proxy container
|
||||
iptables -A OUTPUT -d $PROXY_IP -j ACCEPT
|
||||
|
||||
# Allow established connections
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Allow DNS (needed for initial resolution)
|
||||
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
|
||||
|
||||
# Block all other outbound traffic (HTTP/HTTPS)
|
||||
iptables -A OUTPUT -p tcp --dport 80 -j REJECT
|
||||
iptables -A OUTPUT -p tcp --dport 443 -j REJECT
|
||||
|
||||
# Log the iptables rules for debugging
|
||||
iptables -L -v -n
|
||||
- name: Verify direct HTTPS is blocked
|
||||
run: |
|
||||
echo "Testing that direct HTTPS requests fail..."
|
||||
if curl --noproxy '*' -s --connect-timeout 5 https://github.com > /dev/null 2>&1; then
|
||||
echo "ERROR: Direct HTTPS request succeeded - blocking is not working!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: Direct HTTPS request was blocked as expected"
|
||||
fi
|
||||
|
||||
echo "Testing that HTTPS through proxy succeeds..."
|
||||
if curl --proxy http://squid-proxy:3128 -s --connect-timeout 10 https://github.com > /dev/null 2>&1; then
|
||||
echo "SUCCESS: HTTPS request through proxy succeeded"
|
||||
else
|
||||
echo "ERROR: HTTPS request through proxy failed!"
|
||||
exit 1
|
||||
fi
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Restore cache
|
||||
uses: ./
|
||||
with:
|
||||
key: test-proxy-${{ github.run_id }}
|
||||
path: test-cache
|
||||
- name: Verify cache
|
||||
run: __tests__/verify-cache-files.sh proxy test-cache
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
sources:
|
||||
npm: true
|
||||
|
||||
# Force UTF-8 encoding
|
||||
encoding: 'utf-8'
|
||||
|
||||
# Ignore problematic packages with encoding issues
|
||||
ignored:
|
||||
npm:
|
||||
- form-data
|
||||
|
||||
allowed:
|
||||
- apache-2.0
|
||||
- bsd-2-clause
|
||||
- bsd-3-clause
|
||||
- isc
|
||||
- mit
|
||||
- cc0-1.0
|
||||
- unlicense
|
||||
- 0bsd
|
||||
|
||||
reviewed:
|
||||
npm:
|
||||
- sax
|
||||
- "@protobuf-ts/plugin-framework" # Apache-2.0
|
||||
- "@protobuf-ts/runtime" # Apache-2.0
|
||||
- fs.realpath # ISC
|
||||
- glob # ISC
|
||||
- prettier # MIT
|
||||
- lodash # MIT
|
||||
- "@actions/http-client" # MIT
|
||||
13
action.yml
13
action.yml
|
|
@ -1,6 +1,6 @@
|
|||
name: 'Cache'
|
||||
description: 'Cache artifacts like dependencies and build outputs to improve workflow execution time'
|
||||
author: 'GitHub'
|
||||
name: 'Cache with GCS Support'
|
||||
description: 'Cache artifacts to Google Cloud Storage or GitHub with automatic fallback'
|
||||
author: 'danySam'
|
||||
inputs:
|
||||
path:
|
||||
description: 'A list of files, directories, and wildcard patterns to cache and restore'
|
||||
|
|
@ -26,6 +26,13 @@ inputs:
|
|||
description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache'
|
||||
default: 'false'
|
||||
required: false
|
||||
gcs-bucket:
|
||||
description: 'Google Cloud Storage bucket name to use for caching. When provided, GCS will be used as the cache backend.'
|
||||
required: false
|
||||
gcs-path-prefix:
|
||||
description: 'Optional prefix path within the GCS bucket for cache files'
|
||||
required: false
|
||||
default: 'github-cache'
|
||||
save-always:
|
||||
description: 'Run the post step to save the cache even if another step before fails'
|
||||
default: 'false'
|
||||
|
|
|
|||
159528
dist/restore-only/index.js
vendored
159528
dist/restore-only/index.js
vendored
File diff suppressed because one or more lines are too long
159528
dist/restore/index.js
vendored
159528
dist/restore/index.js
vendored
File diff suppressed because one or more lines are too long
159528
dist/save-only/index.js
vendored
159528
dist/save-only/index.js
vendored
File diff suppressed because one or more lines are too long
159528
dist/save/index.js
vendored
159528
dist/save/index.js
vendored
File diff suppressed because one or more lines are too long
10039
package-lock.json
generated
10039
package-lock.json
generated
File diff suppressed because it is too large
Load diff
42
package.json
42
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cache",
|
||||
"version": "5.0.4",
|
||||
"version": "4.2.3",
|
||||
"private": true,
|
||||
"description": "Cache dependencies and build outputs",
|
||||
"main": "dist/restore/index.js",
|
||||
|
|
@ -23,32 +23,30 @@
|
|||
"author": "GitHub",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/cache": "^5.0.5",
|
||||
"@actions/core": "^2.0.3",
|
||||
"@actions/exec": "^2.0.0",
|
||||
"@actions/io": "^2.0.0"
|
||||
"@actions/cache": "^4.0.3",
|
||||
"@actions/core": "^1.11.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/io": "^1.1.3",
|
||||
"@google-cloud/storage": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/nock": "^11.1.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@types/node": "^16.18.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest": "^27.9.0",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-circus": "^29.7.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^26.9.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"jest": "^28.1.3",
|
||||
"jest-circus": "^27.5.1",
|
||||
"nock": "^13.2.9",
|
||||
"prettier": "^3.6.2",
|
||||
"ts-jest": "^29.4.0",
|
||||
"prettier": "^2.8.0",
|
||||
"ts-jest": "^28.0.8",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: 'Restore Cache'
|
||||
description: 'Restore Cache artifacts like dependencies and build outputs to improve workflow execution time'
|
||||
author: 'GitHub'
|
||||
name: 'Restore Cache with GCS Support'
|
||||
description: 'Restore cache artifacts from Google Cloud Storage or GitHub with automatic fallback'
|
||||
author: 'danySam'
|
||||
inputs:
|
||||
path:
|
||||
description: 'A list of files, directories, and wildcard patterns to restore'
|
||||
|
|
@ -23,6 +23,13 @@ inputs:
|
|||
description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache'
|
||||
default: 'false'
|
||||
required: false
|
||||
gcs-bucket:
|
||||
description: 'Google Cloud Storage bucket name to use for caching. When provided, GCS will be used as the cache backend.'
|
||||
required: false
|
||||
gcs-path-prefix:
|
||||
description: 'Optional prefix path within the GCS bucket for cache files'
|
||||
default: 'github-cache'
|
||||
required: false
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'A boolean value to indicate an exact match was found for the primary key'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: 'Save a cache'
|
||||
description: 'Save Cache artifacts like dependencies and build outputs to improve workflow execution time'
|
||||
author: 'GitHub'
|
||||
name: 'Save Cache with GCS Support'
|
||||
description: 'Save cache artifacts to Google Cloud Storage or GitHub with automatic fallback'
|
||||
author: 'danySam'
|
||||
inputs:
|
||||
path:
|
||||
description: 'A list of files, directories, and wildcard patterns to cache'
|
||||
|
|
@ -15,6 +15,13 @@ inputs:
|
|||
description: 'An optional boolean when enabled, allows windows runners to save caches that can be restored on other platforms'
|
||||
default: 'false'
|
||||
required: false
|
||||
gcs-bucket:
|
||||
description: 'Google Cloud Storage bucket name to use for caching. When provided, GCS will be used as the cache backend.'
|
||||
required: false
|
||||
gcs-path-prefix:
|
||||
description: 'Optional prefix path within the GCS bucket for cache files'
|
||||
default: 'github-cache'
|
||||
required: false
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: '../dist/save-only/index.js'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ export enum Inputs {
|
|||
UploadChunkSize = "upload-chunk-size", // Input for cache, save action
|
||||
EnableCrossOsArchive = "enableCrossOsArchive", // Input for cache, restore, save action
|
||||
FailOnCacheMiss = "fail-on-cache-miss", // Input for cache, restore action
|
||||
LookupOnly = "lookup-only" // Input for cache, restore action
|
||||
LookupOnly = "lookup-only", // Input for cache, restore action
|
||||
GCSBucket = "gcs-bucket", // Input for cache, restore, save action
|
||||
GCSPathPrefix = "gcs-path-prefix" // Input for cache, restore, save action
|
||||
}
|
||||
|
||||
export enum Outputs {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import * as cache from "@actions/cache";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { Events, Inputs, Outputs, State } from "./constants";
|
||||
|
|
@ -8,6 +7,7 @@ import {
|
|||
StateProvider
|
||||
} from "./stateProvider";
|
||||
import * as utils from "./utils/actionUtils";
|
||||
import * as cache from "./utils/gcsCache";
|
||||
|
||||
export async function restoreImpl(
|
||||
stateProvider: IStateProvider,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import * as cache from "@actions/cache";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { Events, Inputs, State } from "./constants";
|
||||
|
|
@ -8,6 +7,7 @@ import {
|
|||
StateProvider
|
||||
} from "./stateProvider";
|
||||
import * as utils from "./utils/actionUtils";
|
||||
import * as cache from "./utils/gcsCache";
|
||||
|
||||
// Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in
|
||||
// @actions/toolkit when a failed upload closes the file descriptor causing any in-process reads to
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as cache from "@actions/cache";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { RefKey } from "../constants";
|
||||
import { Inputs, RefKey } from "../constants";
|
||||
|
||||
export function isGhes(): boolean {
|
||||
const ghUrl = new URL(
|
||||
|
|
@ -66,7 +66,42 @@ export function getInputAsBool(
|
|||
return result.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
// Check if GCS is configured and available
|
||||
export function isGCSAvailable(): boolean {
|
||||
try {
|
||||
const bucket = core.getInput(Inputs.GCSBucket);
|
||||
if (!bucket) {
|
||||
core.info(
|
||||
"GCS bucket name not provided, falling back to GitHub cache"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// We're not doing an actual authentication check here as it would require
|
||||
// making an API call. The Storage client will handle authentication later
|
||||
// via Application Default Credentials (ADC) which supports multiple auth methods:
|
||||
// - Service account JSON key file (GOOGLE_APPLICATION_CREDENTIALS)
|
||||
// - Workload Identity Federation
|
||||
// - Metadata server-based auth (GCE, GKE)
|
||||
// - User credentials from gcloud CLI
|
||||
|
||||
core.info(`GCS bucket configured: ${bucket}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logWarning(
|
||||
`Failed to check GCS availability: ${(error as Error).message}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCacheFeatureAvailable(): boolean {
|
||||
// Check if GCS cache is available
|
||||
if (isGCSAvailable()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, check GitHub cache
|
||||
if (cache.isFeatureAvailable()) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
275
src/utils/gcsCache.ts
Normal file
275
src/utils/gcsCache.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import * as cache from "@actions/cache";
|
||||
import * as utils from "@actions/cache/lib/internal/cacheUtils";
|
||||
import { CompressionMethod } from "@actions/cache/lib/internal/constants";
|
||||
import {
|
||||
createTar,
|
||||
extractTar,
|
||||
listTar
|
||||
} from "@actions/cache/lib/internal/tar";
|
||||
import { DownloadOptions, UploadOptions } from "@actions/cache/lib/options";
|
||||
import * as core from "@actions/core";
|
||||
import { Storage } from "@google-cloud/storage";
|
||||
import * as path from "path";
|
||||
|
||||
import { Inputs } from "../constants";
|
||||
import { isGCSAvailable } from "./actionUtils";
|
||||
|
||||
const DEFAULT_PATH_PREFIX = "github-cache";
|
||||
|
||||
// Function to initialize GCS client using Application Default Credentials
|
||||
function getGCSClient(): Storage | null {
|
||||
try {
|
||||
core.info("Initializing GCS client");
|
||||
return new Storage();
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Failed to initialize GCS client: ${(error as Error).message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreCache(
|
||||
paths: string[],
|
||||
primaryKey: string,
|
||||
restoreKeys?: string[],
|
||||
options?: DownloadOptions,
|
||||
enableCrossOsArchive?: boolean
|
||||
): Promise<string | undefined> {
|
||||
// Check if GCS is available
|
||||
if (isGCSAvailable()) {
|
||||
try {
|
||||
const result = await restoreFromGCS(
|
||||
paths,
|
||||
primaryKey,
|
||||
restoreKeys,
|
||||
options
|
||||
);
|
||||
|
||||
if (result) {
|
||||
core.info(`Cache restored from GCS with key: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
core.info("Cache not found in GCS, falling back to GitHub cache");
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Failed to restore from GCS: ${(error as Error).message}`
|
||||
);
|
||||
core.info("Falling back to GitHub cache");
|
||||
}
|
||||
} else {
|
||||
core.info("GCS not configured, using GitHub cache");
|
||||
}
|
||||
|
||||
// Fall back to GitHub cache
|
||||
return await cache.restoreCache(
|
||||
paths,
|
||||
primaryKey,
|
||||
restoreKeys,
|
||||
options,
|
||||
enableCrossOsArchive
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCache(
|
||||
paths: string[],
|
||||
key: string,
|
||||
options?: UploadOptions,
|
||||
enableCrossOsArchive?: boolean
|
||||
): Promise<number> {
|
||||
if (isGCSAvailable()) {
|
||||
try {
|
||||
const result = await saveToGCS(paths, key);
|
||||
if (result) {
|
||||
core.info(`Cache saved to GCS with key: [${key} | ${result}]`);
|
||||
return 1; // Success ID
|
||||
}
|
||||
|
||||
core.warning("Failed to save to GCS, falling back to GitHub cache");
|
||||
return -1;
|
||||
} catch (error) {
|
||||
core.warning(`Failed to save to GCS: ${(error as Error).message}`);
|
||||
core.info("Falling back to GitHub cache");
|
||||
}
|
||||
} else {
|
||||
core.info("GCS not configured, using GitHub cache");
|
||||
}
|
||||
|
||||
// Fall back to GitHub cache
|
||||
return await cache.saveCache(paths, key, options, enableCrossOsArchive);
|
||||
}
|
||||
|
||||
// Function that checks if the cache feature is available (either GCS or GitHub cache)
|
||||
export function isFeatureAvailable(): boolean {
|
||||
return isGCSAvailable() || cache.isFeatureAvailable();
|
||||
}
|
||||
|
||||
async function restoreFromGCS(
|
||||
_paths: string[], // validate paths?
|
||||
primaryKey: string,
|
||||
restoreKeys: string[] = [],
|
||||
options?: DownloadOptions
|
||||
): Promise<string | undefined> {
|
||||
const storage = getGCSClient();
|
||||
if (!storage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bucket = core.getInput(Inputs.GCSBucket);
|
||||
const pathPrefix =
|
||||
core.getInput(Inputs.GCSPathPrefix) || DEFAULT_PATH_PREFIX;
|
||||
const compressionMethod = await utils.getCompressionMethod();
|
||||
|
||||
const archiveFolder = await utils.createTempDirectory();
|
||||
const archivePath = path.join(
|
||||
archiveFolder,
|
||||
utils.getCacheFileName(compressionMethod)
|
||||
);
|
||||
|
||||
const keys = [primaryKey, ...restoreKeys];
|
||||
const gcsPath = await findFileOnGCS(
|
||||
storage,
|
||||
bucket,
|
||||
pathPrefix,
|
||||
keys,
|
||||
compressionMethod
|
||||
);
|
||||
|
||||
if (!gcsPath) {
|
||||
core.info(`No matching cache found`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If lookup only, just return the key
|
||||
if (options?.lookupOnly) {
|
||||
core.info(`Cache found in GCS with key: ${gcsPath}`);
|
||||
return gcsPath;
|
||||
}
|
||||
|
||||
try {
|
||||
core.info(`Downloading from GCS: ${bucket}/${gcsPath}`);
|
||||
const file = storage.bucket(bucket).file(gcsPath);
|
||||
await file.download({ destination: archivePath });
|
||||
|
||||
if (core.isDebug()) {
|
||||
await listTar(archivePath, compressionMethod);
|
||||
}
|
||||
|
||||
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath);
|
||||
core.info(
|
||||
`Cache Size: ~${Math.round(
|
||||
archiveFileSize / (1024 * 1024)
|
||||
)} MB (${archiveFileSize} B)`
|
||||
);
|
||||
|
||||
await extractTar(archivePath, compressionMethod);
|
||||
core.info("Cache restored successfully");
|
||||
|
||||
return gcsPath;
|
||||
} catch (error) {
|
||||
core.warning(`Failed to restore: ${(error as Error).message}`);
|
||||
} finally {
|
||||
try {
|
||||
await utils.unlinkFile(archivePath);
|
||||
} catch (error) {
|
||||
core.debug(`Failed to delete archive: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getGCSPath(
|
||||
pathPrefix: string,
|
||||
key: string,
|
||||
compressionMethod: CompressionMethod
|
||||
): string {
|
||||
return `${pathPrefix}/${key}.${utils.getCacheFileName(compressionMethod)}`;
|
||||
}
|
||||
|
||||
async function saveToGCS(
|
||||
paths: string[],
|
||||
key: string
|
||||
): Promise<string | undefined> {
|
||||
const storage = getGCSClient();
|
||||
if (!storage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bucket = core.getInput(Inputs.GCSBucket);
|
||||
const pathPrefix =
|
||||
core.getInput(Inputs.GCSPathPrefix) || DEFAULT_PATH_PREFIX;
|
||||
const compressionMethod = await utils.getCompressionMethod();
|
||||
|
||||
const cachePaths = await utils.resolvePaths(paths);
|
||||
core.debug("Cache Paths:");
|
||||
core.debug(`${JSON.stringify(cachePaths)}`);
|
||||
|
||||
if (cachePaths.length === 0) {
|
||||
throw new Error(
|
||||
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
|
||||
);
|
||||
}
|
||||
|
||||
const archiveFolder = await utils.createTempDirectory();
|
||||
const archivePath = path.join(
|
||||
archiveFolder,
|
||||
utils.getCacheFileName(compressionMethod)
|
||||
);
|
||||
|
||||
core.debug(`Archive Path: ${archivePath}`);
|
||||
|
||||
try {
|
||||
await createTar(archiveFolder, cachePaths, compressionMethod);
|
||||
if (core.isDebug()) {
|
||||
await listTar(archivePath, compressionMethod);
|
||||
}
|
||||
|
||||
const gcsPath = getGCSPath(pathPrefix, key, compressionMethod);
|
||||
core.info(`Uploading to GCS: ${bucket}/${gcsPath}`);
|
||||
const [file] = await storage.bucket(bucket).upload(archivePath, {
|
||||
destination: gcsPath,
|
||||
resumable: false
|
||||
});
|
||||
|
||||
return file.metadata.id;
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Error creating or uploading cache: ${(error as Error).message}`
|
||||
);
|
||||
throw new Error(
|
||||
`Error creating or uploading cache: ${(error as Error).message}`
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await utils.unlinkFile(archivePath);
|
||||
} catch (error) {
|
||||
core.debug(`Failed to delete archive: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findFileOnGCS(
|
||||
storage: Storage,
|
||||
bucket: string,
|
||||
pathPrefix: string,
|
||||
keys: string[],
|
||||
compressionMethod: CompressionMethod
|
||||
): Promise<string | undefined> {
|
||||
for (const key of keys) {
|
||||
const gcsPath = getGCSPath(pathPrefix, key, compressionMethod);
|
||||
if (await checkFileExists(storage, bucket, gcsPath)) {
|
||||
core.info(`Found file on bucket: ${bucket} with key: ${gcsPath}`);
|
||||
return gcsPath;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function checkFileExists(
|
||||
storage: Storage,
|
||||
bucket: string,
|
||||
path: string
|
||||
): Promise<boolean> {
|
||||
const [exists] = await storage.bucket(bucket).file(path).exists();
|
||||
return exists;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue