Skip to content

Commit 79eba0d

Browse files
authored
Add action template engine (#48)
Generate flat, self-contained release actions from templates. ## Why This repo ships composite actions that other SDK repos pin by **SHA**. GitHub resolves a `./actions/...` path against the **calling** repo's workspace — so a composite action fetched by SHA that internally calls a sibling action fails: ``` Can't find 'action.yml' under '.../braintrust-sdk-ruby/actions/release/lang/ruby/rubygems-push' ``` Our release pipeline nested 4–5 levels of `uses: ./actions/...`, so it broke for every external consumer. There's no "group `if:`" or sub-action trick that avoids this (confirmed against the GitHub Actions docs) — the only fix is to **not nest**. ## What changed `actions/` is now **generated, flat output**. Source lives in `templates/`; a small ERB generator inlines reusable **steps** into each **action**, so every shipped `action.yml` is self-contained — no intra-repo `uses:`, only `run:` steps and marketplace actions. A single SHA pin now pulls in the whole chain, no transitive bumping. **Before** — `publish` delegated to siblings (breaks when pinned externally): ```yaml - uses: ./actions/release/lang/ruby/rubygems-push # ← resolves in the CALLER's repo → 404 - uses: ./actions/release/create-github-release - uses: ./actions/release/lang/ruby/notify-published ``` **After** — `publish` is one flat action, and its template reads like the pipeline: ```erb - uses: actions/checkout@... if: ${{ inputs.checkout == 'true' }} <%= render_step('lang/ruby/setup') %> <%= render_step('release/lang/ruby/rubygems-push') %> <%= render_step('release/create-github-release') %> <%= render_step('release/lang/ruby/notify-published') %> <%= render_step('release/lang/ruby/on-failure', if: "${{ failure() }}") %> ``` `render_step` inlines each step's YAML. Control flow stays in the action: pure "do" steps, then one terminal `on-failure`. Since GitHub can't guard a *group* of steps, `render_step(if:)` **stamps the condition onto every step it expands** — so the generated `on-failure` fans out to `env-dump` + `notify-failure`, each carrying `if: ${{ failure() }}`. Everything else a step decides ("Slack configured?", "dry run?") is a shell `exit 0` self-guard, so there's never a condition to merge. Result: **4 flat actions** composed from **11 reusable steps**, zero intra-repo `uses:`. ## Usage - **Consumers** copy the canonical workflow (`release-ruby.yml`) into their repo, adapt the marked sections, and pin the actions by SHA. (README) - **Contributors** edit `templates/`, run `rake generate` (renders + validates YAML *and* the GitHub Action schema), and commit `templates/` + `actions/` together. CI (`rake ci:actions`) fails if they drift. Toolchain is one `mise install`. (CONTRIBUTING) ## Validation End-to-end dry run of the generated actions against the `bt-fake` gem (`release-ruby.yml`) **passes** on this branch.
1 parent 71bbb60 commit 79eba0d

35 files changed

Lines changed: 2202 additions & 695 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
# Regenerates actions/ from templates/, validates them (YAML + schema), and
10+
# fails if the committed actions/ is out of sync. actions/ is generated output
11+
# and must always match `rake generate`. Least-privilege: no secrets in scope.
12+
#
13+
# Tool versions come from mise.toml (single source of truth). Ruby is installed
14+
# prebuilt by setup-ruby (fast) rather than compiled by mise; everything else
15+
# (Python, check-jsonschema) comes from mise via mise.toml.
16+
check-generated:
17+
runs-on: ubuntu-24.04
18+
permissions:
19+
contents: read
20+
env:
21+
MISE_DISABLE_TOOLS: ruby # Ruby comes prebuilt from setup-ruby; don't let mise compile it
22+
steps:
23+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
24+
25+
- name: Set up Ruby (prebuilt; version read from mise.toml)
26+
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
27+
with:
28+
ruby-version: mise.toml
29+
30+
- name: Set up Python + check-jsonschema (from mise.toml)
31+
uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
32+
33+
- name: Regenerate, validate, and verify in sync
34+
run: rake ci:actions

CONTRIBUTING.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Contributing
2+
3+
## Setup
4+
5+
This repo uses [mise](https://mise.jdx.dev) to manage its toolchain. `mise.toml`
6+
is the single source of truth for tool versions (Ruby, Python, and
7+
`check-jsonschema`) — CI reads it too. With mise installed:
8+
9+
```sh
10+
git clone https://github.com/braintrustdata/sdk-actions && cd sdk-actions
11+
mise install # ruby, python, check-jsonschema — versions from mise.toml
12+
rake generate # render templates/ → actions/, then validate
13+
```
14+
15+
## Adding or changing actions
16+
17+
`actions/` is generated output. **Do not edit it by hand** — edit `templates/`
18+
and regenerate:
19+
20+
- **A new step:** add `templates/steps/<scope>/<name>.yml.erb` (one or more
21+
steps; document its params in a leading `<%# … %>` comment), then compose it
22+
with `render_step` where needed.
23+
- **A new action:** add `templates/actions/<path>.yml(.erb)` and run
24+
`rake generate`. It appears at `actions/<path>/action.yml`.
25+
26+
After any change, run `rake generate` and commit `templates/` and the regenerated
27+
`actions/` **together**. CI fails if they drift.
28+
29+
### Commands
30+
31+
| command | what it does |
32+
|---|---|
33+
| `rake` / `rake generate` | render `templates/``actions/`, then validate (YAML + schema) |
34+
| `rake render` | render only (no validation) |
35+
| `rake validate` | `validate:yaml` + `validate:schema` |
36+
| `rake validate:yaml` | every generated action parses as YAML (stdlib, no extra deps) |
37+
| `rake validate:schema` | every generated action conforms to the GitHub Action schema |
38+
| `rake ci:actions` | what CI runs: regenerate, validate, and fail if `actions/` is out of sync |
39+
40+
## Design
41+
42+
### Why generated, and why flat
43+
44+
GitHub resolves a `./actions/...` path relative to the **calling workflow's**
45+
workspace, not the repo the action was fetched from. So a composite action that
46+
another repo pins by SHA **cannot** call a sibling action in this repo via `./`
47+
— it just won't be found. (And GitHub has no mechanism to guard a *group* of
48+
steps, so you can't nest your way around it either.)
49+
50+
The template system sidesteps this by **inlining everything at generation time**:
51+
each shipped action is one self-contained `action.yml` with no intra-repo
52+
`uses:` — only `run:` steps and external (marketplace) actions. That makes every
53+
action safely referenceable by SHA from any repo.
54+
55+
### Layout
56+
57+
```
58+
templates/
59+
actions/ # The actions a workflow calls (→ actions/<path>/action.yml)
60+
release/ # Language-agnostic actions for releases
61+
release/lang/<LANG>/ # Language-specific actions for releases go here
62+
steps/ # Reusable steps composed INTO actions (never shipped standalone)
63+
slack/ # Slack functions
64+
release/ # Release functions
65+
lang/<LANG>/ # Language functions
66+
release/lang/<LANG>/ # Language-specific release functions
67+
scripts/
68+
generate.rb # Renders templates/ → actions/
69+
github-action.schema.json # Vendored SchemaStore schema (used by `rake validate:schema`)
70+
actions/ # GENERATED — do not edit
71+
Rakefile
72+
```
73+
74+
#### actions vs. steps
75+
76+
- **`templates/actions/`** — entrypoints a workflow `uses:`. Generated flat, no
77+
intra-repo `uses:`. These are the public, SHA-referenceable surface.
78+
- **`templates/steps/`** — reusable fragments (one or more steps) composed into
79+
actions with `<%= render_step('<name>', ...) %>`. A step may compose other
80+
steps. Steps are *not* referenced by a workflow directly. They're grouped by
81+
scope: `release/` (language-agnostic release logic), `lang/<lang>/`
82+
(language-specific, release-neutral), and `release/lang/<lang>/` (both).
83+
84+
### How templates work
85+
86+
A `.yml.erb` template is plain YAML plus `render_step` calls; a `.yml` template is
87+
copied verbatim. `render_step(name, indent: 4, **locals)`:
88+
89+
- inlines `templates/steps/<name>.yml.erb`;
90+
- exposes keyword args to the step as a `locals` hash, read with
91+
`locals.fetch(:x) { default }`;
92+
- **`if:`** is the composing action's one control-flow knob — it's stamped onto
93+
*every* top-level step in the fragment (GitHub has no group-level `if:`), so a
94+
terminal `on-failure`'s `if: failure()` lands on each of its steps;
95+
- nested calls (a step composing another step) pass `indent: 0`.
96+
97+
#### Conventions
98+
99+
- **Control flow lives in the action.** Compose pure "do" steps, then a terminal
100+
failure handler: `… → render_step('on-failure', if: "${{ failure() }}")`.
101+
- **Non-control conditions are shell self-guards, not `if:`.** "Slack
102+
configured?", "dry run?", "create a GitHub release?" are handled with an early
103+
`exit 0` inside the step's own `run:` — so there's never an `if:` for the
104+
generator to merge, and control flow stays readable at the call site.
105+
106+

README.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,49 @@
11
# sdk-actions
2-
Shared actions for braintrust SDKs.
2+
3+
Shared GitHub actions and workflows for Braintrust SDK repositories.
4+
5+
## Releases
6+
7+
**Copy the canonical release workflow template** from `.github/workflows/release-<LANG>.yml` and adapt it for your repository. Using it as an upstream source will enable the implemented release process to receive improvements in the future.
8+
9+
### Adopting the release workflow
10+
11+
1. **Copy the canonical template** for your language into your repo's
12+
`.github/workflows/`. For Ruby that's
13+
[`release-ruby.yml`](.github/workflows/release-ruby.yml) — it doubles as this
14+
repo's end-to-end test and the reference implementation.
15+
2. **Adapt the marked sections** — version source, gem name, working directory,
16+
and so on. The template's comments flag exactly what changes per repo;
17+
everything else is provided by the pinned actions.
18+
3. **Record the upstream version you based it on** — the sdk-actions ref (commit
19+
SHA or tag) your copy was adapted from, e.g. in a header comment. Tracking it
20+
lets you (or an agent) diff your copy against a newer upstream template and
21+
sync changes deliberately as this repo evolves.
22+
4. **Bump the pinned SHA** to pick up action changes. Because each action is
23+
self-contained, one SHA bump pulls in the whole updated chain.
24+
25+
### Available release actions
26+
27+
The release workflow is composed from these actions. You normally get them via
28+
the template, but they're listed here as a reference for customizing it — each is
29+
**self-contained** (calls no other action in this repo), so a single SHA pin
30+
pulls in everything it needs.
31+
32+
| Action | Purpose |
33+
|---|---|
34+
| `release/lang/ruby/publish` | Push the gem to RubyGems, create the GitHub release, and notify |
35+
| `release/lang/ruby/validate` | Check out the SHA, set up Ruby, read the version, validate the release (tag/branch/metadata), lint + build |
36+
| `release/notify-pending` | Post the pre-approval job summary and Slack notification |
37+
| `release/prepare` | Fetch the PR list and release notes |
38+
39+
Inputs and outputs are documented in each `action.yml`. Reference an action by
40+
commit SHA:
41+
42+
```yaml
43+
- uses: braintrustdata/sdk-actions/actions/release/prepare@<sha>
44+
```
45+
46+
## Developing
47+
48+
`actions/` is **generated** from `templates/` — never edit it by hand. See
49+
[CONTRIBUTING.md](CONTRIBUTING.md) for the template system and workflow.

Rakefile

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
ACTIONS = FileList['actions/**/action.yml']
2+
SCHEMA = 'scripts/github-action.schema.json'
3+
4+
task default: :generate
5+
6+
desc 'Render actions/ from templates/, then validate them (YAML + schema)'
7+
task generate: %i[render validate]
8+
9+
desc 'Render actions/ from templates/ (no validation)'
10+
task :render do
11+
ruby 'scripts/generate.rb'
12+
end
13+
14+
desc 'Run all checks on the generated actions (YAML + schema)'
15+
task validate: %w[validate:yaml validate:schema]
16+
17+
namespace :validate do
18+
desc 'Check that every generated action parses as YAML (stdlib, no extra deps)'
19+
task :yaml do
20+
require 'yaml'
21+
errors = ACTIONS.sort.filter_map do |file|
22+
YAML.safe_load_file(file)
23+
nil
24+
rescue Psych::SyntaxError => e
25+
" #{file}:#{e.line}:#{e.column}: #{e.problem}"
26+
end
27+
abort "Malformed YAML in generated actions:\n#{errors.join("\n")}" unless errors.empty?
28+
end
29+
30+
desc 'Validate every generated action against the GitHub Action schema (needs check-jsonschema)'
31+
task :schema do
32+
on_path = ENV['PATH'].to_s.split(File::PATH_SEPARATOR)
33+
.any? { |dir| File.executable?(File.join(dir, 'check-jsonschema')) }
34+
unless on_path
35+
abort <<~MSG.strip
36+
check-jsonschema not found on PATH. Install the toolchain with `mise install`
37+
(or `pipx install check-jsonschema`), then re-run. To skip schema validation,
38+
use `rake render` or `rake validate:yaml`.
39+
MSG
40+
end
41+
sh 'check-jsonschema', '--schemafile', SCHEMA, *ACTIONS
42+
end
43+
end
44+
45+
namespace :ci do
46+
desc 'CI guard: regenerate + validate, then fail if committed actions/ is out of sync with templates/'
47+
task actions: :generate do
48+
sh 'git', 'diff', '--exit-code', 'actions/'
49+
end
50+
end

actions/lang/ruby/env-dump/action.yml

Lines changed: 0 additions & 27 deletions
This file was deleted.

actions/release/create-github-release/action.yml

Lines changed: 0 additions & 44 deletions
This file was deleted.

actions/release/lang/ruby/notify-published/action.yml

Lines changed: 0 additions & 79 deletions
This file was deleted.

0 commit comments

Comments
 (0)