Paste a GitHub Actions workflow. It reports which release artifacts the matrix legs will fight over, and whether two legs can create two draft releases for one tag.
An Electron release matrix fails quietly. Every leg exits 0, every step is green, and the release page
looks fine right up until someone tries to auto-update. Two things go wrong. First,
electron-builder --publish always resolves the release with a read-then-create that holds no
lock, so two runners that both look before either creates will both create, and GitHub accepts two drafts
carrying one tag name because a draft has no git tag yet. Second, two legs on the same platform write the
same update manifest name, and the second upload deletes the first one's copy and takes its place. This
page derives both from the publisher source, on your workflow, in your browser.
The two "real" buttons load this account owner's own release workflows: the one that produced two draft releases for a single tag, and the two-job rewrite that fixed it. Same tool, same author, one broken and one clean. The synthetic ones exist only because the arch-split and dynamic-matrix paths never fired on his repos, and an analyser you cannot see fail is not worth much.
The analyser reads this table only when a leg carries no explicit architecture, that is, when the matrix
has no arch key and the electron-builder command line has no --x64,
--arm64, --ia32, --armv7l or --universal flag. An
explicit arch always wins over the table. Edit any row; the values are yours, they persist in this browser,
and a label that is not listed is reported as unknown rather than guessed.
verified as of 2026-08
| runs-on label | platform | arch |
|---|
The grid below is not a lookup table. It is generated on this page by the two functions ported from
updateInfoBuilder.ts, run over every platform and arch pair, so it stays correct for inputs
nobody anticipated. Change the channel and watch every name change with it.
| platform | arch | update manifest | shared with |
|---|
Two names are produced by more than one pair, and both are highlighted above. The mac one is well known.
The Windows one is the interesting half: latest.yml is written by win/x64 and by
win/arm64, because the OS suffix is empty for Windows and the arch prefix is empty for
everything that is not Linux. Arm Windows runners are generally available now, so an
[windows-latest, windows-11-arm] matrix is an ordinary thing to write, and it silently leaves
one of the two architectures unreachable by auto-update.
electron-builder, packages/app-builder-lib/src/publish/updateInfoBuilder.ts, lines 65 to 75, functions getUpdateInfoFileName and getArchPrefixForUpdateFile
function getUpdateInfoFileName(channel: string, packager: PlatformPackager<any>, arch: Arch | null): string {
const osSuffix = packager.platform === Platform.WINDOWS ? "" : `-${packager.platform.buildConfigurationKey}`
return `${channel}${osSuffix}${getArchPrefixForUpdateFile(arch, packager)}.yml`
}
function getArchPrefixForUpdateFile(arch: Arch | null, packager: PlatformPackager<any>) {
if (arch == null || arch === Arch.x64 || packager.platform !== Platform.LINUX) {
return ""
}
return arch === Arch.armv7l ? "-arm" : `-${Arch[arch]}`
}
Read the second function as one sentence: the architecture is dropped from the file name unless the
platform is Linux. Every mac arch therefore lands on latest-mac.yml, and both Windows arches
land on latest.yml.
electron-builder, packages/electron-publish/src/gitHubPublisher.ts, lines 145 to 155, method overwriteArtifact
private async overwriteArtifact(fileName: string, release: Release) {
// delete old artifact and re-upload
log.warn({ file: fileName, reason: "already exists on GitHub" }, "overwrite published file")
const assets = await this.githubRequest<Array<Asset>>(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}/assets`, this.token, null)
for (const asset of assets) {
if (asset.name === fileName) {
await this.githubRequest<void>(`/repos/${this.info.owner}/${this.info.repo}/releases/assets/${asset.id}`, this.token, null, "DELETE")
return
}
}
That is a warning, not an error. The losing leg's manifest is deleted and replaced, the step exits 0, and the release page still shows every installer. Only the manifest, the file the updater actually reads, has lost an architecture.
electron-builder, packages/electron-publish/src/gitHubPublisher.ts, lines 79 to 94, method getOrCreateRelease
private async getOrCreateRelease(): Promise<Release | null> {
const logFields = {
tag: this.tag,
version: this.version,
}
// we don't use "Get a release by tag name" because "tag name" means existing git tag, but we draft release and don't create git tag
const releases = await this.githubRequest<Array<Release>>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token)
for (const release of releases) {
if (!(release.tag_name === this.tag || release.tag_name === this.version)) {
continue
}
if (release.draft) {
return release
}
That comment is the whole mechanism. A draft release has no git tag behind it, so GitHub treats
tag_name on a draft as a plain string field and is happy to hold two drafts that carry the same
one. The list call above is the "read" half of a read-then-create with nothing between the halves.
same file, lines 126 to 136, the "create" half
// https://github.com/electron-userland/electron-builder/issues/1835
if (this.options.publish === "always" || getCiTag() != null) {
log.info(
{
reason: "release doesn't exist",
...logFields,
},
`creating GitHub release`
)
return this.createRelease()
}
Note the second half of that condition. getCiTag() != null means a tag-triggered workflow
takes this branch even without --publish always, which is why this page also flags
a leg whose --publish flag is onTag, and a leg carrying no --publish
flag at all, whenever the workflow is triggered by push: tags:.
same file, lines 225 to 236, method createRelease
private createRelease() {
const data: Record<string, any> = {
tag_name: this.tag,
name: this.releaseName || this.version,
draft: this.releaseType === "draft",
prerelease: this.releaseType === "prerelease",
}
if (this.releaseBody) {
data.body = trimStringWithWarn(this.releaseBody, 100000, "release body exceeds GitHub API limit, truncating")
}
return this.githubRequest<Release>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token, data)
}
A bare POST. No conditional request, no idempotency key, no retry-and-reconcile. Both losers of the race get an HTTP 201.
same file, lines 215 to 223 (definition) and lines 202 to 203 (its only call site)
private doesErrorMeanAlreadyExists(e: any) {
if (!e.description) {
return false
}
const desc = e.description
const descIncludesAlreadyExists =
(desc.includes("errors") && desc.includes("already_exists")) || (desc.errors && desc.errors.length >= 1 && desc.errors[0].code === "already_exists")
return e.statusCode === 422 && descIncludesAlreadyExists
}
} else if (this.doesErrorMeanAlreadyExists(e)) {
return this.overwriteArtifact(fileName, release).then(() => this.doUploadFile(attemptNumber + 1, parsedUrl, fileName, dataLength, requestProcessor, release))
The 422 already-exists handler does exist. It is wired into the asset upload path only, and that call site at line 202 is the only one in the file. Release creation has no 422 handling at all, which is a smaller problem than it sounds, because creation does not get a 422 here: two drafts on one tag name are legal, so both creations simply succeed.
softprops/action-gh-release, src/github.ts, lines 1111 to 1118, inside createRelease
case 422:
// Check if this is a race condition with "already_exists" error
if (hasValidationErrorCode(error, 'already_exists')) {
console.log(
'Release already exists (race condition detected), retrying to find and update existing release...',
);
// Don't throw - allow retry to find existing release
} else {
One emoji character has been dropped from the quoted console string above so this page stays pure ASCII. Nothing else is changed.
same file, line 637, inside release; and lines 950 to 964, function cleanupCreatedDuplicateDraftRelease
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
async function cleanupCreatedDuplicateDraftRelease(
releaser: Releaser,
owner: string,
repo: string,
tag: string,
canonicalReleaseId: number,
createdRelease: Release,
): Promise<void> {
if (
createdRelease.id === canonicalReleaseId ||
!createdRelease.draft ||
createdRelease.assets.length > 0
) {
return;
}
That action retries into the existing release on a 422, and it has a function whose entire job is to
delete the duplicate draft it just created. It was written by somebody who hit this race. That is exactly
why the draft-race finding on this page fires for electron-builder and stays silent for
softprops/action-gh-release. An auditor that flags every parallel Electron release workflow is
an auditor nobody keeps installed.
cli/cli, pkg/cmd/release/shared/fetch.go, lines 192 to 221, function FetchRelease
func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) {
publishedURL, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "tags", tagName)
if err != nil {
return nil, err
}
cc, cancel := context.WithCancel(ctx)
results := make(chan fetchResult, 2)
// published release lookup
go func() {
release, err := fetchReleasePath(cc, httpClient, publishedURL)
results <- fetchResult{release: release, error: err}
}()
// draft release lookup
go func() {
release, err := fetchDraftRelease(cc, httpClient, repo, tagName)
results <- fetchResult{release: release, error: err}
}()
// Prefer a release found by either lookup. A single failed lookup, such as
// the draft lookup when unauthenticated, must not mask a release found by
// the other; only report an error when both lookups fail.
first := <-results
if first.error == nil {
cancel()
<-results // drain the channel
return first.release, nil
}
Two goroutines, a two-slot buffered channel, and first := <-results. Whichever HTTP round
trip returns first wins and the other is cancelled and drained. That is not ambiguity in the docs, it is
non-determinism in the code, and it is the tool you reach for to check what the workflow just did.
same file, lines 247 to 255, function fetchDraftRelease
// First use GraphQL to find a draft release by pending tag name, since REST doesn't have this ability.
var query struct {
Repository struct {
Release *struct {
DatabaseID int64
IsDraft bool
} `graphql:"release(tagName: $tagName)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
And the draft lookup asks GraphQL for release(tagName:), singular. One release comes back.
If your matrix produced two drafts for that tag, the second one is not merely deprioritised by
gh, it is invisible to it. You will go looking on the web UI, find both, and wonder why the
command line only ever showed you one.
Legs. A leg is one matrix combination of one job that actually runs electron-builder.
A job with no matrix is one leg. A publish-only job that just calls gh release upload produces
no update manifests, so it is listed but not counted as a leg. That is why the fixed two-job workflow reports
two legs and not three.
Draft race. Fires only when two or more legs run electron-builder in a mode that
reaches createRelease(), which is --publish always, or any non-never
mode on a tag-triggered workflow. It does not fire for softprops/action-gh-release or for
--publish never. The finding is publisher-specific on purpose.
Manifest overwrite. Publisher-independent. Two legs deriving the same update manifest name collide regardless of who uploads, because both electron-builder and softprops resolve the conflict by deleting the existing asset and uploading over it.
What it will not do. It does not resolve fromJson matrices built from another job's
output, and says so rather than guessing. It does not read your package.json, so it cannot know
your product name, version, channel, or configured build.win.target arches; installer file names
are therefore out of scope and only update manifests are analysed. It does not follow reusable workflows
called with uses: at job level. It parses a practical subset of YAML, not all of it: anchors,
aliases, merge keys, multi-document streams and explicit tags are not supported.
The mac half of this has a paper trail.
Issue 5592 is the one people
find. It is worth being precise about its status: it is closed, closed by the reporter himself two days after
filing, and it kept collecting comments for years afterwards. A closed-but-unfixed zombie is worse than an
open issue, because search engines and maintainers both read the green "closed" badge as resolved.
Issue 6676 and
issue 6643 cover neighbouring
ground. The Windows latest.yml collision, which falls straight out of the function quoted at the
top of this page, has no issue at all that this author could find.