Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
79d7bbd
feat(snippets): port the 22 remaining gonfalon getStarted SDKs
kinyoklion Apr 28, 2026
27c36b4
feat(validators): per-runtime Docker harnesses for the new SDKs
kinyoklion Apr 28, 2026
4ea69f2
ci(snippets): per-SDK validator matrix workflow
kinyoklion Apr 28, 2026
9822d08
ci(snippets): use vars.AWS_ROLE_ARN_EXAMPLES for the role ARN
kinyoklion Apr 28, 2026
18ee8cb
ci(snippets): rely on verify-hello-app to inject LAUNCHDARKLY_FLAG_KEY
kinyoklion Apr 28, 2026
e368f8f
fix(snippets): align failing CI cells with the hello-app conventions
kinyoklion Apr 28, 2026
8e16008
ci(snippets): drop T1/T2/T3/T4 labels from the matrix comments
kinyoklion Apr 28, 2026
5b65de1
feat(snippets): cpp-server-sdk validator + canonical print line
kinyoklion Apr 28, 2026
220b916
feat(snippets): cpp-client-sdk validator + canonical print line
kinyoklion Apr 28, 2026
e315535
feat(snippets): lua-server-sdk validator + canonical print line
kinyoklion Apr 28, 2026
14dda8f
feat(snippets): haskell-server-sdk validator + LAUNCHDARKLY_FLAG_KEY fix
kinyoklion Apr 28, 2026
563a526
feat(snippets): vue-client-sdk validator + canonical print line
kinyoklion Apr 28, 2026
08e9544
fix(snippets): align vue-client user key with hello-app convention
kinyoklion Apr 28, 2026
7d489ff
feat(snippets): camelCase filter + react-client-sdk legacy validator
kinyoklion Apr 28, 2026
3e2b79b
feat(snippets): wire react-client-sdk createApp variant
kinyoklion Apr 28, 2026
966712a
feat(snippets): flutter-client-sdk validator (web target)
kinyoklion Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions .github/workflows/snippets-validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
name: Validate snippets

# Runs the per-SDK validators in parallel. Each cell delegates to
# `launchdarkly/gh-actions/actions/verify-hello-app@v2.0.1`, which is the
# same action the `hello-*` repos use to:
# - assume the configured AWS role via OIDC (`vars.AWS_ROLE_ARN_EXAMPLES`)
# - fetch the LaunchDarkly Sandbox account credentials from Secrets
# Manager and inject the appropriate key as an env var
# (LAUNCHDARKLY_SDK_KEY, LAUNCHDARKLY_CLIENT_SIDE_ID, or
# LAUNCHDARKLY_MOBILE_KEY based on the use_*_key flag)
# - run `command:` and assert its output contains the EXAM-HELLO
# `feature flag evaluates to true` line.
#
# Each matrix row declares `key-type: server | client | mobile` so the
# right `use_*_key` flag is set for the SDK under test. The action also
# injects LAUNCHDARKLY_FLAG_KEY from the SSM parameter
# /sdk/common/hello-apps/boolean-flag-key, matching what every hello-*
# repo's CI relies on; we don't set it ourselves.
#
# fail-fast: false so every cell runs to completion. A final `summary` job
# aggregates per-cell artifacts into a markdown table.

on:
pull_request:
paths:
- 'snippets/**'
- '.github/workflows/snippets-validate.yml'
workflow_dispatch:

permissions:
id-token: write # OIDC for verify-hello-app's AWS role assumption
contents: read

concurrency:
group: snippets-validate-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
name: ${{ matrix.sdk }}
runs-on: ${{ matrix.runs-on }}
strategy:
fail-fast: false
matrix:
include:
# Linux server-runtime SDKs (Docker validators):
- { sdk: python-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: go-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: node-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: node-client-sdk, runs-on: ubuntu-latest, key-type: client }
- { sdk: ruby-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: php-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: rust-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: dotnet-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: dotnet-client-sdk, runs-on: ubuntu-latest, key-type: mobile }
- { sdk: java-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: cpp-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: cpp-client-sdk, runs-on: ubuntu-latest, key-type: mobile }
- { sdk: lua-server-sdk, runs-on: ubuntu-latest, key-type: server }
- { sdk: haskell-server-sdk, runs-on: ubuntu-latest, key-type: server }
# Browser SDKs (headless Playwright):
- { sdk: js-client-sdk, runs-on: ubuntu-latest, key-type: client }
- { sdk: vue-client-sdk, runs-on: ubuntu-latest, key-type: client }
- { sdk: react-client-sdk, runs-on: ubuntu-latest, key-type: client }
- { sdk: flutter-client-sdk, runs-on: ubuntu-latest, key-type: client }
#
# Validators not yet wired (snippets ported, harness pending):
# server-runtime: erlang-server-sdk
# Linux mobile/native: android-client-sdk,
# react-native-client-sdk (key-type: mobile)
# macos-latest + xcodebuild: ios-client-sdk (key-type: mobile)
# Skipped: roku-client-sdk (validation: none, manual procedure).
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.24'

- name: Validate
id: validate
uses: launchdarkly/gh-actions/actions/verify-hello-app@verify-hello-app-v2.0.1
with:
use_server_key: ${{ matrix.key-type == 'server' }}
use_client_key: ${{ matrix.key-type == 'client' }}
use_mobile_key: ${{ matrix.key-type == 'mobile' }}
role_arn: ${{ vars.AWS_ROLE_ARN_EXAMPLES }}
command: |
set -o pipefail
cd snippets
go run ./cmd/snippets validate --sdk=${{ matrix.sdk }} \
--sdks=./sdks --validators=./validators 2>&1 | tee /tmp/validate.log

- name: Record result
if: always()
run: |
mkdir -p /tmp/result
if [ "${{ steps.validate.outcome }}" = "success" ]; then
echo "ok" > /tmp/result/status
else
echo "fail" > /tmp/result/status
fi
[ -f /tmp/validate.log ] && cp /tmp/validate.log /tmp/result/log

- name: Upload result
if: always()
uses: actions/upload-artifact@v4
with:
name: result-${{ matrix.sdk }}
path: /tmp/result/
if-no-files-found: ignore
retention-days: 14

summary:
name: Validation summary
if: always()
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: results
pattern: result-*

- name: Build markdown table
run: |
{
echo "## Snippet validation summary"
echo
echo "Run: \`${{ github.run_id }}\` · Commit: \`${{ github.sha }}\`"
echo
echo "| SDK | Result | Excerpt |"
echo "|---|---|---|"
for d in results/result-*; do
[ -d "$d" ] || continue
sdk=$(basename "$d" | sed 's/^result-//')
status="unknown"
[ -f "$d/status" ] && status=$(cat "$d/status")
if [ "$status" = "ok" ]; then
echo "| \`$sdk\` | ok | |"
else
excerpt=""
if [ -f "$d/log" ]; then
excerpt=$(tail -n 50 "$d/log" \
| grep -v '^$' \
| tail -n 3 \
| sed -e 's/|/\\|/g' -e 's/`/\\`/g' \
| tr '\n' ' ')
fi
echo "| \`$sdk\` | **FAIL** | $excerpt |"
fi
done
echo
echo "Per-job logs are uploaded as \`result-<sdk>\` artifacts (14-day retention)."
} >> "$GITHUB_STEP_SUMMARY"

- name: Fail summary if any cell failed
run: |
for d in results/result-*; do
[ -d "$d" ] || continue
if [ -f "$d/status" ] && [ "$(cat "$d/status")" != "ok" ]; then
echo "At least one validator failed; see step summary above." >&2
exit 1
fi
done
3 changes: 2 additions & 1 deletion snippets/internal/adapters/ldapplication/descriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ type descriptor struct {
Regions []string `yaml:"regions"`
HelloWorldRepo string `yaml:"hello-world-repo"`
LDApplication struct {
GetStartedFile string `yaml:"get-started-file"`
GetStartedFile string `yaml:"get-started-file"`
GetStartedFiles []string `yaml:"get-started-files"`
} `yaml:"ld-application"`
Docs struct {
ReferencePage string `yaml:"reference-page"`
Expand Down
34 changes: 18 additions & 16 deletions snippets/internal/adapters/ldapplication/ldapplication.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,24 +92,26 @@ func discoverTargetFiles(sdksDir, appDir string) ([]string, error) {
}
return nil, err
}
rel := desc.LDApplication.GetStartedFile
if rel == "" {
continue
}
if filepath.IsAbs(rel) {
return nil, fmt.Errorf("descriptor %s: get-started-file %q must be relative", descPath, rel)
}
full := filepath.Join(absAppDir, rel)
// Reject any path that escapes appDir. filepath.Rel followed by a
// `..` prefix check is the canonical way to do this.
relCheck, err := filepath.Rel(absAppDir, full)
if err != nil || relCheck == ".." || strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("descriptor %s: get-started-file %q escapes appDir", descPath, rel)
rels := desc.LDApplication.GetStartedFiles
if rel := desc.LDApplication.GetStartedFile; rel != "" {
rels = append([]string{rel}, rels...)
}
if _, err := os.Stat(full); err != nil {
return nil, fmt.Errorf("descriptor %s: target not found: %w", descPath, err)
for _, rel := range rels {
if filepath.IsAbs(rel) {
return nil, fmt.Errorf("descriptor %s: get-started-file %q must be relative", descPath, rel)
}
full := filepath.Join(absAppDir, rel)
// Reject any path that escapes appDir. filepath.Rel followed by a
// `..` prefix check is the canonical way to do this.
relCheck, err := filepath.Rel(absAppDir, full)
if err != nil || relCheck == ".." || strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("descriptor %s: get-started-file %q escapes appDir", descPath, rel)
}
if _, err := os.Stat(full); err != nil {
return nil, fmt.Errorf("descriptor %s: target not found: %w", descPath, err)
}
out = append(out, full)
}
out = append(out, full)
}
return out, nil
}
Expand Down
85 changes: 77 additions & 8 deletions snippets/internal/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ func RenderRuntime(nodes []Node, inputs map[string]string) (string, error) {
v, ok := inputs[x.Name]
if !ok {
// Unknown name — emit verbatim so foreign templates pass through.
sb.WriteString("{{ ")
sb.WriteString(x.Name)
sb.WriteString(" }}")
sb.WriteString(literalVar(x))
continue
}
if x.Filter != "" {
v = applyFilter(x.Filter, v)
}
sb.WriteString(v)
case *Cond:
v, ok := inputs[x.Var]
Expand Down Expand Up @@ -80,11 +81,18 @@ func RenderForLDApplicationTemplate(nodes []Node, declaredInputs map[string]stru
if _, ok := declaredInputs[x.Name]; !ok {
// Foreign template — emit the original `{{ name }}` literally,
// escaped for the surrounding template literal.
sb.WriteString(escapeTL("{{ " + x.Name + " }}"))
sb.WriteString(escapeTL(literalVar(x)))
continue
}
sb.WriteString("${")
sb.WriteString(x.Name)
if x.Filter != "" {
sb.WriteString(x.Filter)
sb.WriteString("(")
sb.WriteString(x.Name)
sb.WriteString(")")
} else {
sb.WriteString(x.Name)
}
sb.WriteString("}")
case *Cond:
sb.WriteString("${")
Expand Down Expand Up @@ -117,9 +125,7 @@ func RenderForJSXText(nodes []Node, declaredInputs map[string]struct{}) (string,
return "", fmt.Errorf("RenderForJSXText: template has declared interpolation; use RenderForLDApplicationTemplate")
}
// Foreign template — emit literal.
sb.WriteString("{{ ")
sb.WriteString(x.Name)
sb.WriteString(" }}")
sb.WriteString(literalVar(x))
case *Cond:
return "", fmt.Errorf("RenderForJSXText: template has conditional; use RenderForLDApplicationTemplate")
}
Expand All @@ -142,3 +148,66 @@ func escapeTL(s string) string {
s = strings.ReplaceAll(s, "${", "\\${")
return s
}

// literalVar formats a Var node back to its source `{{ name }}` /
// `{{ name | filter }}` form. Used when emitting an undeclared name
// verbatim so foreign-template syntax round-trips intact.
func literalVar(v *Var) string {
if v.Filter == "" {
return "{{ " + v.Name + " }}"
}
return "{{ " + v.Name + " | " + v.Filter + " }}"
}

// applyFilter applies a filter to a runtime value. Today only `camelCase`
// is supported — used by react-client-sdk's snippets where useFlags()
// destructures camelCased identifiers from a kebab-cased flag key.
func applyFilter(name, value string) string {
switch name {
case "camelCase":
return camelCase(value)
default:
return value
}
}

// camelCase mirrors @gonfalon/strings' camelCase. Converts kebab-case,
// snake_case, and space-separated words to camelCase. Leading non-alpha
// runs are stripped. The first segment stays lowercase; subsequent
// segments get an uppercase initial.
//
// Examples:
// sample-feature -> sampleFeature
// my_flag_key -> myFlagKey
// already-camelOK -> alreadyCamelOk (lowercases later segments first)
func camelCase(s string) string {
var segs []string
var cur strings.Builder
flush := func() {
if cur.Len() > 0 {
segs = append(segs, strings.ToLower(cur.String()))
cur.Reset()
}
}
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
cur.WriteRune(r)
continue
}
flush()
}
flush()
if len(segs) == 0 {
return ""
}
var out strings.Builder
out.WriteString(segs[0])
for _, seg := range segs[1:] {
if seg == "" {
continue
}
out.WriteString(strings.ToUpper(seg[:1]))
out.WriteString(seg[1:])
}
return out.String()
}
25 changes: 19 additions & 6 deletions snippets/internal/render/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@ import (
// The snippet templating language is intentionally tiny:
//
// {{ varName }} substitute the value of an input
// {{ varName | filter }} substitute the value with a filter applied
// {{ if varName }}...{{ end }} emit "..." only if the input is truthy (non-empty)
//
// Conditionals do not nest in the first-pass slice. The inner "..." may still
// contain {{ varName }} substitutions. Future phases can extend this (filters,
// region toggles, version toggles) without breaking existing snippets.
// contain {{ varName }} substitutions. Filters supported today: `camelCase`.
// React's snippets need the camelCase filter because `useFlags()` destructures
// camelCased identifiers; ld-application maps these to `${camelCase(name)}`.

type Node interface{ isNode() }

type Literal struct{ Text string }
type Var struct{ Name string }
type Var struct {
Name string
Filter string // empty if no filter; e.g. "camelCase"
}
type Cond struct {
Var string
Body []Node
Expand All @@ -29,7 +34,7 @@ func (*Var) isNode() {}
func (*Cond) isNode() {}

var nameRe = `[a-zA-Z][a-zA-Z0-9_]*`
var tokenRe = regexp.MustCompile(`\{\{\s*(if\s+(` + nameRe + `)\s*|end\s*|(` + nameRe + `)\s*)\}\}`)
var tokenRe = regexp.MustCompile(`\{\{\s*(if\s+(` + nameRe + `)\s*|end\s*|(` + nameRe + `)(?:\s*\|\s*(` + nameRe + `))?\s*)\}\}`)

// Parse parses the mini-templating syntax into a flat node list.
// Conditionals are flattened: a Cond node contains its inner body.
Expand Down Expand Up @@ -71,9 +76,17 @@ func Parse(src string) ([]Node, error) {
closed := stack[len(stack)-1]
stack = stack[:len(stack)-1]
append_(closed)
case m[6] >= 0: // "NAME"
case m[6] >= 0: // "NAME" optionally followed by "| FILTER"
name := src[m[6]:m[7]]
append_(&Var{Name: name})
v := &Var{Name: name}
if m[8] >= 0 {
filter := src[m[8]:m[9]]
if filter != "camelCase" {
return nil, fmt.Errorf("template: unknown filter %q at offset %d (only `camelCase` is supported)", filter, start)
}
v.Filter = filter
}
append_(v)
default:
return nil, fmt.Errorf("template: unrecognized directive %q at offset %d", token, start)
}
Expand Down
Loading
Loading