Stop a re-pushed tag from failing the release

Pushing the v1.5.6 tag twice fired every release workflow twice. The other four duplicates were cancelled, but the crates.io one went red with "crate sigit@1.5.6 already exists on crates.io index", even though that workflow already has a check meant to catch exactly this. The check asked crates.io with curl's default User-Agent, which crates.io answers with a 403. Since it used "curl -fsS", that read as a failure, the check concluded the version was unpublished, and cargo publish then walked into a version that was already there. Send a real User-Agent and branch on the status code, so an answer we do not understand stops the job instead of turning into that same confusing publish error. Group every release workflow by tag as well, so a duplicate cannot race the original. cancel-in-progress stays false on purpose: interrupting a half-finished upload is worse than letting the duplicate queue up and no-op against the already-published checks.

paydii committed Sep 4, 2026 at 15:38 UTC 0cd58fa4c82bc669470a62970b71ca6b1445d106
10 files changed +103 -6
.agents/AGENTS.md
+8
@@ -301,6 +301,14 @@ winget, and the AUR PKGBUILD each need one, and they consume the raw binaries ra
301 tarball. `release-github` also builds a `.deb` and `.rpm` per Linux target with nfpm
302 (`packaging/nfpm.yaml`), packaging the already-built binary rather than re-invoking cargo.
303
304 +Re-pushing a tag fires every release workflow a second time, so each one is grouped by tag in a
305 +`concurrency` block with `cancel-in-progress: false` — the duplicate queues behind the original
306 +rather than interrupting a publish that is halfway through uploading. What keeps the queued run
307 +from going red is the "is this version already published?" check each publishing workflow does
308 +before it uploads. Those checks are load-bearing, not belt-and-braces: v1.5.6 published twice
309 +because the crates.io one asked the API with curl's default User-Agent, which crates.io answers
310 +with a 403, which read as "not published yet".
311 +
312 Three of these need credentials or a one-time manual step before they work:
313
314 - Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the
.github/workflows/release-aur.yml
+8
@@ -12,6 +12,14 @@ on:
12 description: "Release tag (e.g. v1.5.2)"
13 required: true
14
15 +concurrency:
16 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
17 + # duplicate behind the original instead of cancelling it: cancelling a run
18 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
19 + # just hits the "already published" check and exits clean.
20 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
21 + cancel-in-progress: false
22 +
23 permissions:
24 contents: read
25
.github/workflows/release-crates.yml
+31 -6
@@ -10,6 +10,14 @@ on:
10 description: "Release tag (e.g. v1.0.2)"
11 required: true
12
13 +concurrency:
14 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
15 + # duplicate behind the original instead of cancelling it: cancelling a run
16 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
17 + # just hits the "already published" check and exits clean.
18 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
19 + cancel-in-progress: false
20 +
21 permissions:
22 contents: read
23
@@ -56,12 +64,29 @@ jobs:
64 print(next(p['version'] for p in pkgs if p['name']=='sigit'))")
65 echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
66
59 - if curl -fsS "https://crates.io/api/v1/crates/sigit/${VERSION}" >/dev/null 2>&1; then
60 - echo "sigit ${VERSION} already exists on crates.io, skipping publish"
61 - echo "exists=true" >> "$GITHUB_OUTPUT"
62 - else
63 - echo "exists=false" >> "$GITHUB_OUTPUT"
64 - fi
67 + # crates.io answers 403 to requests without a descriptive
68 + # User-Agent, so curl's default one makes this look like a 404 and
69 + # the publish below runs into "crate already exists on the index".
70 + # Branch on the status code rather than on curl's exit status, so an
71 + # answer we do not understand stops the job instead of quietly
72 + # degrading into that same failure.
73 + STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
74 + -H "User-Agent: getsigit/sigit release workflow (github.com/getsigit/sigit)" \
75 + "https://crates.io/api/v1/crates/sigit/${VERSION}")
76 +
77 + case "$STATUS" in
78 + 200)
79 + echo "sigit ${VERSION} already exists on crates.io, skipping publish"
80 + echo "exists=true" >> "$GITHUB_OUTPUT"
81 + ;;
82 + 404)
83 + echo "exists=false" >> "$GITHUB_OUTPUT"
84 + ;;
85 + *)
86 + echo "Unexpected ${STATUS} from crates.io while checking whether sigit ${VERSION} is published" >&2
87 + exit 1
88 + ;;
89 + esac
90
91 - name: Publish to crates.io
92 if: steps.crates-check.outputs.exists != 'true'
.github/workflows/release-github.yml
+8
@@ -10,6 +10,14 @@ on:
10 description: "Release tag (e.g. v0.1.1)"
11 required: true
12
13 +concurrency:
14 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
15 + # duplicate behind the original instead of cancelling it: cancelling a run
16 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
17 + # just hits the "already published" check and exits clean.
18 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
19 + cancel-in-progress: false
20 +
21 permissions:
22 contents: write
23 actions: write
.github/workflows/release-homebrew.yml
+8
@@ -7,6 +7,14 @@ on:
7 description: "Release tag (e.g. v0.1.1)"
8 required: true
9
10 +concurrency:
11 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
12 + # duplicate behind the original instead of cancelling it: cancelling a run
13 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
14 + # just hits the "already published" check and exits clean.
15 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
16 + cancel-in-progress: false
17 +
18 permissions:
19 contents: read
20
.github/workflows/release-npm.yml
+8
@@ -10,6 +10,14 @@ on:
10 description: "Release tag (e.g. v0.1.1)"
11 required: true
12
13 +concurrency:
14 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
15 + # duplicate behind the original instead of cancelling it: cancelling a run
16 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
17 + # just hits the "already published" check and exits clean.
18 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
19 + cancel-in-progress: false
20 +
21 permissions:
22 id-token: write
23 contents: read
.github/workflows/release-nuget.yml
+8
@@ -10,6 +10,14 @@ on:
10 description: "Release tag (e.g. v1.5.2)"
11 required: true
12
13 +concurrency:
14 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
15 + # duplicate behind the original instead of cancelling it: cancelling a run
16 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
17 + # just hits the "already published" check and exits clean.
18 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
19 + cancel-in-progress: false
20 +
21 permissions:
22 contents: read
23
.github/workflows/release-pypi.yml
+8
@@ -10,6 +10,14 @@ on:
10 description: "Release tag (e.g. v0.1.1)"
11 required: true
12
13 +concurrency:
14 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
15 + # duplicate behind the original instead of cancelling it: cancelling a run
16 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
17 + # just hits the "already published" check and exits clean.
18 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
19 + cancel-in-progress: false
20 +
21 permissions:
22 id-token: write
23 contents: read
.github/workflows/release-scoop.yml
+8
@@ -8,6 +8,14 @@ on:
8 description: "Release tag (e.g. v1.5.2)"
9 required: true
10
11 +concurrency:
12 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
13 + # duplicate behind the original instead of cancelling it: cancelling a run
14 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
15 + # just hits the "already published" check and exits clean.
16 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
17 + cancel-in-progress: false
18 +
19 permissions:
20 contents: read
21
.github/workflows/release-winget.yml
+8
@@ -20,6 +20,14 @@ on:
20 description: "Release tag (e.g. v1.5.2)"
21 required: true
22
23 +concurrency:
24 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
25 + # duplicate behind the original instead of cancelling it: cancelling a run
26 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
27 + # just hits the "already published" check and exits clean.
28 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
29 + cancel-in-progress: false
30 +
31 permissions:
32 contents: read
33