Skip to content

Commit d5d4174

Browse files
jedudenclaude
andauthored
Add publish-release command to flip draft releases to published (#294)
* Fix invalid env context in release workflow job-level env blocks The smoke-test and pages-deploy jobs redefined VERSION via ${{ env.VERSION }} in their job-level env: blocks, but the env context is not available there, failing workflow validation. The workflow-level env.VERSION is already inherited by all jobs and steps, so the redundant redefinitions are removed. https://claude.ai/code/session_014crGVw5Bwiv3nv4EoBimbz * Publish GitHub release as a final step for immutable releases The release job created the release already-published and then uploaded assets, which fails once immutable releases are enforced (post-publish asset uploads are rejected). Every asset now uploads to a draft release first; a new `mdsmith-release publish-release` subcommand flips the fully-populated draft to published as the final atomic step, but only on the tag-push path. The maintainer's UI-created draft path is left as a draft for manual publish. The mise smoke-test is gated to the non-draft path since it resolves the binary off the public GitHub release, which is not downloadable while the release is still a draft. https://claude.ai/code/session_014crGVw5Bwiv3nv4EoBimbz * test: cover publish-release command and reachable error paths Add unit tests for runPublishRelease (was 0% covered) and the PATCH transport / response-body read error branches in PublishRelease, bringing patch coverage above the Codecov gate. https://claude.ai/code/session_014crGVw5Bwiv3nv4EoBimbz * refactor: label publish errors and cover request-build path Extract newGitHubRequest so the lookup and publish calls share one request builder, drop the impossible json.Marshal error branch, and add an Op label to releaseLookupError so a failed publish PATCH no longer reports as "lookup ... unexpected status" (per review). Adds white-box tests for the request-build error path; added Go lines in the PR are now fully covered. https://claude.ai/code/session_014crGVw5Bwiv3nv4EoBimbz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41e61a5 commit d5d4174

9 files changed

Lines changed: 611 additions & 15 deletions

File tree

.github/workflows/release.yml

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,13 @@ jobs:
363363
id-token: write
364364
attestations: write
365365
steps:
366+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
367+
with:
368+
persist-credentials: false
369+
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
370+
with:
371+
go-version-file: go.mod
372+
cache: false
366373
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
367374
with:
368375
merge-multiple: true
@@ -412,17 +419,34 @@ jobs:
412419
cosign sign-blob \
413420
--bundle checksums.txt.bundle \
414421
checksums.txt
415-
- name: Create release
422+
- name: Upload assets to draft release
423+
# Always create/update the release as a draft so every asset
424+
# uploads while the release is still mutable. With immutable
425+
# releases enforced, uploading to an already-published
426+
# release is rejected — the publish must be the final step.
416427
env:
417428
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
418429
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
419430
with:
420-
draft: ${{ needs.trigger-guard.outputs.create_release_is_draft == 'true' }}
431+
draft: true
421432
generate_release_notes: true
422433
files: |
423434
mdsmith-*
424435
checksums.txt
425436
checksums.txt.bundle
437+
- name: Publish release
438+
# Path A (tag push): create_release_is_draft == 'false', so
439+
# flip the fully-populated draft to published as the final
440+
# atomic step — the result is an immutable release. Path B
441+
# (maintainer's UI-created draft): create_release_is_draft ==
442+
# 'true', so the release is left as a draft for manual
443+
# review and publish. Runtime logic lives in mdsmith-release
444+
# per docs/development/release-tooling.md.
445+
if: needs.trigger-guard.outputs.create_release_is_draft == 'false'
446+
env:
447+
RELEASE_TAG: ${{ env.VERSION }}
448+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
449+
run: go run ./cmd/mdsmith-release publish-release
426450

427451
smoke-test:
428452
# Wait until every channel is on the new version before checking
@@ -500,12 +524,19 @@ jobs:
500524
mdsmith version
501525
runs-on: ubuntu-latest
502526
container: ${{ matrix.container }}
503-
env:
504-
VERSION: ${{ env.VERSION }}
505527
steps:
528+
# The mise channel resolves the binary off the public GitHub
529+
# release (ubi backend). On the UI-draft path the release is
530+
# left as a draft, so its assets are not publicly downloadable
531+
# and mise cannot resolve them — skip mise there. npm/pip are
532+
# registry-backed and unaffected by the GitHub release state.
506533
- name: Install
534+
if: &smoke_channel_active >-
535+
matrix.channel != 'mise' ||
536+
needs.trigger-guard.outputs.create_release_is_draft == 'false'
507537
run: ${{ matrix.install }}
508538
- name: Verify version
539+
if: *smoke_channel_active
509540
run: |
510541
got=$(${{ matrix.run }})
511542
want="mdsmith ${VERSION}"
@@ -544,11 +575,6 @@ jobs:
544575
# it expects the binary already on PATH — so no pin lives
545576
# there.)
546577
HUGO_VERSION: "0.161.1"
547-
# The release tag drives the version Hugo renders in
548-
# ``.Site.Params.version`` and the on-disk pin in
549-
# website/hugo.toml. Stamp validates that the value has
550-
# no leading "v" before rewriting, so strip it here.
551-
VERSION: ${{ env.VERSION }}
552578
steps:
553579
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
554580
with:

cmd/mdsmith-release/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// mdsmith-release sync-docs <src-dir> <dst-dir>
1515
// mdsmith-release build-website [--no-fix] [src-dir] [dst-dir]
1616
// mdsmith-release check-release-trigger
17+
// mdsmith-release publish-release
1718
// mdsmith-release check-secret-rotations
1819
// mdsmith-release record-rotation <ENTRY_TITLE> <YYYY-MM-DD>
1920
//
@@ -44,6 +45,7 @@ Commands:
4445
build-website [--no-fix] [src] [dst]
4546
mdsmith fix (unless --no-fix) + sync-docs.
4647
check-release-trigger Emit release trigger guard outputs.
48+
publish-release Flip the tag's draft release to published.
4749
check-secret-rotations Open GitHub issues for secrets due for rotation.
4850
record-rotation <title> <date> Update lastRotated in a per-secret rotation file.
4951
`
@@ -81,6 +83,8 @@ func run(args []string) int {
8183
return runBuildWebsite(root, rest)
8284
case "check-release-trigger":
8385
return runCheckReleaseTrigger(root, rest)
86+
case "publish-release":
87+
return runPublishRelease(root, rest)
8488
case "check-secret-rotations":
8589
return runCheckSecretRotations(root, rest)
8690
case "record-rotation":
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
flag "github.com/spf13/pflag"
8+
9+
"github.com/jeduden/mdsmith/internal/release"
10+
)
11+
12+
func runPublishRelease(_ string, args []string) int {
13+
fs := flag.NewFlagSet("publish-release", flag.ContinueOnError)
14+
fs.Usage = func() {
15+
fmt.Fprintf(os.Stderr, "Usage: mdsmith-release publish-release\n\n"+
16+
"Flip the draft GitHub release for the current tag to a\n"+
17+
"published release. The `release` job uploads every asset\n"+
18+
"to a draft (still mutable); this is the final atomic step\n"+
19+
"that publishes it, yielding an immutable release. Reads\n"+
20+
"GITHUB_REPOSITORY, RELEASE_TAG, GITHUB_TOKEN, and\n"+
21+
"GITHUB_API_URL from the environment. Idempotent.\n")
22+
}
23+
if err := fs.Parse(args); err != nil {
24+
if code := reportFlagParseErr(err, os.Stderr, "mdsmith-release: publish-release"); code >= 0 {
25+
return code
26+
}
27+
}
28+
if fs.NArg() != 0 {
29+
fs.Usage()
30+
return 2
31+
}
32+
33+
return reportError(release.PublishRelease(release.PublishReleaseOptions{
34+
Repository: os.Getenv("GITHUB_REPOSITORY"),
35+
Tag: os.Getenv("RELEASE_TAG"),
36+
Token: os.Getenv("GITHUB_TOKEN"),
37+
APIBaseURL: os.Getenv("GITHUB_API_URL"),
38+
}))
39+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestRunPublishReleaseFlipsDraft(t *testing.T) {
12+
var patched bool
13+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14+
if r.Method == http.MethodPatch {
15+
patched = true
16+
_, _ = w.Write([]byte(`{"id":42,"draft":false}`))
17+
return
18+
}
19+
_, _ = w.Write([]byte(`{"id":42,"draft":true}`))
20+
}))
21+
t.Cleanup(srv.Close)
22+
23+
t.Setenv("GITHUB_REPOSITORY", "jeduden/mdsmith")
24+
t.Setenv("RELEASE_TAG", "v1.2.3")
25+
t.Setenv("GITHUB_TOKEN", "test-token")
26+
t.Setenv("GITHUB_API_URL", srv.URL)
27+
28+
assert.Equal(t, 0, run([]string{"publish-release"}))
29+
assert.True(t, patched)
30+
}
31+
32+
// TestRunPublishReleaseReportsError covers the reportError branch
33+
// when PublishRelease fails (here: no token in the environment).
34+
func TestRunPublishReleaseReportsError(t *testing.T) {
35+
t.Setenv("GITHUB_REPOSITORY", "jeduden/mdsmith")
36+
t.Setenv("RELEASE_TAG", "v1.2.3")
37+
t.Setenv("GITHUB_TOKEN", "")
38+
39+
assert.Equal(t, 1, run([]string{"publish-release"}))
40+
}
41+
42+
// TestRunPublishReleaseFlagParseError covers the
43+
// reportFlagParseErr branch for an unknown flag.
44+
func TestRunPublishReleaseFlagParseError(t *testing.T) {
45+
assert.Equal(t, 2, run([]string{"publish-release", "--bogus"}))
46+
}
47+
48+
// TestRunPublishReleaseRejectsPositionalArgs covers the
49+
// fs.NArg() != 0 usage branch.
50+
func TestRunPublishReleaseRejectsPositionalArgs(t *testing.T) {
51+
assert.Equal(t, 2, run([]string{"publish-release", "extra"}))
52+
}

docs/development/release-tooling.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ setup step.
5151
|----------------------------|--------------------------------|
5252
| `stamp <version>` | `release.yml` publishing jobs |
5353
| `check-release-trigger` | `release.yml` trigger-guard |
54+
| `publish-release` | `release.yml` release job |
5455
| `check` | `ci.yml` version-guard |
5556
| `build-npm <art> <out>` | `release.yml` npm job |
5657
| `build-wheels <art> <out>` | `release.yml` pypi job |

docs/development/release.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,22 @@ the `create` preflight sees no draft release yet.
6464
`pull_request_target`, `workflow_run`, and
6565
`release`. Those triggers can mint OIDC tokens or
6666
reach the PATs from a non-tag context. The `release`
67-
event would also still miss draft creation. When
68-
the run started from `create`, the final GitHub
69-
Release upload keeps the release in draft state. A
70-
normal tag push keeps the current
71-
published-release behavior.
67+
event would also still miss draft creation.
68+
69+
The `release` job always uploads every asset to a
70+
**draft** release first. It then publishes the
71+
release as a separate final step via
72+
`mdsmith-release publish-release`. Uploading to a
73+
published release is rejected once immutable
74+
releases are on. So the publish must be the last
75+
action.
76+
77+
A normal tag push
78+
(`create_release_is_draft == 'false'`) auto-publishes
79+
the full draft. The result is an immutable release.
80+
A maintainer's UI-created draft
81+
(`create_release_is_draft == 'true'`) is left as a
82+
draft for manual review and publish.
7283

7384
`concurrency: { group: release, cancel-in-progress: false }`
7485
still serializes real releases tag-agnostically.

0 commit comments

Comments
 (0)