I have an open-source MCP server. I tag a release, push, GitHub Actions builds, npm publishes, MCP Registry updates. That's the contract. It worked for v2.7.6 through v2.8.4.
Then v2.8.5 didn't publish. Neither did v2.8.6. Or v2.9.0. Or v2.9.1. Or v2.9.2. Or v2.9.3.
Six releases stuck. Not failing — stuck. Yellow dot. Forever.
Here's what was actually happening. And how I got the releases out without GitHub Actions.
The symptom that doesn't match any docs
Every release event triggered the workflow. Every workflow showed up in the runs list. None of them ever started a job.
$ gh run view 25001890100 --json status,conclusion,jobs
{
"status": "queued",
"conclusion": null,
"jobs": []
}
No conclusion. No jobs. Empty pending_deployments. Not "waiting for approval". Not "in_progress". Not "failure". Just pending with no work scheduled — for 125 hours.
If you search "GitHub Actions stuck pending", you'll find a hundred forum posts. Every answer assumes one of:
- You hit the runner concurrency limit (3 for free-tier macos)
- You have a deployment environment requiring approval
- Your
runs-on:label is unreachable - You're using self-hosted runners with no online agents
None of those applied. My workflow was simple, no environments with required reviewers, runs-on: macos-latest, no self-hosted runners.
The thing GitHub doesn't tell you in the run UI
The runs list shows pending. The run detail page shows pending. The job list shows nothing. The "deployment" tab shows nothing.
But if you look at your billing dashboard, there's a different story:
Your account has used 100% of included macOS minutes for this billing period.
That's it. That's the entire diagnostic. There is no banner on the run page. The workflow doesn't fail with a clear error. It just sits in the queue forever — because the runner that would pick it up doesn't exist, and the queue doesn't time out events.
The minutes counter resets monthly. Until it does, every release event becomes another silent pending row.
Two facts that surprised me
Fact 1: macOS runners cost 10x more than Linux runners. Both runs-on: macos-latest and runs-on: macos-13 charge against your Actions minutes at a 10x multiplier. The free 2,000 minutes/month gets you 200 minutes of macOS — about 20 release builds if each takes 10 minutes.
Fact 2: Switching to Linux didn't fix it. I changed runs-on: macos-latest to runs-on: ubuntu-latest. Same symptom. 0 jobs queued, status "pending". Why?
The macOS minutes meter is one bucket. The Linux meter is another. When the macOS bucket emptied, my pending macOS runs were still in the queue, blocking new runs. Even after switching the workflow to ubuntu, the concurrency group in the YAML serialized everything:
concurrency:
group: publish
cancel-in-progress: false
So new ubuntu runs queued behind old stuck macOS runs and never started.
The two-part fix
Part 1: workflow_dispatch with tag input
Adding a manual trigger lets you re-publish a tag whose release-event run got stuck, without deleting and recreating the GitHub Release:
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Tag to publish (e.g. v2.9.3)"
required: true
type: string
In every step that needs the tag, fall back through both event types:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref_name }}
That alone isn't enough — if the runner pool is still empty, the dispatched run also stalls. But it gives you a clean re-trigger path the moment runners are back.
Part 2: portable runner OS
The workflow downloaded mcp-publisher_darwin_${ARCH}.tar.gz — hardcoded "darwin". Switching to ubuntu broke that step. Generalize:
- name: Download mcp-publisher
run: |
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then ARCH=amd64; fi
if [ "$ARCH" = "aarch64" ]; then ARCH=arm64; fi
curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}_${ARCH}.tar.gz" -o mcp-publisher.tar.gz
tar -xzf mcp-publisher.tar.gz mcp-publisher
Now the same step works on macOS-arm64, macOS-x86_64, ubuntu-x86_64, and any future runner.
The manual workaround that actually shipped the release
While the workflow stays stuck, here's how I got v2.9.3 to npm and the MCP Registry from my laptop:
npm: the easy part
git checkout v2.9.3
npm publish --provenance --access public
--provenance requires a valid OIDC token, which only works inside GitHub Actions. Skip it locally:
npm publish --access public
You lose the provenance attestation, but the package ships. Provenance is a nice-to-have, not a publish blocker.
MCP Registry: the trickier part
The MCP Registry's CLI authenticates interactively:
mcp-publisher login github
# Opens a browser, asks you to paste a code, etc.
That's fine for humans. For a script — or for a Claude session running headless — you need non-interactive auth. The mcp-publisher binary accepts -token:
GH_TOKEN=$(gh auth token)
mcp-publisher login github -token "$GH_TOKEN"
mcp-publisher publish
The gh CLI you already use for everything else? Its token works as your GitHub PAT for mcp-publisher. No browser, no copy-paste.
After running these, the MCP Registry's io.github.achiya-automation/safari-mcp v2.9.3 went from "stuck on v2.7.6 for 3 weeks" to isLatest: true in about 15 seconds.
What I'd tell past-me
- Check the billing dashboard *first* when an Actions run sits pending with no error. The run UI does not surface "you're out of minutes". The billing page does.
-
Don't trust
runs-on: ubuntu-latestto "just be cheaper" — it is, but if you've burned your macOS minutes on stalled runs, the queue can still serialize new ones behind dead ones via yourconcurrency:group. - Keep a manual publish path documented. Both npm and the MCP Registry have non-interactive auth options. Write the bash one-liners somewhere your future self can find them at 2am.
-
workflow_dispatchwith a tag input is cheap insurance. It costs you 6 lines of YAML and saves you from needing to delete-and-recreate GitHub Releases when the release-event run gets corrupted.
FAQ
Why didn't a timeout-minutes: rescue me?
That's a job-level timeout. It applies once a job starts. A run that never starts a job has nothing to time out.
Couldn't I have used a self-hosted runner?
Yes — and that's the right answer for high-volume projects. For an OSS hobby project, self-hosted is operationally heavier than the manual publish path.
Doesn't --provenance matter for supply-chain security?
For widely-installed packages, yes. For an OSS project's own emergency-publish workaround, the trade-off is "ship the release without provenance" vs "ship nothing". Pick the first one and re-publish with provenance on the next clean release.
Could I have known about the billing limit before hitting it?
GitHub does send an email when you cross 75% of your minutes. The email goes to the address on your billing account, which may not be the address you watch. Worth setting up a filter.
What about Actions minutes for OSS public repos?
GitHub gives unlimited minutes to public repos using GitHub-hosted runners — but that's only for repos owned by organizations on the Free plan, with the runner type matching the included unlimited tier. For personal accounts and certain runner combinations, the standard quota applies. Check the actual numbers under Settings → Billing → Plans for your specific account type.
If you've hit a similar stuck-pending pattern with no error in the run UI — that's the bug. Check your minutes. Then ship from your laptop.
The repo (with the workflow that handles all this now) is safari-mcp on GitHub.
Top comments (0)