Skip to content

Commit 0e8a714

Browse files
Aurelioloclaude
andauthored
feat: dev channel builds with incremental pre-releases between stable releases (#715)
## Summary - Every push to main between stable releases creates a PEP 440 dev pre-release (e.g. `v0.4.7.dev3`) with full pipeline: Docker images, CLI binaries, cosign signatures, SLSA provenance - CLI supports `stable` / `dev` update channels via `synthorg config set channel dev` - Dev builds reuse existing Docker + CLI workflows by creating proper `v*` tags ## Changes ### New workflow: `dev-release.yml` - Computes dev version from Release Please pending PR + commit count since last tag - Creates lightweight git tag via GitHub API + GitHub pre-release (marked pre-release) - Auto-cleans old dev pre-releases (keeps 5 most recent) - Concurrency group prevents race conditions between parallel runs ### Docker workflow - Adds rolling `dev` tag for dev builds (only on `refs/tags/v*.dev*`) - Prevents `major.minor` tag on dev builds (avoids overwriting stable `0.4` tag) ### Finalize-release workflow - Guards against publishing dev pre-releases (they're already non-draft) ### CLI (`cli/`) - `Channel` config field (`stable` / `dev`) with validation - `synthorg config set channel dev/stable` command - `synthorg config show` and `synthorg status` display current channel - `CheckForChannel` / `CheckDev` in selfupdate with dev-aware version comparison - Generic `fetchJSON[T]` replaces duplicate HTTP fetch functions - `User-Agent` header on all GitHub API requests - `log_level` validation with allowlist (was accepting arbitrary values) - Table-driven tests for `splitDev`, `compareWithDev`, `isDevUpdateAvailable` ### Docs - CLAUDE.md: dev-release workflow, dev channel flow, config command, dev tags - README.md: config command in quick start ## Pre-PR Review 4 agents ran (go-reviewer, infra-reviewer, security-reviewer, docs-consistency), 18 findings addressed in the second commit. ## Test plan - [ ] Push to main triggers `dev-release.yml`, creates `v0.4.7.devN` tag + pre-release - [ ] Docker workflow triggers on dev tag, builds all 3 images with `dev` + version tags - [ ] CLI workflow triggers, GoReleaser produces binaries with dev version - [ ] `finalize-release` does NOT publish dev pre-releases - [ ] `synthorg config set channel dev` persists in config.json - [ ] `synthorg update` on dev channel finds dev pre-releases - [ ] Stable release always preferred over dev at same base version - [ ] Old dev pre-releases cleaned up (>5 removed) - [ ] `go test ./...` passes (including new version comparison tests) Closes #713 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d16a0ac commit 0e8a714

17 files changed

Lines changed: 825 additions & 36 deletions

File tree

.github/workflows/dev-release.yml

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
name: Dev Release
2+
3+
# Create incremental pre-release builds on every push to main between stable
4+
# releases. Computes a PEP 440 dev version (e.g. v0.4.7.dev3) and creates a
5+
# lightweight git tag + GitHub pre-release. The tag triggers the existing
6+
# Docker and CLI workflows for full build/scan/sign pipeline.
7+
8+
on:
9+
push:
10+
branches: [main]
11+
12+
permissions: {}
13+
14+
concurrency:
15+
group: dev-release
16+
cancel-in-progress: true
17+
18+
jobs:
19+
dev-release:
20+
name: Create Dev Pre-Release
21+
# Skip Release Please version-bump merges and tag pushes (handled by
22+
# the stable release pipeline). Also skip if a v* tag already points
23+
# at this commit (the stable release just landed).
24+
if: >-
25+
!startsWith(github.event.head_commit.message, 'chore(main): release')
26+
&& !contains(github.event.head_commit.message, 'Release-As:')
27+
runs-on: ubuntu-latest
28+
timeout-minutes: 5
29+
permissions:
30+
contents: write
31+
steps:
32+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
33+
with:
34+
fetch-depth: 0
35+
persist-credentials: false
36+
37+
- name: Check if commit already has a stable tag
38+
id: check-tag
39+
run: |
40+
# If this commit already has a v* tag (without .dev), skip -- it is
41+
# a stable release and will be handled by Release Please.
42+
TAGS=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)
43+
if [ -n "$TAGS" ]; then
44+
echo "skip=true" >> "$GITHUB_OUTPUT"
45+
echo "Commit already tagged as stable release: $TAGS"
46+
else
47+
echo "skip=false" >> "$GITHUB_OUTPUT"
48+
fi
49+
50+
- name: Compute dev version
51+
if: steps.check-tag.outputs.skip != 'true'
52+
id: version
53+
env:
54+
GH_TOKEN: ${{ github.token }}
55+
run: |
56+
# 1. Find last stable release tag (exclude dev tags)
57+
LAST_TAG=$(git tag --sort=-v:refname --list "v[0-9]*.[0-9]*.[0-9]*" | grep -vE '\.dev[0-9]+$' | head -1)
58+
LAST_TAG=${LAST_TAG:-""}
59+
if [ -z "$LAST_TAG" ]; then
60+
echo "::error::No stable release tag found"
61+
exit 1
62+
fi
63+
echo "Last stable tag: $LAST_TAG"
64+
65+
# 2. Get next version from Release Please pending PR
66+
NEXT_VERSION=$(gh pr list --label "autorelease: pending" --state open --json title --jq '.[0].title // ""' | grep -oP '\d+\.\d+\.\d+' || true)
67+
if [ -z "$NEXT_VERSION" ]; then
68+
# Fallback: bump patch from last tag
69+
LAST_VERSION="${LAST_TAG#v}"
70+
IFS='.' read -r MAJOR MINOR PATCH <<< "$LAST_VERSION"
71+
NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
72+
echo "No Release Please PR found, computed next version: $NEXT_VERSION"
73+
else
74+
echo "Next version from Release Please: $NEXT_VERSION"
75+
fi
76+
77+
# 3. Count commits since last stable tag
78+
DEV_NUM=$(git rev-list "${LAST_TAG}..HEAD" --count)
79+
if [ "$DEV_NUM" -eq 0 ]; then
80+
echo "::notice::No commits since $LAST_TAG, skipping dev release"
81+
echo "skip=true" >> "$GITHUB_OUTPUT"
82+
exit 0
83+
fi
84+
85+
DEV_TAG="v${NEXT_VERSION}.dev${DEV_NUM}"
86+
echo "Dev version: $DEV_TAG"
87+
echo "dev_tag=$DEV_TAG" >> "$GITHUB_OUTPUT"
88+
echo "dev_num=$DEV_NUM" >> "$GITHUB_OUTPUT"
89+
echo "next_version=$NEXT_VERSION" >> "$GITHUB_OUTPUT"
90+
echo "skip=false" >> "$GITHUB_OUTPUT"
91+
92+
- name: Check if dev tag already exists
93+
if: steps.check-tag.outputs.skip != 'true' && steps.version.outputs.skip != 'true'
94+
id: tag-exists
95+
env:
96+
DEV_TAG: ${{ steps.version.outputs.dev_tag }}
97+
run: |
98+
if git rev-parse "$DEV_TAG" >/dev/null 2>&1; then
99+
echo "skip=true" >> "$GITHUB_OUTPUT"
100+
echo "Tag $DEV_TAG already exists, skipping"
101+
else
102+
echo "skip=false" >> "$GITHUB_OUTPUT"
103+
fi
104+
105+
- name: Create dev tag and pre-release
106+
if: >-
107+
steps.check-tag.outputs.skip != 'true'
108+
&& steps.version.outputs.skip != 'true'
109+
&& steps.tag-exists.outputs.skip != 'true'
110+
env:
111+
GH_TOKEN: ${{ github.token }}
112+
DEV_TAG: ${{ steps.version.outputs.dev_tag }}
113+
DEV_NUM: ${{ steps.version.outputs.dev_num }}
114+
NEXT_VERSION: ${{ steps.version.outputs.next_version }}
115+
run: |
116+
SHORT_SHA="${GITHUB_SHA::7}"
117+
118+
# Create tag + pre-release atomically (gh release create --target
119+
# creates the tag if it does not exist, avoiding orphaned tags on
120+
# partial failure).
121+
gh release create "$DEV_TAG" \
122+
--target "$GITHUB_SHA" \
123+
--prerelease \
124+
--title "$DEV_TAG" \
125+
--notes "Dev build #${DEV_NUM} toward v${NEXT_VERSION}
126+
127+
**Commit:** ${SHORT_SHA}
128+
**Full pipeline:** Docker images, CLI binaries, cosign signatures, and SLSA provenance will be attached by downstream workflows.
129+
130+
> This is a pre-release for testing. Use \`synthorg config set channel dev\` to opt in."
131+
132+
- name: Clean up old dev pre-releases
133+
if: >-
134+
steps.check-tag.outputs.skip != 'true'
135+
&& steps.version.outputs.skip != 'true'
136+
&& steps.tag-exists.outputs.skip != 'true'
137+
env:
138+
GH_TOKEN: ${{ github.token }}
139+
run: |
140+
# Keep the 5 most recent dev pre-releases, delete the rest
141+
gh release list --limit 50 --json tagName,isPrerelease,createdAt \
142+
--jq '[.[] | select(.isPrerelease and (.tagName | contains(".dev")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName' \
143+
| while read -r tag; do
144+
echo "Deleting old dev release: $tag"
145+
gh release delete "$tag" --yes --cleanup-tag 2>/dev/null || true
146+
done

.github/workflows/docker.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,10 @@ jobs:
8888
with:
8989
images: ghcr.io/aureliolo/synthorg-backend
9090
tags: |
91-
type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
91+
type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '.dev') }}
9292
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
93-
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
93+
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '.dev') }}
94+
type=raw,value=dev,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '.dev') }}
9495
type=sha,prefix=sha-
9596
9697
# Build locally first, scan, then push only if scans pass
@@ -276,9 +277,10 @@ jobs:
276277
with:
277278
images: ghcr.io/aureliolo/synthorg-web
278279
tags: |
279-
type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
280+
type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '.dev') }}
280281
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
281-
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
282+
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '.dev') }}
283+
type=raw,value=dev,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '.dev') }}
282284
type=sha,prefix=sha-
283285
284286
# Build locally first, scan, then push only if scans pass
@@ -460,9 +462,10 @@ jobs:
460462
with:
461463
images: ghcr.io/aureliolo/synthorg-sandbox
462464
tags: |
463-
type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
465+
type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '.dev') }}
464466
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
465-
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
467+
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '.dev') }}
468+
type=raw,value=dev,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '.dev') }}
466469
type=sha,prefix=sha-
467470
468471
# Build locally first, scan, then push only if scans pass

.github/workflows/finalize-release.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ jobs:
2121
# Only process tag-triggered release builds, not PR-triggered runs.
2222
# The event != 'pull_request' guard prevents a PR that modifies the
2323
# Docker/CLI workflows from reaching this privileged publish step.
24+
# Only publish stable releases (v0.4.7), not dev pre-releases (v0.4.7.dev3).
25+
# Dev pre-releases are created as non-draft by dev-release.yml and need no
26+
# finalization -- their Docker/CLI assets attach directly to the pre-release.
2427
if: >-
2528
github.event.workflow_run.event == 'push'
2629
&& github.event.workflow_run.head_repository.full_name == github.repository
2730
&& startsWith(github.event.workflow_run.head_branch, 'v')
31+
&& !contains(github.event.workflow_run.head_branch, '.dev')
2832
runs-on: ubuntu-latest
2933
permissions:
3034
actions: read

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ curl http://localhost:3000/api/v1/health # backend (via web proxy)
9595
- **Images**: backend (Chainguard distroless, non-root), web (nginx-unprivileged, SPA + API proxy), sandbox (Python + Node.js, non-root)
9696
- **Config**: all Docker files in `docker/` -- Dockerfiles, compose, `.env.example`. Single root `.dockerignore` (all images build with `context: .`)
9797
- **Verification**: CLI verifies cosign signatures + SLSA provenance at pull time; bypass with `--skip-verify`
98-
- **Tags**: version from `pyproject.toml`, semver, and SHA
98+
- **Tags**: version from `pyproject.toml`, semver, SHA, plus dev tags (`v0.4.7.dev3`, `dev` rolling) for dev channel builds
9999

100100
## Package Structure
101101

@@ -131,7 +131,7 @@ web/src/ # Vue 3 + PrimeVue + Tailwind CSS dashboard
131131
__tests__/ # Vitest unit tests
132132
133133
cli/ # Go CLI binary (cross-platform, manages Docker lifecycle)
134-
cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, cleanup, wipe, etc.)
134+
cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, cleanup, wipe, config, etc.)
135135
internal/ # version, config, docker, compose, health, diagnostics, selfupdate, completion, ui, verify
136136
137137
site/ # Astro landing page (synthorg.io)
@@ -223,6 +223,7 @@ site/ # Astro landing page (synthorg.io)
223223
- **Version bumping** (pre-1.0): `fix:`/`feat:` = patch, `feat!:`/`BREAKING CHANGE` = minor. Post-1.0: standard semver
224224
- **`Release-As` trailer**: add `Release-As: 0.4.0` as the **final paragraph** of the PR body (separated by blank line). Mid-body placement is silently ignored.
225225
- **Release flow**: merge release PR -> draft Release + tag -> Docker + CLI workflows attach assets -> finalize-release publishes
226+
- **Dev channel**: every push to `main` (except Release Please bumps) creates a dev pre-release (e.g. `v0.4.7.dev3`) via `dev-release.yml`. Users opt in with `synthorg config set channel dev`. Dev releases flow through the same Docker + CLI pipelines as stable releases.
226227
- **Config**: `.github/release-please-config.json`, `.github/.release-please-manifest.json` (do not edit manually)
227228
- **Changelog**: `.github/CHANGELOG.md` (auto-generated, do not edit)
228229
- **Version locations**: `pyproject.toml` (`[tool.commitizen].version`), `src/synthorg/__init__.py` (`__version__`)
@@ -241,7 +242,8 @@ site/ # Astro landing page (synthorg.io)
241242
- **Dependency review**: `dependency-review.yml` -- license allow-list (permissive only), PR comment summaries
242243
- **CLA**: `cla.yml` -- contributor-assistant check on PRs, signatures in `.github/cla-signatures.json`
243244
- **Release**: `release.yml` -- Release Please creates draft release PR. Uses `RELEASE_PLEASE_TOKEN` (PAT)
244-
- **Finalize Release**: `finalize-release.yml` -- publishes draft after Docker + CLI workflows succeed for tag. Immutable releases enabled.
245+
- **Dev Release**: `dev-release.yml` -- creates PEP 440 dev tags (e.g. `v0.4.7.dev3`) and GitHub pre-releases on every push to main (skips Release Please version-bump commits). Tags trigger existing Docker + CLI workflows for full build/scan/sign pipeline. Old dev pre-releases auto-cleaned (keeps 5 most recent).
246+
- **Finalize Release**: `finalize-release.yml` -- publishes draft after Docker + CLI workflows succeed for tag. Immutable releases enabled. Skips dev pre-releases.
245247

246248
## Dependencies
247249

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ synthorg init # interactive setup wizard
102102
synthorg start # pull images + start containers
103103
synthorg status # check health
104104
synthorg doctor # diagnostics if something is wrong
105+
synthorg config set channel dev # opt in to pre-release builds
105106
synthorg wipe # factory-reset: backup, wipe data, restart fresh
106107
synthorg cleanup # remove old container images
107108
```

cli/cmd/config.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import (
55
"fmt"
66
"os"
77
"strconv"
8+
"strings"
89

910
"github.com/Aureliolo/synthorg/cli/internal/config"
1011
"github.com/Aureliolo/synthorg/cli/internal/ui"
1112
"github.com/spf13/cobra"
1213
)
1314

15+
// supportedConfigKeys is the single source of truth for `config set` key names.
16+
var supportedConfigKeys = []string{"channel", "log_level"}
17+
1418
var configCmd = &cobra.Command{
1519
Use: "config",
1620
Short: "Manage SynthOrg configuration",
@@ -29,8 +33,21 @@ var configShowCmd = &cobra.Command{
2933
RunE: runConfigShow,
3034
}
3135

36+
var configSetCmd = &cobra.Command{
37+
Use: "set <key> <value>",
38+
Short: "Set a configuration value",
39+
Long: `Set a configuration value.
40+
41+
Supported keys:
42+
channel Update channel: "stable" or "dev"
43+
log_level Log verbosity: "debug", "info", "warn", "error"`,
44+
Args: cobra.ExactArgs(2),
45+
RunE: runConfigSet,
46+
}
47+
3248
func init() {
3349
configCmd.AddCommand(configShowCmd)
50+
configCmd.AddCommand(configSetCmd)
3451
rootCmd.AddCommand(configCmd)
3552
}
3653

@@ -61,6 +78,7 @@ func runConfigShow(cmd *cobra.Command, _ []string) error {
6178
out.KeyValue("Config file", statePath)
6279
out.KeyValue("Data directory", state.DataDir)
6380
out.KeyValue("Image tag", state.ImageTag)
81+
out.KeyValue("Channel", state.DisplayChannel())
6482
out.KeyValue("Backend port", strconv.Itoa(state.BackendPort))
6583
out.KeyValue("Web port", strconv.Itoa(state.WebPort))
6684
out.KeyValue("Log level", state.LogLevel)
@@ -76,6 +94,38 @@ func runConfigShow(cmd *cobra.Command, _ []string) error {
7694
return nil
7795
}
7896

97+
func runConfigSet(cmd *cobra.Command, args []string) error {
98+
key, value := args[0], args[1]
99+
dir := resolveDataDir()
100+
out := ui.NewUI(cmd.OutOrStdout())
101+
102+
state, err := config.Load(dir)
103+
if err != nil {
104+
return fmt.Errorf("loading config: %w", err)
105+
}
106+
107+
switch key {
108+
case "channel":
109+
if !config.IsValidChannel(value) {
110+
return fmt.Errorf("invalid channel %q: must be one of %s", value, config.ChannelNames())
111+
}
112+
state.Channel = value
113+
case "log_level":
114+
if !config.IsValidLogLevel(value) {
115+
return fmt.Errorf("invalid log_level %q: must be one of %s", value, config.LogLevelNames())
116+
}
117+
state.LogLevel = value
118+
default:
119+
return fmt.Errorf("unknown config key %q (supported: %s)", key, strings.Join(supportedConfigKeys, ", "))
120+
}
121+
122+
if err := config.Save(state); err != nil {
123+
return fmt.Errorf("saving config: %w", err)
124+
}
125+
out.Success(fmt.Sprintf("Set %s = %s", key, value))
126+
return nil
127+
}
128+
79129
func maskSecret(s string) string {
80130
if s == "" {
81131
return "(not set)"

cli/cmd/config_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,66 @@ func TestConfigShowDisplaysFields(t *testing.T) {
113113
t.Errorf("expected at least 2 masked secrets (****), got %d", maskCount)
114114
}
115115
}
116+
117+
func TestConfigSetChannel(t *testing.T) {
118+
dir := t.TempDir()
119+
// Create initial config.
120+
state := config.DefaultState()
121+
state.DataDir = dir
122+
if err := config.Save(state); err != nil {
123+
t.Fatal(err)
124+
}
125+
126+
var buf bytes.Buffer
127+
rootCmd.SetOut(&buf)
128+
rootCmd.SetErr(&buf)
129+
rootCmd.SetArgs([]string{"config", "set", "channel", "dev", "--data-dir", dir})
130+
if err := rootCmd.Execute(); err != nil {
131+
t.Fatalf("unexpected error: %v", err)
132+
}
133+
134+
// Verify the channel was persisted.
135+
loaded, err := config.Load(dir)
136+
if err != nil {
137+
t.Fatalf("Load after set: %v", err)
138+
}
139+
if loaded.Channel != "dev" {
140+
t.Errorf("Channel = %q, want dev", loaded.Channel)
141+
}
142+
}
143+
144+
func TestConfigSetRejectsInvalidChannel(t *testing.T) {
145+
dir := t.TempDir()
146+
state := config.DefaultState()
147+
state.DataDir = dir
148+
if err := config.Save(state); err != nil {
149+
t.Fatal(err)
150+
}
151+
152+
var buf bytes.Buffer
153+
rootCmd.SetOut(&buf)
154+
rootCmd.SetErr(&buf)
155+
rootCmd.SetArgs([]string{"config", "set", "channel", "nightly", "--data-dir", dir})
156+
err := rootCmd.Execute()
157+
if err == nil {
158+
t.Fatal("expected error for invalid channel")
159+
}
160+
}
161+
162+
func TestConfigSetRejectsUnknownKey(t *testing.T) {
163+
dir := t.TempDir()
164+
state := config.DefaultState()
165+
state.DataDir = dir
166+
if err := config.Save(state); err != nil {
167+
t.Fatal(err)
168+
}
169+
170+
var buf bytes.Buffer
171+
rootCmd.SetOut(&buf)
172+
rootCmd.SetErr(&buf)
173+
rootCmd.SetArgs([]string{"config", "set", "unknown_key", "value", "--data-dir", dir})
174+
err := rootCmd.Execute()
175+
if err == nil {
176+
t.Fatal("expected error for unknown key")
177+
}
178+
}

0 commit comments

Comments
 (0)