
I set up a CI process to build a custom Docker image for a self-hosted runner and push it to ghcr.io (GitHub Container Registry). While this is a one-liner with docker/login-action on GitHub-hosted runners, it didn't work on my macOS self-hosted runner.
Error: User interaction is not allowed. (-25308)
This article explains the cause of the osxkeychain issue and the workaround I found after three attempts.
The Problem: osxkeychain Blocks Access
Docker Desktop for macOS stores credentials in the macOS Keychain. The ~/.docker/config.json file is configured with "credsStore": "desktop", so when you run docker login, Docker Desktop's credential helper accesses the Keychain.
GitHub Actions jobs run in a non-interactive session via launchd. Since there is no GUI session, access to the Keychain is denied with error -25308 (User interaction is not allowed).
This problem doesn't occur on GitHub-hosted runners because they run on Linux VMs and don't depend on the macOS Keychain.
Attempt 1: Emptying credsStore (Failed)
I thought I could bypass the credential helper by creating a job-specific DOCKER_CONFIG directory and setting credsStore to an empty string.
- name: Isolate DOCKER_CONFIG
run: |
docker_config_dir="${RUNNER_TEMP}/docker-config"
mkdir -p "$docker_config_dir"
echo '{"credsStore": ""}' > "$docker_config_dir/config.json"
echo "DOCKER_CONFIG=$docker_config_dir" >> "$GITHUB_ENV"
Result: Failure. Even with an empty credsStore, the Docker CLI still tries to find the default credential helper. On macOS, that's osxkeychain, so the same error occurred.
Attempt 2: Writing Directly to config.json (Success)
Instead of using docker login at all, I decided to Base64-encode the credentials and write them directly to config.json.
- name: Configure ghcr auth
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }}
run: |
set -euo pipefail
docker_config_dir="${RUNNER_TEMP}/docker-config"
mkdir -p "$docker_config_dir"
auth=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64)
cat > "$docker_config_dir/config.json" <<EOF
{"auths": {"ghcr.io": {"auth": "$auth"}}}
EOF
chmod 0600 "$docker_config_dir/config.json"
echo "DOCKER_CONFIG=$docker_config_dir" >> "$GITHUB_ENV"
There are three key points:
- Don't call
docker login: This completely skips the credential helper resolution process. - The
GITHUB_TOKENis short-lived: It's only valid for the duration of the job, making it acceptable to store it as a Base64-encoded string. - Use
chmod 0600: Self-hosted runners don't automatically clean up$RUNNER_TEMP, so it's important to minimize file permissions.
Cleanup
Ensure the credentials are deleted at the end of the job.
- name: Cleanup docker config
if: always()
run: rm -rf "${RUNNER_TEMP}/docker-config" || true
The if: always() condition ensures this step runs even if the job is canceled or times out. Unlike GitHub-hosted runners, self-hosted runners may use the same filesystem for subsequent jobs, making this cleanup step essential.
Attempt 3: The Step Execution Order Trap
Attempt 2 solved the authentication problem, but then a new error appeared.
Error: Docker buildx is required
I was using docker/setup-buildx-action to set up buildx, so why couldn't it be found?
The cause was the timing of the DOCKER_CONFIG setup.
# BAD: DOCKER_CONFIG is set *after* setup-buildx
- uses: docker/setup-buildx-action@v3 # ← Creates a builder in the original $DOCKER_CONFIG/buildx/
- name: Configure ghcr auth # ← Changes DOCKER_CONFIG to a new directory
run: echo "DOCKER_CONFIG=..." >> "$GITHUB_ENV"
- uses: docker/build-push-action@v6 # ← The new DOCKER_CONFIG has no builder!
setup-buildx-action saves the builder's state in $DOCKER_CONFIG/buildx/. If you change DOCKER_CONFIG after this step, the builder can no longer be found.
# GOOD: Move DOCKER_CONFIG setup *before* setup-buildx
- name: Configure ghcr auth # ← Set DOCKER_CONFIG first
- uses: docker/setup-buildx-action@v3 # ← Creates the builder in the correct DOCKER_CONFIG
- uses: docker/build-push-action@v6 # ← Refers to the builder from the same DOCKER_CONFIG
Simply reordering the steps solved the issue.
Final Workflow Configuration
image-build-and-scan:
runs-on: [self-hosted, macOS, ARM64, native, biz-dev]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
# 1. Configure authentication (set DOCKER_CONFIG first)
- name: Configure ghcr auth
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }}
run: |
docker_config_dir="${RUNNER_TEMP}/docker-config"
mkdir -p "$docker_config_dir"
auth=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64)
cat > "$docker_config_dir/config.json" <<EOF
{"auths": {"ghcr.io": {"auth": "$auth"}}}
EOF
chmod 0600 "$docker_config_dir/config.json"
echo "DOCKER_CONFIG=$docker_config_dir" >> "$GITHUB_ENV"
# 2. Set up Buildx (using the correct DOCKER_CONFIG)
- uses: docker/setup-buildx-action@v3
# 3. Build → Scan → Push
- uses: docker/build-push-action@v6
with:
push: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runner:buildcache
...
# 4. Cleanup (always run)
- name: Cleanup
if: always()
run: rm -rf "${RUNNER_TEMP}/docker-config" || true
GHCR Operations: Automating Retention
Continuously pushing images to ghcr.io can lead to an accumulation of old, untagged manifests. The same applies to BuildKit's registry cache.
I use a weekly retention job for automatic cleanup.
on:
schedule:
- cron: '0 18 * * 0' # Every Sunday at 18:00 UTC
workflow_dispatch:
inputs:
delete:
type: boolean
default: false
age-days:
type: string
default: '7'
max-delete:
type: string
default: '100'
The script lists untagged manifests via the GitHub API and deletes those older than a specified number of days.
# dry-run (default)
gh api "orgs/{org}/packages/container/{pkg}/versions" --paginate \
| jq '[.[] | select((.metadata.container.tags // []) | length == 0)]'
# Delete (only when --delete is specified)
gh api -X DELETE "orgs/{org}/packages/container/{pkg}/versions/{id}"
As a safety measure, max-delete sets a limit on the number of deletions per run. The schedule trigger always performs a dry-run; actual deletions are performed explicitly via workflow_dispatch.
Summary
| Problem | Cause | Solution |
|---|---|---|
User interaction is not allowed |
macOS Keychain blocks non-interactive sessions. | Write Base64-encoded credentials directly to config.json. |
Docker buildx is required |
Changing DOCKER_CONFIG caused the buildx state to be lost. | Move the auth setup step before setup-buildx. |
| Accumulation of untagged manifests. | Leftovers from BuildKit cache and image pushes. | Weekly retention job. |
Things that are 'obvious' on GitHub-hosted runners don't always apply to self-hosted runners. The inability to use docker/login-action is a specific issue with the macOS + launchd combination and isn't mentioned in the documentation. I hope this helps anyone facing the same problem with a similar setup.


