Skip to content

Commit 4b68ad2

Browse files
committed
ci: make CLI releases resumable
1 parent db3e5ce commit 4b68ad2

4 files changed

Lines changed: 236 additions & 16 deletions

File tree

‎.github/workflows/backfill-release-assets.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,21 @@ jobs:
2424
- name: Validate tag name
2525
id: validate
2626
env:
27+
GH_TOKEN: ${{ github.token }}
2728
TAG_NAME: ${{ inputs.tag_name }}
2829
run: |
30+
set -euo pipefail
2931
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
3032
echo "Invalid release tag." >&2
3133
exit 1
3234
fi
35+
release="$(gh release view "$TAG_NAME" --json isDraft,isPrerelease,tagName)"
36+
if [[ "$(jq -r .tagName <<<"$release")" != "$TAG_NAME" ]] ||
37+
[[ "$(jq -r .isDraft <<<"$release")" != "false" ]] ||
38+
[[ "$(jq -r .isPrerelease <<<"$release")" != "false" ]]; then
39+
echo "Backfill accepts only an existing published stable release." >&2
40+
exit 1
41+
fi
3342
printf 'tag_name=%s\n' "$TAG_NAME" >> "$GITHUB_OUTPUT"
3443
3544
build-unix-binaries:

‎.github/workflows/ci.yml‎

Lines changed: 200 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88

99
concurrency:
1010
group: ci-${{ github.workflow }}-${{ github.ref }}
11-
cancel-in-progress: true
11+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
1212

1313
permissions: {}
1414

@@ -57,9 +57,11 @@ jobs:
5757
contents: read
5858
id-token: write
5959
outputs:
60-
new_release_published: ${{ steps.semantic.outputs.new_release_published }}
61-
new_release_git_tag: ${{ steps.semantic.outputs.new_release_git_tag }}
62-
new_release_version: ${{ steps.semantic.outputs.new_release_version }}
60+
release_draft: ${{ steps.release-target.outputs.draft }}
61+
release_present: ${{ steps.release-target.outputs.present }}
62+
release_published: ${{ steps.release-target.outputs.published }}
63+
release_tag: ${{ steps.release-target.outputs.tag }}
64+
release_version: ${{ steps.release-target.outputs.version }}
6365

6466
steps:
6567
- name: Create release bot token
@@ -126,8 +128,84 @@ jobs:
126128
GIT_COMMITTER_NAME: ${{ steps.release-bot.outputs.app-slug }}[bot]
127129
GIT_COMMITTER_EMAIL: ${{ steps.release-bot-identity.outputs.user_id }}+${{ steps.release-bot.outputs.app-slug }}[bot]@users.noreply.github.com
128130

131+
- name: Resolve exact release target
132+
id: release-target
133+
env:
134+
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
135+
NEW_RELEASE_TAG: ${{ steps.semantic.outputs.new_release_git_tag }}
136+
run: |
137+
set -euo pipefail
138+
git fetch origin main --tags --force
139+
140+
tag="$NEW_RELEASE_TAG"
141+
if [[ -z "$tag" ]]; then
142+
matches=""
143+
while IFS= read -r candidate; do
144+
candidate_commit="$(git rev-parse "${candidate}^{commit}")"
145+
candidate_parent="$(git rev-parse "${candidate}^{commit}^" 2>/dev/null || true)"
146+
if [[ "$candidate_commit" == "$GITHUB_SHA" || "$candidate_parent" == "$GITHUB_SHA" ]]; then
147+
matches="${matches}${candidate}"$'\n'
148+
fi
149+
done < <(git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)
150+
151+
match_count="$(printf '%s' "$matches" | awk 'NF { count++ } END { print count + 0 }')"
152+
if (( match_count > 1 )); then
153+
echo "multiple release tags match workflow commit $GITHUB_SHA" >&2
154+
exit 1
155+
fi
156+
tag="$(printf '%s' "$matches" | awk 'NF { print; exit }')"
157+
fi
158+
159+
if [[ -z "$tag" ]]; then
160+
echo "present=false" >> "$GITHUB_OUTPUT"
161+
exit 0
162+
fi
163+
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
164+
echo "unsupported release tag: $tag" >&2
165+
exit 1
166+
fi
167+
if ! git merge-base --is-ancestor "$tag" origin/main; then
168+
echo "release tag is not on origin/main: $tag" >&2
169+
exit 1
170+
fi
171+
172+
release=""
173+
release_error_file="$(mktemp)"
174+
trap 'rm -f "$release_error_file"' EXIT
175+
for attempt in {1..12}; do
176+
if release="$(gh release view "$tag" --json databaseId,isDraft,isPrerelease,tagName 2>"$release_error_file")"; then
177+
break
178+
fi
179+
if (( attempt == 12 )); then
180+
cat "$release_error_file" >&2
181+
echo "release $tag lookup failed after $attempt attempts" >&2
182+
exit 1
183+
fi
184+
sleep 5
185+
done
186+
rm -f "$release_error_file"
187+
trap - EXIT
188+
189+
if [[ "$(jq -r .tagName <<<"$release")" != "$tag" ]]; then
190+
echo "release lookup returned the wrong tag" >&2
191+
exit 1
192+
fi
193+
if [[ "$(jq -r .isPrerelease <<<"$release")" != "false" ]]; then
194+
echo "stable release tag resolved to a prerelease: $tag" >&2
195+
exit 1
196+
fi
197+
198+
is_draft="$(jq -r .isDraft <<<"$release")"
199+
{
200+
echo "present=true"
201+
echo "tag=$tag"
202+
echo "version=${tag#v}"
203+
echo "draft=$is_draft"
204+
echo "published=$([[ "$is_draft" == "false" ]] && echo true || echo false)"
205+
} >> "$GITHUB_OUTPUT"
206+
129207
build-unix-binaries:
130-
if: needs.release.outputs.new_release_published == 'true'
208+
if: needs.release.outputs.release_draft == 'true'
131209
name: Build ${{ matrix.os }} release assets
132210
needs:
133211
- release
@@ -161,7 +239,7 @@ jobs:
161239
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
162240
with:
163241
fetch-depth: 0
164-
ref: ${{ needs.release.outputs.new_release_git_tag }}
242+
ref: ${{ needs.release.outputs.release_tag }}
165243
persist-credentials: false
166244

167245
- name: Set up Vite+
@@ -182,7 +260,7 @@ jobs:
182260
- name: Package release assets
183261
shell: pwsh
184262
run: |
185-
$version = "${{ needs.release.outputs.new_release_version }}"
263+
$version = "${{ needs.release.outputs.release_version }}"
186264
$assetBase = "putio-cli-$version-${{ matrix.asset_os }}-${{ matrix.asset_arch }}"
187265
$releaseDir = ".artifacts/release"
188266
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
@@ -205,12 +283,13 @@ jobs:
205283
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
206284
with:
207285
token: ${{ steps.release-bot.outputs.token }}
208-
tag_name: ${{ needs.release.outputs.new_release_git_tag }}
286+
tag_name: ${{ needs.release.outputs.release_tag }}
287+
draft: true
209288
files: |
210289
.artifacts/release/*
211290
212291
build-windows-binary:
213-
if: needs.release.outputs.new_release_published == 'true'
292+
if: needs.release.outputs.release_draft == 'true'
214293
name: Build windows-latest release assets
215294
needs:
216295
- release
@@ -234,7 +313,7 @@ jobs:
234313
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
235314
with:
236315
fetch-depth: 0
237-
ref: ${{ needs.release.outputs.new_release_git_tag }}
316+
ref: ${{ needs.release.outputs.release_tag }}
238317
persist-credentials: false
239318

240319
- name: Set up Vite+
@@ -255,7 +334,7 @@ jobs:
255334
- name: Package release assets
256335
shell: pwsh
257336
run: |
258-
$version = "${{ needs.release.outputs.new_release_version }}"
337+
$version = "${{ needs.release.outputs.release_version }}"
259338
$assetBase = "putio-cli-$version-windows-amd64"
260339
$releaseDir = ".artifacts/release"
261340
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
@@ -273,16 +352,109 @@ jobs:
273352
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
274353
with:
275354
token: ${{ steps.release-bot.outputs.token }}
276-
tag_name: ${{ needs.release.outputs.new_release_git_tag }}
355+
tag_name: ${{ needs.release.outputs.release_tag }}
356+
draft: true
277357
files: |
278358
.artifacts/release/*
279359
360+
finalize-release:
361+
if: >-
362+
${{
363+
!cancelled() &&
364+
needs.release.result == 'success' &&
365+
needs.release.outputs.release_present == 'true' &&
366+
(
367+
needs.release.outputs.release_published == 'true' ||
368+
(
369+
needs.release.outputs.release_draft == 'true' &&
370+
needs.build-unix-binaries.result == 'success' &&
371+
needs.build-windows-binary.result == 'success'
372+
)
373+
)
374+
}}
375+
name: Verify and publish GitHub release
376+
needs:
377+
- release
378+
- build-unix-binaries
379+
- build-windows-binary
380+
runs-on: ubuntu-latest
381+
timeout-minutes: 10
382+
environment:
383+
name: release
384+
deployment: false
385+
permissions:
386+
contents: read
387+
outputs:
388+
release_ready: ${{ steps.finalized.outputs.ready }}
389+
390+
steps:
391+
- name: Create release bot token
392+
id: release-bot
393+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
394+
with:
395+
client-id: ${{ vars.PUTIO_RELEASE_BOT_CLIENT_ID }}
396+
private-key: ${{ secrets.PUTIO_RELEASE_BOT_PRIVATE_KEY }}
397+
permission-contents: write
398+
399+
- name: Verify draft asset manifest and publish
400+
if: needs.release.outputs.release_draft == 'true'
401+
env:
402+
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
403+
RELEASE_TAG: ${{ needs.release.outputs.release_tag }}
404+
RELEASE_VERSION: ${{ needs.release.outputs.release_version }}
405+
run: |
406+
set -euo pipefail
407+
release="$(gh release view "$RELEASE_TAG" --json assets,isDraft,isPrerelease)"
408+
[[ "$(jq -r .isDraft <<<"$release")" == "true" ]]
409+
[[ "$(jq -r .isPrerelease <<<"$release")" == "false" ]]
410+
expected="$(printf '%s\n' \
411+
"putio-cli-${RELEASE_VERSION}-darwin-arm64.tar.gz" \
412+
"putio-cli-${RELEASE_VERSION}-darwin-arm64.tar.gz.sha256" \
413+
"putio-cli-${RELEASE_VERSION}-linux-amd64.tar.gz" \
414+
"putio-cli-${RELEASE_VERSION}-linux-amd64.tar.gz.sha256" \
415+
"putio-cli-${RELEASE_VERSION}-windows-amd64.zip" \
416+
"putio-cli-${RELEASE_VERSION}-windows-amd64.zip.sha256" | sort)"
417+
actual="$(jq -r '.assets[].name' <<<"$release" | sort)"
418+
diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$actual")
419+
gh release edit "$RELEASE_TAG" --draft=false --latest
420+
421+
- name: Verify published release
422+
id: finalized
423+
env:
424+
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
425+
RELEASE_TAG: ${{ needs.release.outputs.release_tag }}
426+
RELEASE_VERSION: ${{ needs.release.outputs.release_version }}
427+
run: |
428+
set -euo pipefail
429+
expected="$(printf '%s\n' \
430+
"putio-cli-${RELEASE_VERSION}-darwin-arm64.tar.gz" \
431+
"putio-cli-${RELEASE_VERSION}-darwin-arm64.tar.gz.sha256" \
432+
"putio-cli-${RELEASE_VERSION}-linux-amd64.tar.gz" \
433+
"putio-cli-${RELEASE_VERSION}-linux-amd64.tar.gz.sha256" \
434+
"putio-cli-${RELEASE_VERSION}-windows-amd64.zip" \
435+
"putio-cli-${RELEASE_VERSION}-windows-amd64.zip.sha256" | sort)"
436+
for attempt in {1..12}; do
437+
release="$(gh release view "$RELEASE_TAG" --json assets,isDraft,isPrerelease)"
438+
actual="$(jq -r '.assets[].name' <<<"$release" | sort)"
439+
if [[ "$(jq -r .isDraft <<<"$release")" == "false" ]] &&
440+
[[ "$(jq -r .isPrerelease <<<"$release")" == "false" ]] &&
441+
diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$actual"); then
442+
echo "ready=true" >> "$GITHUB_OUTPUT"
443+
exit 0
444+
fi
445+
if (( attempt == 12 )); then
446+
echo "release $RELEASE_TAG did not reach the expected published state" >&2
447+
exit 1
448+
fi
449+
sleep 5
450+
done
451+
280452
update-homebrew-tap:
281-
if: needs.release.outputs.new_release_published == 'true'
453+
if: needs.finalize-release.outputs.release_ready == 'true'
282454
name: Update Homebrew tap
283455
needs:
284456
- release
285-
- build-unix-binaries
457+
- finalize-release
286458
runs-on: ubuntu-latest
287459
timeout-minutes: 20
288460
environment:
@@ -329,6 +501,7 @@ jobs:
329501
commit_email: ${{ steps.homebrew-release-bot-user.outputs.user_id }}+${{ steps.homebrew-release-bot.outputs.app-slug }}[bot]@users.noreply.github.com
330502
branch: main
331503
formula_folder: Formula
504+
version: ${{ needs.release.outputs.release_tag }}
332505
install: 'bin.install "putio"'
333506
test: |
334507
output = shell_output("#{bin}/putio version")
@@ -337,3 +510,16 @@ jobs:
337510
target_darwin_arm64: true
338511
target_linux_amd64: true
339512
skip_checksum: true
513+
ignore_warnings: true
514+
515+
- name: Verify Homebrew tap points at the release
516+
env:
517+
GH_TOKEN: ${{ steps.homebrew-release-bot.outputs.token }}
518+
RELEASE_TAG: ${{ needs.release.outputs.release_tag }}
519+
RELEASE_VERSION: ${{ needs.release.outputs.release_version }}
520+
run: |
521+
set -euo pipefail
522+
formula="$(gh api repos/putdotio/homebrew-tap/contents/Formula/putio-cli.rb --jq .content | base64 --decode)"
523+
grep -Fq "/archive/refs/tags/${RELEASE_TAG}.tar.gz" <<<"$formula"
524+
grep -Fq "/releases/download/${RELEASE_TAG}/putio-cli-${RELEASE_VERSION}-darwin-arm64.tar.gz" <<<"$formula"
525+
grep -Fq "/releases/download/${RELEASE_TAG}/putio-cli-${RELEASE_VERSION}-linux-amd64.tar.gz" <<<"$formula"

‎.releaserc.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
"npmPublish": true
2020
}
2121
],
22-
"@semantic-release/github",
22+
[
23+
"@semantic-release/github",
24+
{
25+
"draftRelease": true
26+
}
27+
],
2328
[
2429
"@semantic-release/git",
2530
{

‎docs/DISTRIBUTION.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ GitHub Actions owns npm publishing, GitHub Releases, release assets, and Homebre
1212
4. `vp run build`
1313
5. `semantic-release`
1414

15-
Binary asset jobs build from the published release tag after semantic-release creates it.
15+
semantic-release publishes npm, writes the version tag, and creates a draft
16+
GitHub Release. Binary jobs build from that exact tag and upload all six archive
17+
and checksum assets while the Release is mutable. A final job verifies the exact
18+
asset manifest before publishing, then reconciles the Homebrew formula to the
19+
same tag.
1620

1721
## Release Environment
1822

@@ -36,6 +40,22 @@ The workflow keeps dependency caches only on the secretless verify job. Secret-b
3640

3741
The release-bot remote is configured only after dependencies are installed and the package build completes.
3842

43+
## Recover
44+
45+
Rerun the failed `main` workflow. The release job resolves the tag associated
46+
with the original workflow commit and retries exact Release visibility before
47+
choosing a state:
48+
49+
- a draft rebuilds and replaces its binary assets, verifies all six names, and
50+
publishes once
51+
- a published Release skips asset mutation, verifies the complete manifest,
52+
and reruns the idempotent Homebrew reconciliation
53+
- a push with no associated release remains a no-op
54+
55+
An expected Release that stays invisible fails closed with the final lookup
56+
error. Use the manual `Backfill Release Assets` workflow only for an older
57+
published release created before draft-first publication.
58+
3959
## Package Contents
4060

4161
The npm package includes `dist`, `README.md`, `docs`, `skills`, `AGENTS.md`,

0 commit comments

Comments
 (0)