diff --git a/.agents/skills/release-devbox/SKILL.md b/.agents/skills/release-devbox/SKILL.md index e652bce8437..c9c35d0c036 100644 --- a/.agents/skills/release-devbox/SKILL.md +++ b/.agents/skills/release-devbox/SKILL.md @@ -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`, @@ -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 | diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index dab11c005d4..14152b9ebb2 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -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 @@ -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() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 27d9d816b60..7bef48cc7a7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 diff --git a/scripts/release.ts b/scripts/release.ts index f9b865116a7..82a3e4dd68a 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -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 @@ -838,7 +843,7 @@ async function stepWait(version: string): Promise { } 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) {