Skip to content

Commit 6433a8d

Browse files
Copilotjeduden
andauthored
Move release trigger guard into mdsmith-release
Agent-Logs-Url: https://github.com/jeduden/mdsmith/sessions/963e287d-3f99-463e-8224-0d9ebb1d4d93 Co-authored-by: jeduden <1117699+jeduden@users.noreply.github.com>
1 parent 15ff653 commit 6433a8d

9 files changed

Lines changed: 503 additions & 85 deletions

File tree

.github/workflows/release.yml

Lines changed: 21 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@ permissions:
1212
env:
1313
VERSION: ${{ github.event_name == 'create' && github.event.ref || github.ref_name }}
1414

15-
# Serialize every release run so two publish jobs cannot mint OIDC
16-
# tokens against the same registry at the same time. The group is
17-
# tag-agnostic — different tags pushed close together queue rather
18-
# than overlap. `cancel-in-progress: false` keeps the first run
19-
# going (cancelling mid-publish would leave the scoped platform
20-
# packages out of sync with the root package).
15+
# Serialize only release-intent runs so two publish jobs cannot mint
16+
# OIDC tokens against the same registry at the same time. Ordinary
17+
# branch-creation `create` events get a per-run group and never queue
18+
# behind a real release. `cancel-in-progress: false` keeps the first
19+
# release going (cancelling mid-publish would leave the scoped
20+
# platform packages out of sync with the root package).
2121
concurrency:
22-
group: release
22+
group: >-
23+
${{
24+
(github.event_name != 'create' ||
25+
(github.event.ref_type == 'tag' && startsWith(github.event.ref, 'v'))) &&
26+
'release' ||
27+
format('release-nonrelease-{0}-{1}', github.run_id, github.run_attempt)
28+
}}
2329
cancel-in-progress: false
2430

2531
jobs:
@@ -32,77 +38,15 @@ jobs:
3238
- name: Decide whether this trigger should publish
3339
id: decide
3440
env:
41+
EVENT_NAME: ${{ github.event_name }}
42+
CREATE_REF_TYPE: ${{ github.event.ref_type }}
43+
RELEASE_TAG: ${{ env.VERSION }}
3544
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36-
RELEASE_TAG: ${{ github.event_name == 'create' && github.event.ref || github.ref_name }}
37-
RELEASE_LOOKUP_RETRY_ATTEMPTS: "3"
38-
RELEASE_LOOKUP_RETRY_DELAY_SECONDS: "2"
39-
run: |
40-
if [ "${{ github.event_name }}" != "create" ]; then
41-
echo "should_run=true" >> "$GITHUB_OUTPUT"
42-
echo "create_release_is_draft=false" >> "$GITHUB_OUTPUT"
43-
exit 0
44-
fi
45-
46-
if [ "${{ github.event.ref_type }}" != "tag" ]; then
47-
echo "should_run=false" >> "$GITHUB_OUTPUT"
48-
echo "create_release_is_draft=false" >> "$GITHUB_OUTPUT"
49-
exit 0
50-
fi
51-
52-
case "${RELEASE_TAG}" in
53-
v*) ;;
54-
*)
55-
echo "should_run=false" >> "$GITHUB_OUTPUT"
56-
echo "create_release_is_draft=false" >> "$GITHUB_OUTPUT"
57-
exit 0
58-
;;
59-
esac
60-
61-
status=404
62-
for attempt in $(seq 1 "${RELEASE_LOOKUP_RETRY_ATTEMPTS}"); do
63-
status=$(
64-
curl \
65-
--silent \
66-
--show-error \
67-
--location \
68-
--output /tmp/release.json \
69-
--write-out '%{http_code}' \
70-
-H "Accept: application/vnd.github+json" \
71-
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
72-
-H "X-GitHub-Api-Version: 2022-11-28" \
73-
"https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
74-
)
75-
if [ "${status}" != "404" ] || \
76-
[ "${attempt}" = "${RELEASE_LOOKUP_RETRY_ATTEMPTS}" ]; then
77-
break
78-
fi
79-
sleep "${RELEASE_LOOKUP_RETRY_DELAY_SECONDS}"
80-
done
81-
82-
case "${status}" in
83-
200)
84-
draft=$(jq -r '(.draft // false) | tostring' /tmp/release.json)
85-
if [ "${draft}" = "true" ]; then
86-
echo "should_run=true" >> "$GITHUB_OUTPUT"
87-
echo "create_release_is_draft=true" >> "$GITHUB_OUTPUT"
88-
exit 0
89-
fi
90-
91-
echo "should_run=false" >> "$GITHUB_OUTPUT"
92-
echo "create_release_is_draft=false" >> "$GITHUB_OUTPUT"
93-
exit 0
94-
;;
95-
404)
96-
echo "should_run=false" >> "$GITHUB_OUTPUT"
97-
echo "create_release_is_draft=false" >> "$GITHUB_OUTPUT"
98-
exit 0
99-
;;
100-
*)
101-
echo "failed to inspect release for ${RELEASE_TAG}; api status ${status}" >&2
102-
cat /tmp/release.json >&2
103-
exit 1
104-
;;
105-
esac
45+
# The runtime logic lives in mdsmith-release per
46+
# docs/development/release-tooling.md. The step itself stays a
47+
# fixed one-liner so no event payload is interpolated into an
48+
# inline shell script (zizmor template-injection finding).
49+
run: go run ./cmd/mdsmith-release check-release-trigger
10650

10751
build:
10852
needs: [trigger-guard]

cmd/mdsmith-release/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// mdsmith-release build-wheels <artifacts-dir> <out-dir>
1414
// mdsmith-release sync-docs <src-dir> <dst-dir>
1515
// mdsmith-release build-website [--no-fix] [src-dir] [dst-dir]
16+
// mdsmith-release check-release-trigger
1617
// mdsmith-release check-secret-rotations
1718
// mdsmith-release record-rotation <ENTRY_TITLE> <YYYY-MM-DD>
1819
//
@@ -42,6 +43,7 @@ Commands:
4243
sync-docs <src> <dst> Snapshot docs/ into a Hugo content tree.
4344
build-website [--no-fix] [src] [dst]
4445
mdsmith fix (unless --no-fix) + sync-docs.
46+
check-release-trigger Emit release trigger guard outputs.
4547
check-secret-rotations Open GitHub issues for secrets due for rotation.
4648
record-rotation <title> <date> Update lastRotated in a per-secret rotation file.
4749
`
@@ -77,6 +79,8 @@ func run(args []string) int {
7779
return runSyncDocs(root, rest)
7880
case "build-website":
7981
return runBuildWebsite(root, rest)
82+
case "check-release-trigger":
83+
return runCheckReleaseTrigger(root, rest)
8084
case "check-secret-rotations":
8185
return runCheckSecretRotations(root, rest)
8286
case "record-rotation":

cmd/mdsmith-release/main_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func TestRunRejectsBadArity(t *testing.T) {
5959
{"build-wheels without args", []string{"build-wheels"}},
6060
{"build-wheels with one arg", []string{"build-wheels", "art"}},
6161
{"build-website with three positionals", []string{"build-website", "a", "b", "c"}},
62+
{"check-release-trigger with extra arg", []string{"check-release-trigger", "extra"}},
6263
}
6364
for _, c := range cases {
6465
assert.Equal(t, 2, run(c.args), c.name)
@@ -95,15 +96,21 @@ func TestReportFlagParseErrNilReturnsContinue(t *testing.T) {
9596
// of reportFlagParseErr per subcommand. pflag prints the Usage
9697
// itself, so the dispatcher just needs to surface exit code 0.
9798
func TestSubcommandHelpExitsZero(t *testing.T) {
98-
for _, sub := range []string{"stamp", "check", "build-npm", "build-wheels", "sync-docs", "build-website"} {
99+
for _, sub := range []string{
100+
"stamp", "check", "build-npm", "build-wheels",
101+
"sync-docs", "build-website", "check-release-trigger",
102+
} {
99103
assert.Equal(t, 0, run([]string{sub, "--help"}), "%s --help", sub)
100104
}
101105
}
102106

103107
// TestSubcommandRejectsUnknownFlag exercises the non-help, non-nil
104108
// branch of reportFlagParseErr.
105109
func TestSubcommandRejectsUnknownFlag(t *testing.T) {
106-
for _, sub := range []string{"stamp", "check", "build-npm", "build-wheels", "sync-docs", "build-website"} {
110+
for _, sub := range []string{
111+
"stamp", "check", "build-npm", "build-wheels",
112+
"sync-docs", "build-website", "check-release-trigger",
113+
} {
107114
assert.Equal(t, 2, run([]string{sub, "--bogus"}), "%s --bogus", sub)
108115
}
109116
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 runCheckReleaseTrigger(_ string, args []string) int {
13+
fs := flag.NewFlagSet("check-release-trigger", flag.ContinueOnError)
14+
fs.Usage = func() {
15+
fmt.Fprintf(os.Stderr, "Usage: mdsmith-release check-release-trigger\n\n"+
16+
"Inspect the current GitHub Actions event and write\n"+
17+
"`should_run` / `create_release_is_draft` outputs for\n"+
18+
"release.yml's create-event guard. Reads EVENT_NAME,\n"+
19+
"CREATE_REF_TYPE, RELEASE_TAG, GITHUB_REPOSITORY,\n"+
20+
"GITHUB_TOKEN, GITHUB_API_URL, and GITHUB_OUTPUT from\n"+
21+
"the environment.\n")
22+
}
23+
if err := fs.Parse(args); err != nil {
24+
if code := reportFlagParseErr(err, os.Stderr, "mdsmith-release: check-release-trigger"); code >= 0 {
25+
return code
26+
}
27+
}
28+
if fs.NArg() != 0 {
29+
fs.Usage()
30+
return 2
31+
}
32+
33+
res, err := release.CheckReleaseTrigger(release.TriggerGuardOptions{
34+
EventName: os.Getenv("EVENT_NAME"),
35+
Repository: os.Getenv("GITHUB_REPOSITORY"),
36+
RefName: os.Getenv("RELEASE_TAG"),
37+
RefType: os.Getenv("CREATE_REF_TYPE"),
38+
Token: os.Getenv("GITHUB_TOKEN"),
39+
APIBaseURL: os.Getenv("GITHUB_API_URL"),
40+
})
41+
if err != nil {
42+
return reportError(err)
43+
}
44+
if err := writeReleaseTriggerGuardOutput(os.Getenv("GITHUB_OUTPUT"), res); err != nil {
45+
return reportError(err)
46+
}
47+
return 0
48+
}
49+
50+
func writeReleaseTriggerGuardOutput(path string, res release.TriggerGuardResult) error {
51+
lines := fmt.Sprintf(
52+
"should_run=%t\ncreate_release_is_draft=%t\n",
53+
res.ShouldRun,
54+
res.CreateReleaseIsDraft,
55+
)
56+
if path == "" {
57+
_, err := fmt.Print(lines)
58+
return err
59+
}
60+
61+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
62+
if err != nil {
63+
return err
64+
}
65+
defer func() { _ = f.Close() }()
66+
67+
_, err = f.WriteString(lines)
68+
return err
69+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestRunCheckReleaseTriggerWritesGitHubOutput(t *testing.T) {
15+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
16+
_, _ = w.Write([]byte(`{"draft":true}`))
17+
}))
18+
t.Cleanup(srv.Close)
19+
20+
out := filepath.Join(t.TempDir(), "github-output.txt")
21+
t.Setenv("EVENT_NAME", "create")
22+
t.Setenv("CREATE_REF_TYPE", "tag")
23+
t.Setenv("RELEASE_TAG", "v1.2.3")
24+
t.Setenv("GITHUB_REPOSITORY", "jeduden/mdsmith")
25+
t.Setenv("GITHUB_TOKEN", "test-token")
26+
t.Setenv("GITHUB_API_URL", srv.URL)
27+
t.Setenv("GITHUB_OUTPUT", out)
28+
29+
assert.Equal(t, 0, run([]string{"check-release-trigger"}))
30+
31+
body, err := os.ReadFile(out)
32+
require.NoError(t, err)
33+
assert.Equal(t, "should_run=true\ncreate_release_is_draft=true\n", string(body))
34+
}

docs/development/release-tooling.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ setup step.
5050
| Subcommand | Invoked by |
5151
|----------------------------|--------------------------------|
5252
| `stamp <version>` | `release.yml` publishing jobs |
53+
| `check-release-trigger` | `release.yml` trigger-guard |
5354
| `check` | `ci.yml` version-guard |
5455
| `build-npm <art> <out>` | `release.yml` npm job |
5556
| `build-wheels <art> <out>` | `release.yml` pypi job |

docs/development/release.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ path. `release.yml` also listens to the repo's
5151
a draft release creates a new `v*` tag. GitHub does
5252
not fire `release` workflows for draft creation. On
5353
`create` runs, a preflight job checks
54-
`github.event.ref_type=tag`, `github.event.ref`
55-
starting with `v`, and the GitHub Releases API for
56-
an existing draft release on that tag. A normal
54+
`github.event.ref_type == 'tag'`, `github.event.ref`
55+
starting with `v`, and — via
56+
`mdsmith-release check-release-trigger` — the GitHub
57+
Releases API for an existing draft release on that
58+
tag. A normal
5759
`git push origin vX.Y.Z` still fires both `create`
5860
and `push`, but only the `push` run proceeds because
5961
the `create` preflight sees no draft release yet.
@@ -69,9 +71,11 @@ normal tag push keeps the current
6971
published-release behavior.
7072

7173
`concurrency: { group: release, cancel-in-progress: false }`
72-
serializes every run tag-agnostically. A second
73-
push or tag-creation event queues. The flag lets
74-
the in-flight publish finish; cancelling
74+
still serializes real releases tag-agnostically.
75+
Non-release `create` events get a per-run concurrency
76+
group, so they do not queue behind the release lock.
77+
78+
The flag lets the in-flight publish finish. Cancelling
7579
mid-publish would desync the platform packages from
7680
the root.
7781

0 commit comments

Comments
 (0)