Skip to content

Commit d6ae683

Browse files
ci: fix release push race and hotfix mirror tagging (#1781)
* ci: serialize master writers and retry release pushes to fix hotfix race The Hotfix Release run for PR #1780 (run 28998970128) aborted inside python-semantic-release with "Upstream branch 'origin/master' has changed!": sync-tool-docs.yml pushed its doc-sync commit while the release was mid-run, and the action's pre-push freshness check is not retryable from inside the workflow. Because the job checked out the event's frozen merge_commit_sha, re-runs re-fetched an already-advanced master and were doomed to the same abort. - Put every workflow that pushes to master (hotfix-release, semver-release, sync-tool-docs, addon-publish-dev via _update-addon-config) in one master-write concurrency group with cancel-in-progress: false, so no bot push can land while a release's semantic-release is running. - hotfix-release now checks out master by name instead of the pinned merge_commit_sha: still an attached branch (the PR #1090 constraint), but re-runs recover and post-merge bot commits are already in the base. The redundant manual attach step is dropped. - The plain changelog/config pushes we own get the sync-tool-docs rebase-and-retry loop as defense in depth (covers manual dispatches that bypass the group). Known tradeoff, documented in the workflow comments: GitHub queues only one pending run per group, so a three-way coincidence (hotfix merge touching tools) can cancel one queued bot run; both bot workflows are workflow_dispatch-recoverable and self-heal on the next merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: tag integration mirror on hotfix releases too sync-integration-mirror.yml only tagged the HACS mirror after a "SemVer Release" workflow_run, so a hotfix that bumps the component version copied the code to the mirror's main but never created the version tag - HACS users stayed pinned to the previous release (bit component 1.0.1 after PR #1780). The tag step is already idempotent (skips when the manifest version is tagged), so hotfixes without a component change are a no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(internal): drop dead version_variables from semantic-release config src/ha_mcp/__init__.py assigns __version__ = get_version() (resolved at runtime from package metadata), so the version_variables pattern has matched nothing since that change (num_matches=0 in release logs). version_toml on pyproject.toml is the single bump source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: queue master-write runs FIFO and guard retry rebases Review follow-up (Patch76 on #1781). Concern 1: with the default single-slot concurrency queue, a newly queued run EVICTS the older pending one — so a queued release sitting behind an in-progress bot writer could be silently dropped when a later bot run queues. Add queue: max (GA 2026-05-07, valid because cancel-in-progress is false) to all four master-write blocks: up to 100 pending runs wait FIFO, no eviction. This also corrects the sync-tool-docs comment that described the opposite eviction direction. Minor: the retry-rebase loops now abort a conflicted/failed rebase and surface the real error instead of leaving rebase-in-progress state for the remaining attempts to trip over. Also refreshed the stale sync-tool-docs loop comment (the mechanism is now the shared group, not cancel-in-progress). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: skip mirror sync on non-success release runs; serialize pushes Review follow-up (Patch76 on #1781). Concern 2: adding Hotfix Release to the workflow_run trigger means the mirror sync now fires on a completed run of a workflow that is skipped on every non-hotfix PR merge (the release job is if-guarded). Without a job-level guard the clone/snapshot/push stages ran on effectively every merge, and a real hotfix produced two near-simultaneous mirror pushes (push leg + workflow_run leg) with no serialization -- the second a non-fast-forward rejection that fails the job before it can tag. Guard the job with "event_name != 'workflow_run' || workflow_run conclusion == 'success'" (also fixes the pre-existing odd-week/no-change SemVer case), and add a mirror-sync concurrency group (cancel-in-progress: false, queue: max) so the two legs of a real hotfix serialize instead of racing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: replace vacuous hotfix origin check with a stable-tag existence guard Review follow-up (Patch76 on #1781). The "Validate hotfix is based on stable" step ran post-merge, where HEAD is master's tip. Because master is never rewritten, the stable tag is always an ancestor of HEAD, so `git merge-base --is-ancestor stable HEAD` can never fail -- the ancestor logic (both exit-1 fallbacks) was unreachable dead code. Verified against git-scm merge-base exit semantics and actions/checkout ref behavior. It was equally vacuous under the previous merge_commit_sha checkout. Real hotfix-origin validation already happens PRE-merge in pr-validate-hotfix.yml, which checks out the unmerged branch head. Rather than delete the whole step (which would silently drop its one reachable behavior -- the missing-stable-tag hard-fail, which pr-validate-hotfix.yml does NOT cover: it only warns-and-skips), replace the dead ancestor logic with a one-line stable-tag existence guard and keep the hard-fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ff1dcbf commit d6ae683

7 files changed

Lines changed: 170 additions & 49 deletions

File tree

.github/workflows/_update-addon-config.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ jobs:
9292
echo "No changes to config.yaml, skipping commit"
9393
else
9494
git commit -m "chore(addon): ${{ inputs.commit_message_prefix }} $VERSION [skip ci]"
95-
git push "https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:master
96-
echo "✓ Updated addon config.yaml to $VERSION"
95+
# Master can advance between this job's checkout and its push
96+
# (bot commits from other workflows); retry with a rebase instead
97+
# of failing the publish. Mirrors the loop in sync-tool-docs.yml.
98+
for attempt in 1 2 3 4 5; do
99+
if git push "https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:master; then
100+
echo "✓ Updated addon config.yaml to $VERSION (attempt ${attempt})"
101+
exit 0
102+
fi
103+
echo "Push rejected on attempt ${attempt}; rebasing onto origin/master and retrying."
104+
# Abort a conflicted rebase so later attempts fail for the real reason.
105+
git pull --rebase origin master || {
106+
git rebase --abort 2>/dev/null || true
107+
echo "::error::Rebase onto origin/master failed; aborting retries."
108+
exit 1
109+
}
110+
done
111+
echo "::error::Could not push addon config.yaml update after 5 attempts."
112+
exit 1
97113
fi

.github/workflows/addon-publish-dev.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ env:
1818
permissions:
1919
contents: read
2020

21+
# Serialize with every other workflow that pushes to master (this one pushes
22+
# the dev add-on config bump via _update-addon-config.yml): a push landing
23+
# while a release workflow's semantic-release is mid-run aborts that release
24+
# un-retryably ("Upstream branch ... has changed").
25+
concurrency:
26+
group: master-write
27+
cancel-in-progress: false
28+
# queue: max keeps every queued run waiting FIFO. The default single-slot
29+
# queue evicts the OLDER pending run when a new one queues, which could
30+
# silently drop a queued release behind a bot push.
31+
queue: max
32+
2133
jobs:
2234
# Skip if commit is from semantic-release
2335
check-skip:

.github/workflows/hotfix-release.yml

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ on:
77
types: [closed]
88
branches: [master, main]
99

10+
# Serialize with every other workflow that pushes to master (sync-tool-docs,
11+
# addon-publish-dev, semver-release). semantic-release's internal push aborts
12+
# hard when origin/master moves mid-run ("Upstream branch ... has changed") and
13+
# that abort is not retryable from inside the action, so the only safe fix is
14+
# to keep master writers from overlapping. cancel-in-progress stays false: a
15+
# queued release must run, not be cancelled.
16+
concurrency:
17+
group: master-write
18+
cancel-in-progress: false
19+
# queue: max keeps every queued run waiting FIFO. The default single-slot
20+
# queue evicts the OLDER pending run when a new one queues, which could
21+
# silently drop a queued release behind a bot push.
22+
queue: max
23+
1024
env:
1125
PYTHON_VERSION: "3.13"
1226

@@ -28,46 +42,38 @@ jobs:
2842
issues: write
2943

3044
steps:
31-
- name: Checkout merged hotfix on master
45+
- name: Checkout master tip
3246
uses: actions/checkout@v7
3347
with:
34-
# Check out the merge commit so the workspace matches master's history;
35-
# then attach to a local master branch below. python-semantic-release
36-
# matches the active branch against [tool.semantic_release].branch
37-
# in pyproject.toml — a detached HEAD checkout (e.g., of head.sha)
38-
# fails silently with "Detached HEAD state cannot match any release
39-
# groups; no release will be made", which is what broke PR #1090.
40-
ref: ${{ github.event.pull_request.merge_commit_sha }}
48+
# Check out master BY NAME, not the event's frozen merge_commit_sha:
49+
# a named branch ref gives an attached local master branch, which
50+
# python-semantic-release matches against [tool.semantic_release].branch
51+
# (a detached HEAD checkout of a bare SHA "cannot match any release
52+
# groups", which is what broke PR #1090). Using the live tip also folds
53+
# in any [skip ci] bot commits that landed right after the merge and —
54+
# unlike the pinned SHA — lets a re-run of a failed release recover
55+
# instead of aborting forever on "Upstream branch ... has changed".
56+
ref: master
4157
fetch-depth: 0
4258
token: ${{ secrets.GITHUB_TOKEN }}
4359

44-
- name: Attach to master branch locally
45-
run: git checkout -B master HEAD
46-
47-
- name: Validate hotfix is based on stable
60+
- name: Require stable tag exists
4861
run: |
49-
# Get the stable tag (moving tag that points to latest stable)
50-
if ! git rev-parse stable &>/dev/null; then
51-
echo "::error::No 'stable' tag found. Cannot validate hotfix base."
62+
# Hotfix ORIGIN (branch based on the stable tag, not master) is validated
63+
# PRE-merge in pr-validate-hotfix.yml, which checks out the unmerged
64+
# branch head. Doing it here is impossible: post-merge HEAD is master's
65+
# tip, and because master is never rewritten, `stable` is ALWAYS an
66+
# ancestor of it — so `merge-base --is-ancestor stable HEAD` can never
67+
# fail. The former ancestor check here was therefore dead code. The one
68+
# thing still worth guarding is that `stable` exists at all: a hotfix
69+
# must have been based on it, and the release below repoints it. (This
70+
# missing-tag guard is not redundant with pr-validate-hotfix.yml, which
71+
# only warns-and-skips when the tag is absent.)
72+
if ! git rev-parse -q --verify refs/tags/stable >/dev/null; then
73+
echo "::error::No 'stable' tag found — a hotfix must be based on the stable release."
5274
exit 1
5375
fi
54-
55-
STABLE_COMMIT=$(git rev-parse stable)
56-
HOTFIX_BASE=$(git merge-base $STABLE_COMMIT HEAD)
57-
58-
# The hotfix should be based on or after the stable tag
59-
if git merge-base --is-ancestor $STABLE_COMMIT HEAD; then
60-
echo "✓ Hotfix is based on stable tag"
61-
else
62-
# Check if stable is ancestor of hotfix base
63-
if git merge-base --is-ancestor $HOTFIX_BASE $STABLE_COMMIT; then
64-
echo "✓ Hotfix is based on a commit at or before stable"
65-
else
66-
echo "::error::Hotfix contains commits from master that are not in stable release"
67-
echo "Hotfix must be branched from the 'stable' tag"
68-
exit 1
69-
fi
70-
fi
76+
echo "✓ stable tag present"
7177
7278
- name: Run semantic-release
7379
id: semantic
@@ -111,7 +117,24 @@ jobs:
111117
git config user.email "github-actions[bot]@users.noreply.github.com"
112118
git add homeassistant-addon/CHANGELOG.md
113119
git diff --staged --quiet || git commit -m "chore(addon): sync changelog for Home Assistant add-on [skip ci]"
114-
git push origin HEAD:master
120+
# Master can advance between checkout and this push (bot commits from
121+
# other workflows); retry with a rebase instead of failing the release.
122+
# Mirrors the loop in sync-tool-docs.yml.
123+
for attempt in 1 2 3 4 5; do
124+
if git push origin HEAD:master; then
125+
echo "Synced addon changelog (attempt ${attempt})."
126+
exit 0
127+
fi
128+
echo "Push rejected on attempt ${attempt}; rebasing onto origin/master and retrying."
129+
# Abort a conflicted rebase so later attempts fail for the real reason.
130+
git pull --rebase origin master || {
131+
git rebase --abort 2>/dev/null || true
132+
echo "::error::Rebase onto origin/master failed; aborting retries."
133+
exit 1
134+
}
135+
done
136+
echo "::error::Could not push addon changelog sync after 5 attempts."
137+
exit 1
115138
116139
- name: Update stable git tag
117140
if: steps.semantic.outputs.released == 'true'

.github/workflows/semver-release.yml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ on:
1717
permissions:
1818
contents: read
1919

20+
# Serialize with every other workflow that pushes to master (sync-tool-docs,
21+
# addon-publish-dev, hotfix-release). semantic-release's internal push aborts
22+
# hard when origin/master moves mid-run ("Upstream branch ... has changed") and
23+
# that abort is not retryable from inside the action, so the only safe fix is
24+
# to keep master writers from overlapping. cancel-in-progress stays false: a
25+
# queued release must run, not be cancelled.
26+
concurrency:
27+
group: master-write
28+
cancel-in-progress: false
29+
# queue: max keeps every queued run waiting FIFO. The default single-slot
30+
# queue evicts the OLDER pending run when a new one queues, which could
31+
# silently drop a queued release behind a bot push.
32+
queue: max
33+
2034
env:
2135
PYTHON_VERSION: "3.13"
2236

@@ -177,7 +191,24 @@ jobs:
177191
git config user.email "github-actions[bot]@users.noreply.github.com"
178192
git add homeassistant-addon/CHANGELOG.md
179193
git diff --staged --quiet || git commit -m "chore(addon): sync changelog for Home Assistant add-on [skip ci]"
180-
git push
194+
# Master can advance between checkout and this push (bot commits from
195+
# other workflows); retry with a rebase instead of failing the release.
196+
# Mirrors the loop in sync-tool-docs.yml.
197+
for attempt in 1 2 3 4 5; do
198+
if git push; then
199+
echo "Synced addon changelog (attempt ${attempt})."
200+
exit 0
201+
fi
202+
echo "Push rejected on attempt ${attempt}; rebasing onto origin/master and retrying."
203+
# Abort a conflicted rebase so later attempts fail for the real reason.
204+
git pull --rebase origin master || {
205+
git rebase --abort 2>/dev/null || true
206+
echo "::error::Rebase onto origin/master failed; aborting retries."
207+
exit 1
208+
}
209+
done
210+
echo "::error::Could not push addon changelog sync after 5 attempts."
211+
exit 1
181212
182213
183214
- name: Update stable git tag

.github/workflows/sync-integration-mirror.yml

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,37 @@ on:
2727
- "scripts/build_mirror_readme.py"
2828
- ".github/integration-mirror-workflows/**"
2929
workflow_run:
30-
workflows: ["SemVer Release"]
30+
# Both stable-release paths must tag the mirror: a hotfix that bumps the
31+
# component version otherwise leaves HACS users stuck on the old tag
32+
# (the push-triggered snapshot copies code to the mirror's main but only
33+
# this workflow_run leg creates the version tag). The tag step is
34+
# idempotent (skips when the manifest version is already tagged), so a
35+
# hotfix without a component change is a harmless no-op.
36+
workflows: ["SemVer Release", "Hotfix Release"]
3137
types: [completed]
3238
workflow_dispatch:
3339

40+
# Serialize mirror syncs. A real hotfix that touches the component triggers
41+
# BOTH legs — the master push (paths) and the workflow_run — nearly at once,
42+
# and both clone the mirror and push to main; without serialization the second
43+
# push is a non-fast-forward rejection that fails the job before it can tag.
44+
# queue: max keeps a queued release-tag run from being evicted by a later push
45+
# run (requires cancel-in-progress: false).
46+
concurrency:
47+
group: mirror-sync
48+
cancel-in-progress: false
49+
queue: max
50+
3451
jobs:
3552
sync:
53+
# workflow_run fires for EVERY completed run of the listed workflows —
54+
# including the SKIPPED Hotfix Release that every non-hotfix PR merge
55+
# produces (the release job is `if`-guarded) and odd-week / no-change
56+
# SemVer runs. Only proceed for a SUCCESSFUL release run; push and manual
57+
# dispatch (where workflow_run is absent) always run.
58+
if: >-
59+
github.event_name != 'workflow_run' ||
60+
github.event.workflow_run.conclusion == 'success'
3661
runs-on: ubuntu-latest
3762
steps:
3863
- uses: actions/checkout@v7

.github/workflows/sync-tool-docs.yml

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,19 @@ on:
1515
permissions:
1616
contents: read
1717

18-
# Prevent overlapping runs if multiple tool PRs merge in quick succession
18+
# Shared group for every workflow that pushes to master (hotfix-release,
19+
# semver-release, addon-publish-dev): a doc-sync push landing while
20+
# semantic-release is mid-run aborts the release un-retryably ("Upstream
21+
# branch ... has changed"), so master writers must not overlap. This also
22+
# keeps sync runs from overlapping each other. cancel-in-progress must be
23+
# false: a running release must never be cancelled by a doc sync.
1924
concurrency:
20-
group: sync-tool-docs
21-
cancel-in-progress: true
25+
group: master-write
26+
cancel-in-progress: false
27+
# queue: max keeps every queued run waiting FIFO. The default single-slot
28+
# queue evicts the OLDER pending run when a new one queues, which could
29+
# silently drop a queued release behind a bot push.
30+
queue: max
2231

2332
jobs:
2433
sync-tools-json:
@@ -62,18 +71,26 @@ jobs:
6271
git commit -m "chore(internal): sync tool docs after merge [skip ci]"
6372
# master can advance between this job's checkout and its push whenever
6473
# any unrelated PR merges in the same window, which rejects a bare push
65-
# as a non-fast-forward (the concurrency group only serialises sync runs
66-
# against each other, not against ordinary merges). Rebase onto the
67-
# latest master and retry before giving up. cancel-in-progress guarantees
68-
# no competing sync run, so the only thing to rebase over is unrelated
69-
# commits that do not touch the generated files — never a conflict.
74+
# as a non-fast-forward (the master-write concurrency group only
75+
# serialises the workflows in the group, not ordinary merges). Rebase
76+
# onto the latest master and retry before giving up. The group
77+
# guarantees no competing sync run, so the only thing to rebase over
78+
# is unrelated commits that do not touch the generated files — never
79+
# a conflict.
7080
for attempt in 1 2 3 4 5; do
7181
if git push; then
7282
echo "Synced tool docs (attempt ${attempt})."
7383
exit 0
7484
fi
7585
echo "Push rejected on attempt ${attempt}; rebasing onto origin/master and retrying."
76-
git pull --rebase origin master
86+
# A conflicted/failed rebase leaves rebase-in-progress state and the
87+
# remaining attempts would fail for a misleading reason — abort and
88+
# surface the real error instead.
89+
git pull --rebase origin master || {
90+
git rebase --abort 2>/dev/null || true
91+
echo "::error::Rebase onto origin/master failed; aborting retries."
92+
exit 1
93+
}
7794
done
7895
echo "::error::Could not push tool-doc sync after 5 attempts."
7996
exit 1

pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,6 @@ dev = [
219219
# Semantic versioning configuration
220220
[tool.semantic_release]
221221
version_toml = ["pyproject.toml:project.version"]
222-
version_variables = [
223-
"src/ha_mcp/__init__.py:__version__",
224-
]
225222
build_command = """
226223
pip install uv && uv lock
227224
git add uv.lock

0 commit comments

Comments
 (0)