Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .agents/skills/release-devbox/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,16 @@ commits) stops with the command that fixes it.

Don't work around these; they're why the script exists.

- **Draft before tag.** goreleaser runs with `use_existing_draft` and attaches
its artifacts to whatever draft it finds, keeping the body. With no draft it
creates one whose body is a bare list of commit SHAs — that's what shipped as
the 0.17.5 release notes.
- **Draft before tag.** goreleaser runs with `release.disable` and only builds
`dist/`; `cli-release` uploads that to the release for the tag, looking it up
by tag with `gh`. With no draft it creates one from GitHub's generated notes —
usable, but not the notes you wrote. (goreleaser used to do the upload itself,
but it matched drafts by *title*, so any release with a real title got a
silent duplicate draft with bare-commit-SHA notes. That's what shipped as the
0.17.5 release notes, and what stalled 0.18.0.)
- **Publish after the build.** `docker-image-release` fires on the release event
and immediately downloads the release tarballs to bake into the image.
Publishing before goreleaser uploads them fails the Docker build. That's
Publishing before `cli-release` uploads them fails the Docker build. That's
exactly what happened on 0.17.3 and 0.17.5, both published from the GitHub UI,
which creates the tag and publishes in one action.
- **Flake bump before the `cli-tests` check.** The bump has to merge into `main`,
Expand All @@ -186,7 +189,7 @@ result and the current `flake.nix` version in one shot.
| --- | --- | --- |
| `cli-release` never started | Tag push didn't land | Re-run the same command |
| `cli-release` failed in `tests` | Red `main` (see below) | Fix the test, re-run |
| Draft has 0 assets | goreleaser didn't find the draft | Confirm the draft exists, re-run |
| Draft has 0 assets | The upload step didn't run or failed | Check `cli-release`'s "Attach artifacts" step, re-run |
| Published but installer serves the old version | `cli-post-release` failed or is still running | `gh run list --workflow=cli-post-release.yml` |
| Docker build failed | Published before assets uploaded | Re-run `docker-image-release` via `workflow_dispatch` with the tag |

Expand Down
36 changes: 35 additions & 1 deletion .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ jobs:
# match the version the binary reports.
version: ${{ github.ref_name }}
version_prefix: "devbox@"
- name: Release with goreleaser
- name: Build with goreleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
Expand All @@ -148,6 +148,40 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TELEMETRY_KEY: ${{ secrets.TELEMETRY_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
# `release.disable` is set in .goreleaser.yaml, so goreleaser only builds
# dist/ — the GitHub release belongs to this step. Both ways of cutting a
# release land here and both work:
#
# * `devbox run draft-release` created the draft, with its real title and
# hand-written notes, before pushing the tag. We upload onto it.
# * the tag was pushed by hand with no draft. We create one, titled after
# the tag with GitHub's generated notes, and upload onto that. It stays
# a draft for a human to retitle, rewrite and publish.
#
# Either way the release is looked up by tag, so a tag always resolves to
# exactly one release. goreleaser used to do this itself, but it matched
# existing drafts by *title*, so any release with a real title went
# unmatched and got a silent duplicate. See .goreleaser.yaml.
- name: Attach artifacts to the release for this tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release for $TAG already exists — attaching artifacts to it."
else
echo "No release for $TAG (tag pushed outside draft-release?) —" \
"creating a draft for it."
# Same rule as release.ts: a semver prerelease is anything with a "-".
prerelease=()
case "$TAG" in *-*) prerelease=(--prerelease) ;; esac
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --draft \
--title "$TAG" --generate-notes "${prerelease[@]}"
fi
# --clobber so a re-run of this workflow replaces the assets instead of
# failing on the ones already uploaded.
gh release upload "$TAG" dist/*.tar.gz dist/checksums.txt \
--repo "$GITHUB_REPOSITORY" --clobber
- name: Notify jetpack.io slack of release status
id: slack
if: always()
Expand Down
28 changes: 15 additions & 13 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,21 @@ checksum:
name_template: "checksums.txt"
algorithm: sha256
release:
prerelease: auto
draft: true
# Reuse an existing draft release for this tag instead of creating a new one.
# `keep-existing` leaves the release (and its description) as is if one already
# exists, so a manually-written description is preserved.
mode: keep-existing
replace_existing_draft: false
# GitHub's "get release by tag" API doesn't return drafts, so this is required
# for goreleaser to actually find and attach assets to the existing draft.
use_existing_draft: true
# Allow re-runs on the same tag to overwrite already-uploaded assets instead of
# failing with a 422 conflict.
replace_existing_artifacts: true
# This disables the *release* pipe only: goreleaser still builds every binary,
# archive and checksum into dist/ exactly as before. All it no longer does is
# create the GitHub release and upload the assets — cli-release does that with
# `gh release upload`, onto the draft scripts/release.ts made (title,
# hand-written notes, prerelease flag) before the tag was pushed.
#
# goreleaser used to do the upload with `use_existing_draft: true`, but that
# matches an existing draft by *title* (`r.GetDraft() && r.GetName() == tag` in
# its findDraftRelease), not by tag. Any draft whose title isn't verbatim the
# tag — i.e. every release with a real title — went unmatched and goreleaser
# silently created a second draft for the same tag, with a bare list of commit
# SHAs for notes and all the artifacts on it. That is what happened to 0.17.5
# and 0.18.0. Even a matched draft had its title overwritten by
# `name_template`, so a custom title could never survive.
disable: true
github:
owner: jetify-com
name: devbox
Expand Down
15 changes: 10 additions & 5 deletions scripts/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@
// invents them. Everything else is mechanical and happens in a fixed order,
// because that order is load-bearing:
//
// 1. The draft must exist before the tag is pushed. goreleaser runs with
// `use_existing_draft` and attaches its artifacts to whatever draft it
// finds, keeping the body. With no draft it creates one whose body is a
// bare list of commit SHAs — that is what shipped as the 0.17.5 notes.
// 1. The draft must exist before the tag is pushed, or your title and notes
// aren't what ships. goreleaser runs with `release.disable` and only builds
// dist/; cli-release uploads that to the release for the tag, creating a
// draft with GitHub's generated notes only if it finds none. So a tag
// pushed by hand still produces a usable draft — it just won't have the
// notes you wrote here. (goreleaser used to do the upload itself, but it
// matched existing drafts by title instead of tag, so it quietly created a
// second draft — with bare-commit-SHA notes — for any release that had a
// real title. That is where the 0.17.5 and 0.18.0 duplicates came from.)
//
// 2. The release must not be published until the build has uploaded its
// artifacts. `docker-image-release` fires on the release event and
Expand Down Expand Up @@ -838,7 +843,7 @@ async function stepWait(version: string): Promise<void> {
}
ok("cli-release succeeded");

// goreleaser attaches the tarballs to the draft. Publishing without them
// cli-release uploads the tarballs to this draft. Publishing without them
// fails the downstream Docker build, which downloads them immediately.
const release = getRelease(version);
if (!release || release.assetCount === 0) {
Expand Down
Loading