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.
The footer is written once¶
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¶
- Providers for the full support matrix
- Errors for
ErrNotHonouredand the refusal sentinels - Verify checksums and signatures, which is what this module deliberately does not do to the bytes it attaches