プログラマーがデジタルなワークフロー上で credsStore を操作し、osxkeychain エラーを回避して GHCR へのプッシュを試行錯誤する様子を描いたイラスト。macOS Self-hosted Runner 環境における認証情報の問題を解決するプロセスを示しています。

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:

  1. Don't call docker login: This completely skips the credential helper resolution process.
  2. The GITHUB_TOKEN is short-lived: It's only valid for the duration of the job, making it acceptable to store it as a Base64-encoded string.
  3. 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.

References