Skip to content

Publish a complete release

CreateRelease followed by AddReleaseAsset always passes through a state where the release is visible and empty. ReleaseAssetPublisher removes it.

The window is worse than it sounds, because the release exists. A consumer asking "is there a release?" is told yes, fetches an asset, and gets a 404 that reads as a missing file rather than a race — so the retry it would have done for a race never happens.

Discover the capability

It is optional, like every other capability here. Treat a failed assertion and ErrNotSupported identically, and fall back.

var pub forge.ReleaseAssetPublisher
if !forge.As(provider, &pub) {
    // Fall back to CreateRelease, then AddReleaseAsset.
    return publishInTwoSteps(ctx, provider, draft, assets)
}

Publish with the assets attached

rel, err := pub.CreateReleaseWithAssets(ctx, owner, repo, forge.ReleaseDraft{
    TagName: "v1.2.3",
    Commit:  headSHA, // full SHA; it is checked against the tag, not sent
    Name:    "v1.2.3",
    Body:    "## Changes\n\n- ...",
}, []forge.ReleaseAssetSource{
    {Name: "tool_linux_amd64.tar.gz", Size: size, Content: f},
    {Name: "tool_darwin_arm64.tar.gz", Location: "https://gitlab.example/api/v4/projects/1/packages/generic/tool/v1.2.3/tool_darwin_arm64.tar.gz"},
})

Every rule CreateRelease states still applies: the tag must already exist and is never created, Commit is resolved and compared rather than sent, and a tag that already carries a release returns ErrAlreadyExists.

The tag check happens before any byte is uploaded, so a wrong commit costs you nothing.

CreateReleaseWithAssets can return a release and an error

This is the one thing to get right.

rel, err := pub.CreateReleaseWithAssets(ctx, owner, repo, draft, assets)
if err != nil && !errors.Is(err, forge.ErrNotHonoured) {
    return err // a real failure; nothing was created
}
// rel exists either way. Log err if non-nil: something was dropped.

The reflexive if err != nil { return err } treats a success as a failure, and because this makes no at-most-once claim, the retry that usually follows creates a second release — or fails with ErrAlreadyExists, which is the better of the two outcomes.

What it never does is succeed silently on a partial. The guarantee is the point of the capability, so a release that went out incomplete says so.

Two asset shapes, and support for them is inverted

A ReleaseAssetSource carries either Content (bytes you hold, with a required Size) or Location (an absolute http/https URL where the bytes already live). Setting both or neither is refused before any request is sent.

Provider Bytes you hold A location already hosted
GitLab wrapper over the link native
GitHub native no external-asset concept
Gitea / Codeberg native no external-asset concept

The platform weakest on one shape is strongest on the other, so which shape you supply changes what is possible. GitLab's release assets are links; GitHub and Gitea take only bytes.

On GitHub and Gitea a location becomes a footer, and is reported

The location is rendered as a link in an ### Assets footer appended to the release notes, and returned as ErrNotHonoured.

Release.GetAssets() will not contain it. If you count assets to decide whether a release is ready — and you should — that count will not include a location on those two forges.

Nothing is fetched. The provider does not download your URL to turn it into bytes.

Content is read once, so the value is single-use

// WRONG: the reader is drained after the first attempt.
for range 3 {
    rel, err = pub.CreateReleaseWithAssets(ctx, owner, repo, draft, assets)
    if err == nil {
        break
    }
}

A ReleaseAssetSource carrying Content cannot be reused. Rebuild the slice — reopening the file — for each attempt, or the retry publishes an empty asset, which is the failure this capability exists to prevent arriving one layer up.

A Location has no such problem.

UpdateRelease replaces the body wholesale. An update that does not carry the footer forward removes it:

// This drops any asset footer the provider wrote.
_ = pub.UpdateRelease(ctx, owner, repo, "v1.2.3", name, correctedNotes)

Which regions of a body are machine-owned is your policy, as it is everywhere else in this contract. If you rewrite notes after publishing, read the release back first and carry the footer with them.

For the same reason AddReleaseAssetLocation has no footer fallback: on a forge that cannot hold a location it returns ErrNotHonoured and writes nothing, because appending would mean a read-modify-write on every call. Use CreateReleaseWithAssets, where the body is composed once.

Fetch an asset at the conventional address

Once a release is published, the asset is reachable at the address that platform conventionally serves a release asset from, derived from the Name you gave it. That is the address to put in a Dockerfile or an install script — it survives the bytes moving, and it does not require knowing where the provider chose to host them:

GitLab            https://gitlab.com/<owner>/<repo>/-/releases/<tag>/downloads/<name>
GitHub, Gitea     the asset's browser_download_url

GitLab also serves /-/releases/permalink/latest/downloads/<name>, which needs no tag.

So Name is not merely a label. It is what a consumer will type — and on GitLab it is also what the address must be spellable as. Letters, digits, _, -, . and / are accepted there; a space, +, #, ?, %, @, ~, (, ), ,, : or a non-ASCII character are not.

An asset whose name uses one of those is still attached — it is fetchable at its own URL, and only the conventional address is lost — and the provider tells you with ErrNotHonoured, naming the asset in the hint. Worth knowing if your build names artefacts from a semver with build metadata, because the + alone reaches it.

Two rules worth knowing before you build against this

A location is never checked for reachability. No provider fetches or HEADs your URL, so a forge will record one that 404s. The guarantee is that no observer sees the release before its assets are attached; that the bytes are there is yours to check, and before publishing rather than after. Only the shape is validated — absolute, http or https, with a host.

AddReleaseAssetLocation refuses bytes. Hand it a source carrying Content and it errors before sending anything, rather than quietly uploading and becoming AddReleaseAsset. That refusal carries no sentinel — it is a programming mistake, not a condition to branch on — so do not test it with errors.Is.

If something fails to attach

On GitHub and Gitea the provider deletes the unpublished draft it created, so your retry behaves like a first attempt rather than hitting ErrAlreadyExists against a half-built release you have no way to finish or remove. The tag is untouched.

On GitLab there is nothing to clean up: the uploads precede the create, so a failure leaves no release at all.

See also