Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,19 @@ on:
# "published" (not "created") so draft releases never trigger a publish;
# matches the trigger update-homebrew.yml already uses.
types: [published]
# Allow re-publishing a tag manually if the release-triggered run fails.
# Pick the tag (or any branch) when dispatching from the Actions tab.
# The tag-driven release path: release.yml dispatches this workflow on the
# tag ref. Also used to re-publish manually if a run fails — pick the tag
# (or any branch) when dispatching from the Actions tab.
#
# Deliberately NOT workflow_call: npm trusted publishing validates the OIDC
# token's top-level workflow file, and the trusted publisher on npmjs.com is
# registered for publish.yml. A called workflow presents release.yml as the
# top-level file and npm rejects the token with a misleading E404 (this
# failed the first v0.13.0 run).
workflow_dispatch:
inputs:
ref:
description: "Git ref to publish (defaults to the current branch)"
required: false
type: string
# Called from release.yml on tag push (GITHUB_TOKEN-created releases don't
# emit events, so the release:published trigger can't fire for that path).
workflow_call:
inputs:
ref:
description: "Git ref to publish (defaults to the calling ref)"
description: "Git ref to publish (defaults to the dispatched ref)"
required: false
type: string

Expand Down
56 changes: 29 additions & 27 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
name: Release

# Pushing a v* tag drives the whole release: create the GitHub Release with
# notes from the matching CHANGELOG section, then chain the npm/MCP publish
# and the Homebrew tap update via workflow_call.
# notes from the matching CHANGELOG section, then DISPATCH the npm/MCP publish
# and the Homebrew tap update on the same tag ref.
#
# Why the explicit chaining: releases created here use GITHUB_TOKEN, and
# GITHUB_TOKEN-created events do not trigger other workflows, so the
# release:published triggers on publish.yml / update-homebrew.yml would never
# fire for tag-driven releases. Those event triggers still work for releases
# created manually in the UI; every downstream step is idempotent, so the
# occasional double-run is harmless.
# Why dispatch instead of events or workflow_call:
# - Releases created here use GITHUB_TOKEN, and GITHUB_TOKEN-created events do
# not trigger other workflows, so the release:published triggers on
# publish.yml / update-homebrew.yml never fire for tag-driven releases.
# (Those event triggers still work for releases created manually in the UI.)
# - workflow_call does not work for the npm publish: npm trusted publishing
# validates the OIDC token's top-level workflow file, which would be
# release.yml for a called workflow, but the trusted publisher on npmjs.com
# is registered for publish.yml (this failed the first v0.13.0 run with
# npm's misleading E404). An explicitly dispatched run has publish.yml as
# its top-level workflow, so the claim matches.
# - Explicit workflow_dispatch API calls ARE allowed with GITHUB_TOKEN — the
# recursion prevention only suppresses event-triggered runs.
#
# Ordering: both are dispatched immediately; update-homebrew.yml already polls
# npm for up to 5 minutes for the new version, so it tolerates the publish
# finishing after it starts. Every downstream step is idempotent.

on:
push:
tags: ["v*"]

permissions:
contents: write
id-token: write
actions: write

# NOTE: this must NOT be the "publish" group — the called publish.yml declares
# that group itself, and a reusable workflow's concurrency still applies when
# called, so parent+child sharing a group is an instant deadlock-cancel
# (bit us on the v0.13.0 tag). The child's group is what serializes actual
# publishing; this one only keeps two release runs from racing each other.
# The dispatched publish.yml serializes real publishing via its own "publish"
# concurrency group; this group only keeps two release runs from racing.
concurrency:
group: release
cancel-in-progress: false
Expand Down Expand Up @@ -61,16 +69,10 @@ jobs:
--notes-file /tmp/notes.md
fi

publish:
needs: github-release
uses: ./.github/workflows/publish.yml
permissions:
contents: read
id-token: write

homebrew:
needs: publish
uses: ./.github/workflows/update-homebrew.yml
secrets: inherit
permissions:
contents: read
- name: Dispatch publish and homebrew on this tag
env:
GH_TOKEN: ${{ github.token }}
run: |
gh workflow run publish.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
gh workflow run update-homebrew.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
echo "Dispatched publish.yml and update-homebrew.yml on $GITHUB_REF_NAME."
7 changes: 4 additions & 3 deletions .github/workflows/update-homebrew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ name: Update Homebrew Tap
on:
release:
types: [published]
# Called from release.yml on tag push (GITHUB_TOKEN-created releases don't
# emit events). GITHUB_REF_NAME is the tag in both paths.
workflow_call:
# The tag-driven release path: release.yml dispatches this workflow on the
# tag ref (GITHUB_TOKEN-created releases don't emit events). GITHUB_REF_NAME
# is the tag in both paths — dispatch on a v* tag, never a branch.
workflow_dispatch:

permissions:
contents: read
Expand Down
12 changes: 9 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,16 @@ git push --tags
`.github/workflows/release.yml` (on `v*` tags):

1. **GitHub Release** — created idempotently, with the release notes extracted from that version's CHANGELOG section. Tagging without a matching CHANGELOG section fails the run on purpose.
2. **Publish** (`publish.yml` via `workflow_call`) — npm publish with provenance (idempotent: skips if the version is already on npm), then MCP Registry publish via a pinned `mcp-publisher`.
3. **Homebrew** (`update-homebrew.yml` via `workflow_call`) — waits for the version to appear on npm, regenerates the tap formula with the new tarball sha, and pushes to `I4cTime/homebrew-tap` (skips if unchanged).
2. **Publish** (`publish.yml`, dispatched on the tag ref) — npm publish with provenance (idempotent: skips if the version is already on npm), then MCP Registry publish via a pinned `mcp-publisher`.
3. **Homebrew** (`update-homebrew.yml`, dispatched on the tag ref) — waits up to 5 minutes for the version to appear on npm, regenerates the tap formula with the new tarball sha, and pushes to `I4cTime/homebrew-tap` (skips if unchanged).

Why `workflow_call` instead of the `release: published` event for this path: the workflow creates the release with `GITHUB_TOKEN`, and GitHub does not fire workflow triggers for events created by `GITHUB_TOKEN`. The event triggers remain in place so a release created manually in the GitHub UI still publishes; every downstream step is idempotent, so overlap is harmless (a shared `publish` concurrency group serializes runs).
Why **dispatch** instead of events or `workflow_call` (both were tried and failed on the first v0.13.0 attempt):

- The release is created with `GITHUB_TOKEN`, and GitHub does not fire workflow triggers for events created by `GITHUB_TOKEN` — so the `release: published` triggers can't fire on this path. (They remain in place so a release created manually in the UI still publishes.)
- `workflow_call` breaks npm trusted publishing: npm validates the OIDC token's *top-level* workflow file, which is `release.yml` for a called workflow, but the trusted publisher on npmjs.com is registered for `publish.yml` — npm rejects the token with a misleading `E404`.
- Explicit `workflow_dispatch` API calls **are** allowed with `GITHUB_TOKEN` (recursion prevention only suppresses event-triggered runs), and a dispatched run has `publish.yml` as its top-level workflow, so the OIDC claim matches.

Also learned the hard way: `release.yml` must not share `publish.yml`'s concurrency group — a called/dispatched workflow's own group is what serializes publishing, and a parent holding the same group deadlocks. Every downstream step is idempotent, so overlap between the manual-release and tag-driven paths is harmless.

## Recovering from a failed release

Expand Down
Loading