Skip to content

Commit edff89a

Browse files
ci: harden publish release recovery (#361)
1 parent cddbe40 commit edff89a

2 files changed

Lines changed: 90 additions & 8 deletions

File tree

.github/workflows/publish.yml

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ on:
55
tags:
66
- "v*"
77
workflow_dispatch:
8+
inputs:
9+
release_tag:
10+
description: Existing v* tag to publish or repair
11+
required: true
12+
type: string
13+
publish_to_pypi:
14+
description: Publish artifacts to PyPI before syncing the GitHub Release
15+
required: true
16+
default: false
17+
type: boolean
18+
sync_github_release:
19+
description: Create or repair the GitHub Release and release assets
20+
required: true
21+
default: true
22+
type: boolean
23+
24+
concurrency:
25+
group: publish-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
26+
cancel-in-progress: false
827

928
permissions:
1029
contents: write
@@ -19,6 +38,7 @@ jobs:
1938
uses: actions/checkout@v6
2039
with:
2140
fetch-depth: 0
41+
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
2242

2343
- name: Setup Python
2444
uses: actions/setup-python@v6
@@ -48,6 +68,8 @@ jobs:
4868
run: uv build --no-sources
4969

5070
- name: Verify published version matches tag
71+
env:
72+
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
5173
run: |
5274
python - <<'PY'
5375
import os
@@ -64,7 +86,7 @@ jobs:
6486
sdist = sdists[0].name
6587
version = wheel.removeprefix("opencode_a2a-").split("-py3", 1)[0]
6688
sdist_version = sdist.removeprefix("opencode_a2a-").removesuffix(".tar.gz")
67-
tag = os.environ["GITHUB_REF_NAME"].removeprefix("v")
89+
tag = os.environ["RELEASE_TAG"].removeprefix("v")
6890
if version != tag:
6991
raise SystemExit(f"Wheel version {version!r} does not match tag {tag!r}")
7092
if sdist_version != tag:
@@ -79,12 +101,60 @@ jobs:
79101
run: bash ./scripts/smoke_test_built_cli.sh dist/opencode_a2a-*.tar.gz
80102

81103
- name: Publish to PyPI
104+
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_to_pypi }}
82105
uses: pypa/gh-action-pypi-publish@release/v1
83106

84-
- name: Create GitHub Release
85-
uses: softprops/action-gh-release@v2
86-
with:
87-
generate_release_notes: true
88-
files: |
89-
dist/*.tar.gz
90-
dist/*.whl
107+
- name: Sync GitHub Release
108+
if: ${{ github.event_name != 'workflow_dispatch' || inputs.sync_github_release }}
109+
env:
110+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
111+
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
112+
run: |
113+
set -euo pipefail
114+
115+
retry() {
116+
local attempts="$1"
117+
local sleep_seconds="$2"
118+
shift 2
119+
120+
local attempt=1
121+
while true; do
122+
if "$@"; then
123+
return 0
124+
fi
125+
126+
if [ "$attempt" -ge "$attempts" ]; then
127+
echo "Command failed after ${attempts} attempts: $*" >&2
128+
return 1
129+
fi
130+
131+
echo "Retrying (${attempt}/${attempts}) after transient failure: $*" >&2
132+
sleep "$sleep_seconds"
133+
attempt=$((attempt + 1))
134+
done
135+
}
136+
137+
ensure_release() {
138+
if retry 3 5 gh release view "$RELEASE_TAG" --json url >/dev/null; then
139+
echo "Release ${RELEASE_TAG} already exists."
140+
return 0
141+
fi
142+
143+
retry 3 5 gh release create "$RELEASE_TAG" --verify-tag --generate-notes
144+
}
145+
146+
ensure_release
147+
148+
mapfile -t existing_assets < <(
149+
retry 3 5 gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name'
150+
)
151+
152+
for asset in dist/*.tar.gz dist/*.whl; do
153+
asset_name="$(basename "$asset")"
154+
if printf '%s\n' "${existing_assets[@]}" | grep -Fxq "$asset_name"; then
155+
echo "Release asset already present: $asset_name"
156+
continue
157+
fi
158+
159+
retry 3 5 gh release upload "$RELEASE_TAG" "$asset"
160+
done

CONTRIBUTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ uv run mypy src/opencode_a2a
7878
- Link the relevant issue in commits and PR descriptions when applicable.
7979
- Open PRs as Draft by default when the change still needs review or iteration.
8080

81+
## Release Recovery
82+
83+
`Publish` is the release workflow for tagged versions. It now supports both the normal tag-push path and explicit maintainer recovery runs.
84+
85+
- Tag pushes still perform the full release flow: regression checks, artifact builds, PyPI publish, and GitHub Release sync.
86+
- Manual `workflow_dispatch` runs require a `release_tag` input so the workflow checks out and rebuilds the exact tagged revision instead of the current branch tip.
87+
- Manual `workflow_dispatch` runs default `publish_to_pypi=false` so the safest recovery path is to repair the GitHub Release without attempting a duplicate PyPI publish.
88+
- Set `publish_to_pypi=true` only when a maintainer intentionally needs the manual dispatch to publish artifacts to PyPI before syncing the GitHub Release.
89+
- GitHub Release sync is idempotent for the tagged release: it creates the release when missing and uploads only missing wheel/sdist assets.
90+
91+
If a release run publishes to PyPI successfully but fails while creating or uploading the GitHub Release, recover with a manual dispatch against the same tag and set `publish_to_pypi=false`.
92+
8193
## Documentation
8294

8395
Update docs together with code whenever you change:

0 commit comments

Comments
 (0)