Skip to content

Commit c800ea7

Browse files
authored
Merge branch 'main' into fix/fix-setup-wizard
2 parents a4dd7a9 + d767aa3 commit c800ea7

File tree

17 files changed

+830
-36
lines changed

17 files changed

+830
-36
lines changed

.github/workflows/dev-release.yml

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
environment: release
30+
permissions:
31+
contents: write
32+
steps:
33+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
34+
with:
35+
fetch-depth: 0
36+
persist-credentials: false
37+
38+
- name: Check if commit already has a stable tag
39+
id: check-tag
40+
run: |
41+
# If this commit already has a v* tag (without .dev), skip -- it is
42+
# a stable release and will be handled by Release Please.
43+
TAGS=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)
44+
if [ -n "$TAGS" ]; then
45+
echo "skip=true" >> "$GITHUB_OUTPUT"
46+
echo "Commit already tagged as stable release: $TAGS"
47+
else
48+
echo "skip=false" >> "$GITHUB_OUTPUT"
49+
fi
50+
51+
- name: Compute dev version
52+
if: steps.check-tag.outputs.skip != 'true'
53+
id: version
54+
env:
55+
GH_TOKEN: ${{ github.token }}
56+
run: |
57+
# 1. Find last stable release tag (exclude dev tags)
58+
LAST_TAG=$(git tag --sort=-v:refname --list "v[0-9]*.[0-9]*.[0-9]*" | grep -vE '\.dev[0-9]+$' | head -1)
59+
LAST_TAG=${LAST_TAG:-""}
60+
if [ -z "$LAST_TAG" ]; then
61+
echo "::error::No stable release tag found"
62+
exit 1
63+
fi
64+
echo "Last stable tag: $LAST_TAG"
65+
66+
# 2. Get next version from Release Please pending PR
67+
NEXT_VERSION=$(gh pr list --label "autorelease: pending" --state open --json title --jq '.[0].title // ""' | grep -oP '\d+\.\d+\.\d+' || true)
68+
if [ -z "$NEXT_VERSION" ]; then
69+
# Fallback: bump patch from last tag
70+
LAST_VERSION="${LAST_TAG#v}"
71+
IFS='.' read -r MAJOR MINOR PATCH <<< "$LAST_VERSION"
72+
NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
73+
echo "No Release Please PR found, computed next version: $NEXT_VERSION"
74+
else
75+
echo "Next version from Release Please: $NEXT_VERSION"
76+
fi
77+
78+
# 3. Count commits since last stable tag
79+
DEV_NUM=$(git rev-list "${LAST_TAG}..HEAD" --count)
80+
if [ "$DEV_NUM" -eq 0 ]; then
81+
echo "::notice::No commits since $LAST_TAG, skipping dev release"
82+
echo "skip=true" >> "$GITHUB_OUTPUT"
83+
exit 0
84+
fi
85+
86+
DEV_TAG="v${NEXT_VERSION}.dev${DEV_NUM}"
87+
echo "Dev version: $DEV_TAG"
88+
echo "dev_tag=$DEV_TAG" >> "$GITHUB_OUTPUT"
89+
echo "dev_num=$DEV_NUM" >> "$GITHUB_OUTPUT"
90+
echo "next_version=$NEXT_VERSION" >> "$GITHUB_OUTPUT"
91+
echo "skip=false" >> "$GITHUB_OUTPUT"
92+
93+
- name: Check if dev tag already exists
94+
if: steps.check-tag.outputs.skip != 'true' && steps.version.outputs.skip != 'true'
95+
id: tag-exists
96+
env:
97+
DEV_TAG: ${{ steps.version.outputs.dev_tag }}
98+
run: |
99+
if git rev-parse "$DEV_TAG" >/dev/null 2>&1; then
100+
echo "skip=true" >> "$GITHUB_OUTPUT"
101+
echo "Tag $DEV_TAG already exists, skipping"
102+
else
103+
echo "skip=false" >> "$GITHUB_OUTPUT"
104+
fi
105+
106+
- name: Create dev tag and pre-release
107+
if: >-
108+
steps.check-tag.outputs.skip != 'true'
109+
&& steps.version.outputs.skip != 'true'
110+
&& steps.tag-exists.outputs.skip != 'true'
111+
env:
112+
# Use PAT instead of github.token so the tag push triggers
113+
# downstream workflows (Docker, CLI). Tags created with the
114+
# default GITHUB_TOKEN do not fire push events for other
115+
# workflows -- a GitHub Actions anti-recursion safeguard.
116+
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
117+
DEV_TAG: ${{ steps.version.outputs.dev_tag }}
118+
DEV_NUM: ${{ steps.version.outputs.dev_num }}
119+
NEXT_VERSION: ${{ steps.version.outputs.next_version }}
120+
run: |
121+
SHORT_SHA="${GITHUB_SHA::7}"
122+
123+
# Create tag + pre-release atomically (gh release create --target
124+
# creates the tag if it does not exist, avoiding orphaned tags on
125+
# partial failure).
126+
gh release create "$DEV_TAG" \
127+
--target "$GITHUB_SHA" \
128+
--prerelease \
129+
--title "$DEV_TAG" \
130+
--notes "Dev build #${DEV_NUM} toward v${NEXT_VERSION}
131+
132+
**Commit:** ${SHORT_SHA}
133+
**Full pipeline:** Docker images, CLI binaries, cosign signatures, and SLSA provenance will be attached by downstream workflows.
134+
135+
> This is a pre-release for testing. Use \`synthorg config set channel dev\` to opt in."
136+
137+
- name: Clean up old dev pre-releases
138+
if: >-
139+
steps.check-tag.outputs.skip != 'true'
140+
&& steps.version.outputs.skip != 'true'
141+
&& steps.tag-exists.outputs.skip != 'true'
142+
env:
143+
GH_TOKEN: ${{ github.token }}
144+
run: |
145+
# Keep the 5 most recent dev pre-releases, delete the rest
146+
gh release list --limit 50 --json tagName,isPrerelease,createdAt \
147+
--jq '[.[] | select(.isPrerelease and (.tagName | contains(".dev")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName' \
148+
| while read -r tag; do
149+
echo "Deleting old dev release: $tag"
150+
gh release delete "$tag" --yes --cleanup-tag 2>/dev/null || true
151+
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)"

0 commit comments

Comments
 (0)