Debugging, Speed and Cost in GitHub Actions: Debug Logging, act, Cache Strategy and Artifact Retention
For debugging, turn on ACTIONS_STEP_DEBUG/ACTIONS_RUNNER_DEBUG for a single re-run, shorten the loop with act, build cache keys from the lock file hash with restore-keys, and keep artifacts briefly. Real cache miss, hit and restore-keys output from three act runs, a list of act's limits, the 10 GB quota and 7-day rule, retention, billing screens and a speed checklist.
Version note: The examples in this post were run with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10, actions/cache@v6 and actions/setup-dotnet@v6. GitHub's debug log archive, billing screens and repository cache limits were not run on GitHub; values were checked against the GitHub documentation and changelog as of November 2026.
Guess First
In Bookstore's CI (Continuous Integration) job, NuGet packages are cached with actions/cache; the key is built from the hash of the packages.lock.json files. On a feature branch you update a package and the lock file changes. Does that branch's first run get nothing from the cache, or does it get something?
Second question: you wrote retention-days: 120 on upload-artifact, while the repository's retention setting is the default 90 days. How many days does the artifact live?
Debug Logging: Seeing What the Log Doesn't Show
When a step fails, the log is the first place to look, but the default log doesn't show what happens inside actions. There are two separate switches. ACTIONS_STEP_DEBUG=true turns on the ::debug:: lines that actions write with core.debug() and that are normally hidden. ACTIONS_RUNNER_DEBUG=true produces the runner's own diagnostic logs; they end up in the runner-diagnostic-logs folder of the run's log archive. Both can be defined in the repository as a secret or a variable; if both exist, the secret wins. Rather than leaving them on permanently, it is better to turn them on for a single run: the "Enable debug logging" box in the "Re-run jobs" dialog of a failed run, or gh run rerun RUN_ID --debug.
With debug on, RUNNER_DEBUG=1 is defined on the runner and the runner.debug context is '1'. You can use this to run your own diagnostic steps only in debug runs:
name: api-ci-cache
on:
push:
branches: [main]
permissions:
contents: read
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- id: nuget-cache
uses: actions/cache@v6
with:
path: ${{ env.NUGET_PACKAGES }}
key: nuget-${{ runner.os }}-${{ hashFiles('api/**/packages.lock.json') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore api/Bookstore.slnx --locked-mode
- name: Build and test
run: |
dotnet build api/Bookstore.slnx --no-restore -c Release
dotnet test --project api/tests/Bookstore.Api.Tests --no-build -c Release
- name: Debug details
if: runner.debug == '1'
run: |
echo "::debug::cache-hit=${{ steps.nuget-cache.outputs.cache-hit }}"
du -sh "$NUGET_PACKAGES"
In act you get the same effect by passing secrets. I made the second run with --secret ACTIONS_STEP_DEBUG=true --secret ACTIONS_RUNNER_DEBUG=true:
[api-ci-cache/build-test] | Cache restored successfully
[api-ci-cache/build-test] | Cache restored from key: nuget-linux-b1f7a458d95b3dc4b465d4b588d09bf18e110a9fa59658004d8d3305f394d946
[api-ci-cache/build-test] ✅ Success - Main actions/cache@v6 [1.824401208s]
[api-ci-cache/build-test] ⚙ ::set-output:: cache-hit=***
[api-ci-cache/build-test] ✅ Success - Main Restore [2.071911375s]
[api-ci-cache/build-test] ✅ Success - Main Build and test [5.137234042s]
There are two things in this output. First, something that isn't there: act does not emulate debug mode. Not a single ::debug:: line appeared, and since runner.debug was not '1', the "Debug details" step never ran. act has its own --verbose flag for detailed logs, but that shows act's internals, not the actions' debug output. Really trying debug logging is something to do on GitHub. Second, an unexpected side effect: cache-hit=***. Because the secret's value was true, act masked every true in the log. GitHub also masks secret values in logs; defining the debug switch as a repository variable instead of a secret is a cleaner way to avoid surprises like this.
Shortening the Loop Locally with act, and Its Limits
I ran every workflow in this series with act first; the gain is concrete. Instead of the "commit, push, open the Actions tab, wait" loop, YAML (YAML Ain't Markup Language) errors, wrong expressions, missing outputs, job dependencies and shell script mistakes show up in seconds. act starts the job container in Docker, clones actions from GitHub and opens its own local servers for artifacts and cache. The essence of the command I used:
act push -W .github/workflows/api-ci-cache.yml \
-P ubuntu-latest=catthehacker/ubuntu:act-24.04 \
--container-architecture linux/arm64 \
--artifact-server-path ./artifacts \
--secret DEPLOY_TOKEN=local-dummy --var APP_URL=http://localhost:18320
But we saw its limits one by one in this series, and the list is not short:
- No environment protection rules, approvals or environment-scoped secrets; all secrets and variables come from a single pool (09).
- Its schema doesn't know the new syntax:
deployment: false,concurrency.queue,$/references (09, 12). - There is no OIDC (OpenID Connect) token endpoint; cloud logins and attestations can't run (10, 11).
- The runner image is not GitHub's: no
az, no preinstalled .NET SDK (Software Development Kit), a misleadingImageOS(10, 14). - The built-in artifact server rejects upload-artifact@v7 and download-artifact@v8; the local copy had to be rewritten to v4 (09).
- Actions that call the GitHub API (Application Programming Interface) fail without a token; metadata-action was the example (11).
- It is more lenient than GitHub about some errors: it ran a composite step without
shell:(12).
The right mental model: act is the unit test of your workflow logic, the run on GitHub is the integration test. It doesn't replace the second one, but it catches most of the mistakes that would otherwise reach it, earlier.
Cache Strategy: Keys, restore-keys and Scope
I ran the workflow above three times with act; the output of the cache step and the restore:
# Run 1: no cache
| Cache not found for input keys: nuget-Linux-b1f7a458d95b3dc4b465d4b588d09bf18e110a9fa59658004d8d3305f394d946, nuget-Linux-
✅ Success - Main Restore [5.005426042s]
| Cache Size: ~37 MB (39273317 B)
| Cache saved with key: nuget-Linux-b1f7a458d95b3dc4b465d4b588d09bf18e110a9fa59658004d8d3305f394d946
# Run 2: same commit, exact match
| Cache restored from key: nuget-linux-b1f7a458d95b3dc4b465d4b588d09bf18e110a9fa59658004d8d3305f394d946
✅ Success - Main Restore [2.071911375s]
| Cache hit occurred on the primary key nuget-Linux-b1f7a458d95b3dc4b465d4b588d09bf18e110a9fa59658004d8d3305f394d946, not saving cache.
# Run 3: a package added to the test project, lock file changed
| Cache restored from key: nuget-linux-b1f7a458d95b3dc4b465d4b588d09bf18e110a9fa59658004d8d3305f394d946
✅ Success - Main Restore [3.028522625s]
| Cache saved with key: nuget-Linux-19ab9ab300b96d8456426a5bebf558e1c63e82b0e2c414693623f6e5c9e0ac2a
On the first run neither the key nor the nuget-Linux- prefix was found; restore downloaded the packages from NuGet and a 37 MB cache was saved at the end of the job. The second run found an exact match, restore dropped from 5 seconds to 2, and the same key was not saved again. For the third run I added a package to the test project; the hash of the lock file, and therefore the key, changed. No exact match, but the prefix in restore-keys brought back the old cache, restore downloaded only the new package, and at the end of the job a second entry was written under the new key. Since Bookstore is a small solution the gain is in seconds; in a solution with dozens of projects or with a slow package source, the same ratio means minutes. act's cache server reports the key in lowercase in the restore message; it has no effect on behavior.
Key design rests on a few rules. The key should contain the hash of the files that determine the cached content: packages.lock.json for NuGet, package-lock.json for npm. Without a lock file, the hash of *.csproj is an approximation, but with floating package versions it can return the wrong content; that's one reason we use lock files in this series. Put the operating system (and the architecture if needed) into the key; a cache created on Linux is useless on Windows. restore-keys brings back the closest cache by prefix when there is no exact match; that is the answer to the first question: when the lock file changes the key changes, but the previous cache is restored through the nuget-Linux- prefix and restore only downloads the missing packages.
A cache entry is immutable; it is not saved a second time under the same key. On an exact match, actions/cache skips saving in its post step. That's why putting a value that changes on every run, such as github.run_id, into the key means writing a new entry on every run and filling the quota quickly.
The scope rule matters too: a run can use caches from its own branch and from the default branch; pull request runs can additionally use the base branch's. Sibling branches can't see each other's caches. The practical consequence: a job that runs regularly on the default branch "warms up" the cache, and feature branches feed from it. A cache first created on a feature branch only helps that branch's later runs.
On capacity, the default limit is 10 GB per repository; entries not accessed for more than seven days are removed, and when the limit is reached the least recently accessed ones go first. Since November 20, 2025, Pro, Team and Enterprise accounts can exceed this limit on a pay-as-you-go basis; the "cache size eviction limit" and "cache retention limit" policies are set at repository, organization and enterprise level, and if the budget runs out the cache becomes read-only. The Docker layer cache from post 11 (type=gha,mode=max) shares the same quota; with large images it can push the NuGet cache out.
Artifact Retention and Size
Artifacts are kept for 90 days by default. In repository or organization settings this can be set to 1–90 days for public repositories and 1–400 days for private ones; since October 1, 2026 the same setting also covers checks, workflow runs and statuses. The step-level retention-days can only shorten it. The answer to the second question: 90 days.
- uses: actions/upload-artifact@v7
with:
name: test-results
path: TestResults
retention-days: 7
For test results, Playwright traces and temporary build outputs, 7 days is usually enough; release packages should live not in artifacts but in a GitHub Release or a registry. Since upload-artifact v4, artifacts are immutable: you can't upload under the same name twice; if needed, overwrite: true deletes the old one and writes the new one. v7, released in February 2026, can upload a single file without zipping it via archive: false. In matrix jobs, don't forget to add the matrix value to the artifact name, or the second job fails.
Reading Billing and Usage
Minute and storage usage are on the "Usage" and "Metered usage" pages under Billing & Licensing, and can be grouped by product, repository and machine type; for personal accounts the same screen is github.com/settings/billing. At organization level there are "Actions Usage Metrics" (which workflow and job consumes how many minutes) in the Actions tab, and "Actions Performance Metrics" (queue time, run time, failure rate), GA (General Availability) since March 2025. When I read these screens I ask three questions: which workflow consumes the most minutes, where do macOS or larger runner minutes come from, and which job is frequently canceled or times out.
Speed Checklist
The order that works in Bookstore's pipelines:
Package and layer caches. NuGet and npm caches, the type=gha cache in Docker builds. Don't add without measuring, and measure after adding: if restoring the cache takes longer than it saves, you are losing.
Checkout depth. actions/checkout fetches only the last commit by default (fetch-depth: 1). Use fetch-depth: 0 only for steps that need tags, git describe or full history; in a large repository it adds minutes.
Parallel jobs and a correct dependency graph. As in post 04, lint, unit tests and the web build should not wait for each other; only the job that really needs it should wait with needs.
Path filters. In the monorepo layout from post 07, not running .NET jobs when only web/ changed is the cheapest minute saving there is.
Cancel old runs. concurrency with cancel-in-progress: true kills old CI runs on consecutive pushes to the same PR (pull request). We discussed keeping it off for deploy jobs in post 09.
Set a timeout. The default job timeout is 360 minutes; a hung test burns six hours of minutes. Bookstore's CI jobs have timeout-minutes: 15.
Keep the matrix under control. Every axis multiplies; three operating systems, three SDKs and two architectures mean 18 jobs. Run the full matrix nightly, and a single-row quick check on PRs.
In Production
Don't leave debug logging on permanently. Debug output carries actions' internal details, sometimes environment information and unmasked intermediate values into the log; in a public repository everyone reads it.
Don't entrust security to the cache. The cache poisoning topic from post 13 applies here too: in a release build, the package folder coming from cache should be validated with --locked-mode.
Watch the quota. The repository's Actions cache page lists entries, their sizes and last access times; large entries that never get a hit are a sign of bad key design.
Know the cache rate limits. There is a limit of 200 uploads and 1,500 downloads per minute per repository; if every job of a very wide matrix tries to write a cache at the same time, some saves will be dropped.
Common Pitfalls
Putting run_id or a date into the key. A new entry on every run, zero hits, a full quota.
Not providing restore-keys. Every time the lock file changes, the cache fills from scratch.
Not putting the platform in the key. Windows and Linux jobs load the same cache into each other.
Trying to extend retention with retention-days. It can't exceed the repository setting.
Calling a workflow that passes in act done. act doesn't verify environment, OIDC, image or new-syntax behavior.
Forgetting to turn debug off. If ACTIONS_STEP_DEBUG stays as a secret, every run produces verbose logs.
A Small Experiment
★☆☆ Run the api-ci-cache workflow three times with act and note the Restore duration of each run. Then remove hashFiles from the key and try again with a fixed key; what happens when a package is updated?
★★☆ Add a job summary step to the workflow: write the cache hit status, the restore duration and the size of NUGET_PACKAGES to $GITHUB_STEP_SUMMARY as a table.
★★★ In your own test repository, run the same workflow on a feature branch and on main; look at the repository's cache page to see which entry belongs to which ref and how the feature branch uses main's cache.
In the last post of the series we look back rather than forward: reading the 2019–2023 era workflows you find on the internet, recognizing the changes from set-output to GITHUB_OUTPUT and from Node 12 to Node 24, and moving an old .NET 5 workflow to .NET 10 step by step.