Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.

### Added

- **genie/ci-workflow/release**: Promote `releaseWorkflow` from a skeleton to an adoption-ready helper. Fills the previously-stub job bodies for `create-release-pr`, `validate-release-plan`, and `publish-release` from typed inputs (`stagedFiles`, `releaseBranchPrefix`, `prTitleTemplate`, `prBodyTemplate`, `releaseNotesPath`), so consumers like LiveStore can drop the consumer-owned bash heredoc. New per-channel `deployTarget: 'prod' | 'dev' | 'none'` on `ReleaseChannel` drives the exported `<WORKSPACE>_RELEASE_DEPLOY_TARGET` env var and auto-gates `postPublishSteps`. New `extraSetupSteps` appends to `setupSteps` without replacing it, and the publish job now creates/refreshes a GitHub Release with `gh release create --notes-file ${releaseNotesPath}`. Covered by `packages/@overeng/genie/src/runtime/github-workflow/release-workflow.unit.test.ts`. See `context/workflows/release-workflow.md` for the worked LiveStore example and migration notes. Refs livestorejs/livestore#1281, livestorejs/livestore#1282.
- **devenv-modules/tasks/changesets**: New shared task module providing `release:changeset:check-bodies`, which rejects malformed Changesets where the YAML frontmatter has no package bumps **and** the body is empty. Catches `changeset add --empty` invocations whose `---\n---\n` placeholder was never filled in. Consume via `(inputs.effect-utils.devenvModules.tasks.changesets { })` in `devenv.nix`. Ported from livestorejs/livestore#1269.
- **@overeng/react-inspector**: Lineage annotation namespace (#687). New `Lineage` module with `SourceOfTruth | Derived | Projection | Cache | Mirror | External | Computed` tagged union, plus composable companion annotations (`Authority`, `Freshness`, `ForeignKey`). All annotations are self-describing Effect Schemas with ergonomic `pipe`-style constructors (`Lineage.derivedFrom`, `Lineage.cache`, `Lineage.authority`, etc.). The schema-aware renderer surfaces a small superscript glyph next to annotated field names and a dedicated `LINEAGE` / `AUTHORITY` / `FRESHNESS` / `REF` block in the schema tooltip. `SchemaInfo` gains an optional `lineage: LineageBundle` field. Source-field path references in `Derived.from` carry `data-lineage-target` attributes for future jump-to-source wiring. Round-trip-tested via vitest.
- **@overeng/react-inspector**: Map/Set container labels (#686). `Schema.Map({key, value})` renders as `Map<K, V>(N)`, `Schema.Set(T)` as `Set<T>(N)`, plus the `Readonly*` variants. Detected via the `effect/annotation/TypeConstructor` annotation on `Declaration` ASTs.
Expand Down
282 changes: 173 additions & 109 deletions context/workflows/release-workflow.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,49 @@
# Release Workflow Helper (`releaseWorkflow`)

Status: **draft / skeleton in `genie/ci-workflow/release.ts`**.
Status: **adoption-ready in `genie/ci-workflow/release.ts`**.

This document captures the intended shape of a shared
`releaseWorkflow({...})` helper that codifies the Changesets-based
supervised release flow LiveStore just shipped, so other megarepo
members (`molty`, `openclaw`, …) can adopt the same structure via config
instead of copying `release.yml.genie.ts` wholesale.
This document captures the shape of the shared `releaseWorkflow({...})`
helper that codifies the Changesets-based supervised release flow first
shipped in LiveStore, so other megarepo members (`molty`, `openclaw`, …)
can adopt the same structure via config instead of copying
`release.yml.genie.ts` wholesale.

The reference implementation it abstracts from is
`livestorejs/livestore:.github/workflows/release.yml.genie.ts` plus the
human-readable description at
`livestorejs/livestore:contributor-docs/release-workflows.md`. See
`Refs livestorejs/livestore#1269` for the immediate motivation (the
stable-release auto-merge gate).
stable-release auto-merge gate) and
`Refs livestorejs/livestore#1281` for the release-notes artifact that
the helper now hands to `gh release create --notes-file`.

## Goals

- One `releaseWorkflow({...})` call per repo produces a complete
`.github/workflows/release.yml`.
- Same job ids and `if` conditions across consumers so operators can
navigate any repo's release workflow without re-learning the layout.
- Per-npm-tag policy is config, not branching code. In particular the
stable-vs-prerelease auto-merge distinction is one boolean per tag.
- Per-channel policy (stable-vs-prerelease auto-merge, prod-vs-dev
deploy target) is config, not branching code.
- Repo-specific work (publish task names, devtools repack, prod docs
deploy, search sync, …) plugs in as `validateSteps` / `publishSteps`.
The helper does not try to model those directly.
deploy, search sync, …) plugs in as `validateSteps` / `publishSteps`
/ `postPublishSteps`. The helper does not try to model those directly.
- The release PR's bash body is owned by the helper and rendered from
typed inputs so consumers cannot accidentally diverge on the PR
markup, branch naming, or auto-merge wiring.

## Non-goals

The first iteration intentionally does **not** cover:

- Snapshot releases. Those have a separate workflow and lifecycle
(per-commit, `0.0.0-snapshot-<sha>`, no release plan) and do not
benefit from the supervised release-plan PR shape.
- Devtools-style artifact repackaging. This is LiveStore-specific
glue; repos that need it pass extra steps into `validateSteps` /
`publishSteps`.
- Docs / examples deploy and search-index sync. Same reasoning —
these are repo-specific post-publish hooks.
- `release/version.json` / `release/devtools-artifact.json`
generation. LiveStore's release PR generator stages those files
before opening the PR. The skeleton names only the canonical
`release/release-plan.json` location and leaves the broader staged
file set to the repo (probably via a follow-up `stagedFiles` option).
- A migration of LiveStore's own `release.yml.genie.ts` to consume the
helper. The intention is to land the skeleton, iterate on the
interface against a second adopter (molty or openclaw), and only
then migrate LiveStore so we get one round of real-world feedback
on the API before committing to it.
- **Snapshot releases.** Per-commit `0.0.0-snapshot-<sha>` publishing has
no release plan and does not benefit from the supervised PR shape.
Snapshot publishing stays in `ci.yml`.
- **Migrating effect-utils' own usage.** Effect-utils does not consume the
helper; LiveStore (and future molty/openclaw) do.
- A turing-complete template engine for `prTitleTemplate` /
`prBodyTemplate`. Substitution is `${workspaceName}`,
`${workspaceDisplayName}`, `${version}` only. Consumers that need
richer markup can render a string themselves and pass it as the
template.

## Input shape

Expand All @@ -59,80 +54,139 @@ export default releaseWorkflow({
workspaceName: 'livestore',
workspaceDisplayName: 'LiveStore',

releasePlanPath: 'release/release-plan.json',
releasePlanPaths: [
'.github/workflows/release.yml',
'.github/workflows/release.yml.genie.ts',
'genie/repo.ts',
'nix/devenv-modules/tasks/local/mono-wrappers.nix',
'release/release-plan.json',
'release/version.json',
'release/devtools-artifact.json',
'scripts/src/commands/release.ts',
'scripts/src/commands/devtools-artifact.ts',
'scripts/src/commands/changesets.ts',
],

// Per-npm-tag policy. `manualGate: true` keeps the release-plan PR
// human-merged. `manualGate: false` enables GitHub auto-merge for
// dev / prerelease PRs.
// Per-npm-tag policy. `manualGate` drives auto-merge; `deployTarget`
// gates `postPublishSteps` per channel and is exported as
// LIVESTORE_RELEASE_DEPLOY_TARGET.
releaseChannels: {
latest: { manualGate: true },
dev: { manualGate: false },
next: { manualGate: false },
latest: { manualGate: true, deployTarget: 'prod' },
dev: { manualGate: false, deployTarget: 'dev' },
next: { manualGate: false, deployTarget: 'none' },
},
defaultNpmTag: 'latest',

// Per-job setup (devenv, nix cache, pnpm install, ...). The helper
// does not assume a particular CI prep contract; pass whatever the
// repo uses for the rest of its workflows. For LiveStore today this
// is `livestoreSetupSteps`.
// Files staged into the release plan commit by `create-release-pr`.
stagedFiles: [
'.changeset',
'package.json',
'pnpm-lock.yaml',
'release/devtools-artifact.json',
'release/release-notes.md',
'release/release-plan.json',
'release/version.json',
'docs/package.json',
'docs/src/content/_assets/code/package.json',
'examples',
'packages',
'tests',
],

// Per-job setup (devenv, nix cache, pnpm install, ...). When provided,
// fully replaces the default empty setup. Effect-utils does not assume
// a particular CI prep contract.
setupSteps: livestoreSetupSteps,

// Materialize the release-notes artifact into the staged file set
// before commit/push.
preCreatePrSteps: [
devenvTaskStep('Generate release plan from Changesets', 'release:changeset:version'),
devenvTaskStep('Extract release notes', 'release:notes:extract'),
],

// Dry-run + repack-dryrun substance for the release-plan PR.
validateSteps: [
devenvTaskStep('Dry-run stable package publish', 'release:stable:dryrun'),
devenvTaskStep(
'Repack DevTools artifact (dryrun)',
'Dry-run DevTools artifact repack',
'release:devtools-artifact:repack-dryrun:no-install',
),
],

// Publish substance after the release-plan PR merges to main.
// npm publish substance after the release-plan PR merges to main.
publishSteps: [
devenvTaskStep('Publish stable package release', 'release:stable:publish'),
devenvTaskStep(
'Publish DevTools artifact release',
'release:devtools-artifact:publish:no-install',
),
// ...optional prod docs deploy, search sync, etc. — repo-specific.
],

trustedPublishing: true, // OIDC, no NPM_TOKEN
// Per-deploy-target post-publish hooks. Each step is auto-gated by
// the channel's `deployTarget !== 'none'`; consumers can add an
// explicit `if:` for finer prod-vs-dev gating.
postPublishSteps: [
{
name: 'Deploy production docs',
if: "env.LIVESTORE_RELEASE_DEPLOY_TARGET == 'prod'",
run: runDevenvTasksBefore('docs:deploy:prod'),
env: { NETLIFY_AUTH_TOKEN: '${{ secrets.NETLIFY_AUTH_TOKEN }}' },
},
{
name: 'Deploy production examples',
if: "env.LIVESTORE_RELEASE_DEPLOY_TARGET == 'prod'",
run: runDevenvTasksBefore('examples:deploy:prod'),
env: {
CLOUDFLARE_API_TOKEN: '${{ secrets.CLOUDFLARE_API_TOKEN }}',
CLOUDFLARE_ACCOUNT_ID: '${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
},
},
],

trustedPublishing: false, // LiveStore keeps the NPM_TOKEN fallback
sourcePolicyJob: livestoreDefaultRefPolicyJob,
})
```

The full TypeScript type is at `genie/ci-workflow/release.ts`:
The full TypeScript type is in `genie/ci-workflow/release.ts`:

```ts
export type ReleaseChannel = {
readonly manualGate: boolean
readonly deployTarget: 'prod' | 'dev' | 'none'
}

export type ReleaseWorkflowOptions = {
readonly name?: string
readonly workspaceName: string
readonly workspaceDisplayName?: string
readonly releasePlanPath?: string
readonly releasePlanPaths: readonly string[]

readonly releasePlanPath?: string // default: 'release/release-plan.json'
readonly releasePlanPaths: readonly string[] // PR trigger paths
readonly releaseNotesPath?: string // default: 'release/release-notes.md'

readonly releaseChannels: Record<string, ReleaseChannel>
readonly defaultNpmTag?: string
readonly setupSteps: readonly WorkflowStep[]

readonly releaseBranchPrefix?: string // default: 'automation/release-'
readonly prTitleTemplate?: string // default: 'Prepare ${workspaceDisplayName} ${version} release'
readonly prBodyTemplate?: string // default covers rationale + handoff
readonly stagedFiles: readonly string[]

readonly setupSteps?: readonly WorkflowStep[] // replaces empty default
readonly extraSetupSteps?: readonly WorkflowStep[] // appended to setupSteps
readonly preCreatePrSteps?: readonly WorkflowStep[]

readonly validateSteps: readonly WorkflowStep[]
readonly publishSteps: readonly WorkflowStep[]
readonly postPublishSteps?: readonly WorkflowStep[]

readonly trustedPublishing?: boolean
readonly sourcePolicyJob?: WorkflowJob | false
readonly actionlint?: ActionlintConfig | false
readonly env?: Record<string, string>
readonly runner?: GitHubWorkflowJob['runs-on']
}
```

Expand All @@ -155,71 +209,81 @@ export type ReleaseWorkflowOptions = {

Workflow-level: `contents: read`, `id-token: write` (for npm OIDC).
Per-job permissions tighten or widen as needed; `create-release-pr` is
the only job that writes contents and pull-requests.
the only job that writes contents and pull-requests; `publish-release`
gains `contents: write` so `gh release create` can upload the release
body.

### Jobs

- `source-policy` — optional first-party ref policy job (LiveStore
- **`source-policy`** — optional first-party ref policy job (LiveStore
uses `livestoreDefaultRefPolicyJob`). Omitted when
`sourcePolicyJob: false`.
- `create-release-pr` — runs only on `workflow_dispatch` with
`mode == create-release-pr`. Runs `setupSteps`, generates the
release plan from Changesets, opens or refreshes the
`automation/release-<version>` branch + PR, dispatches the
`validate-release-plan` workflow against the branch, and (per
channel) either enables GitHub auto-merge (prerelease) or leaves
the PR for a human reviewer (stable). The stable manual-gate is the
behaviour introduced by livestorejs/livestore#1269.
- `validate-release-plan` — runs on `pull_request` (against any of
- **`create-release-pr`** — runs only on `workflow_dispatch` with
`mode == create-release-pr`. Checks out `main`, runs `setupSteps`
- `preCreatePrSteps` (typically: `changeset version` + extract
release notes), then runs the helper-owned "Open release plan PR"
step which:
1. derives the version from `release/release-plan.json`,
2. force-pushes `automation/release-<version>` with the configured
`stagedFiles`,
3. opens or refreshes the PR with the rendered title/body templates,
4. dispatches the validate workflow,
5. enables GitHub auto-merge when the channel's `manualGate` is
`false`.
- **`validate-release-plan`** — runs on `pull_request` (against any of
`releasePlanPaths`) and on `workflow_dispatch` with
`mode == validate-release-plan`. Runs `setupSteps`, synthesizes a
release plan when the PR did not include one, then runs
`validateSteps`.
- `publish-release` — runs on `push` to `main` touching
release plan when the PR did not include one, runs `validateSteps`,
then exports `<WORKSPACE>_RELEASE_VERSION`, `<WORKSPACE>_NPM_TAG`,
and `<WORKSPACE>_RELEASE_DEPLOY_TARGET` (the deploy target is looked
up from `releaseChannels[npmTag].deployTarget`).
- **`publish-release`** — runs on `push` to `main` touching
`releasePlanPath`, and on `workflow_dispatch` with
`mode == publish-release`. Runs `setupSteps`, reads the release
plan, (optionally) configures the npm token fallback, then runs
`publishSteps`.

## What the skeleton does today

`genie/ci-workflow/release.ts` returns a workflow with the correct
triggers, permissions, env, and job ids / `if` conditions, but the job
bodies are placeholders (each substantive step is replaced by an
`echo TODO ... ; exit 1` so a partial migration cannot silently
publish). The next iteration replaces those placeholders with the real
step sequence from the LiveStore reference workflow, factored against
the option surface above.

## Open questions

The following choices still want a human review before the helper goes
beyond skeleton:

1. **Granularity of `releaseChannels`.** Today every channel is just
`{ manualGate: boolean }`. The LiveStore reference workflow also
varies the deploy target (`prod` vs `dev`) per channel. Should the
channel record carry that, or should it stay opaque and live in
`publishSteps` per repo?
2. **`setupSteps` vs structured prep.** `setupSteps` is currently a
flat `readonly WorkflowStep[]`, which means each repo can stay on
its existing setup helper. An alternative is to require
`standardSelfHostedPnpmCiPrepSteps(...)` and surface its options
instead, which would unify the four jobs but tie the helper to one
specific CI prep contract.
3. **Release PR shell ownership.** The `create-release-pr` body is a
~60-line bash script today (branch fork, `gh pr edit`/`create`,
workflow dispatch, auto-merge toggle). The skeleton keeps it as
one step in the helper. Alternatives: split into named composite
actions, or have the helper render the shell from typed inputs
(workspace name, branch prefix, etc.) so repos cannot accidentally
diverge on the PR body markup.

## Worked example: LiveStore

The shape above is the LiveStore release workflow re-expressed in the
helper's vocabulary. The full migration is intentionally out of scope
for the first PR — once the inputs above are signed off, the
follow-up replaces the placeholder job bodies, then LiveStore swaps
its hand-written `release.yml.genie.ts` for a `releaseWorkflow({...})`
call and the diff should be a near-pure deletion.
plan (same env-export step as above), optionally configures the
NPM_TOKEN fallback (`trustedPublishing: false`), runs `publishSteps`,
creates / updates the GitHub Release with
`--notes-file <releaseNotesPath>`, then runs `postPublishSteps`
with each step auto-gated on `<WORKSPACE>_RELEASE_DEPLOY_TARGET != 'none'`.

## Migration notes for consumers

The helper assumes the canonical job-id layout and trigger set listed
above. Consumers replacing a hand-written `release.yml.genie.ts`
should:

1. Move bash heredocs from the `create-release-pr` body into
`stagedFiles`, `releaseBranchPrefix`, and (if needed) custom
`prTitleTemplate` / `prBodyTemplate` strings.
2. Move the "read release plan + derive deploy target" bash into
`releaseChannels[*].deployTarget`. The helper writes the env
exports.
3. Move the GitHub Release creation step into the helper's contract by
ensuring the repo generates `release/release-notes.md` (default
path) before `create-release-pr` finishes — typically via a
`preCreatePrSteps` entry that runs `release:notes:extract`.
4. Keep the heavy Nix / cachix / megarepo / pnpm setup in `setupSteps`
(effect-utils intentionally does not require
`standardSelfHostedPnpmCiPrepSteps`). Use `extraSetupSteps` when
you only want to append a single extra step to the standard
setup.

## Test surface

`packages/@overeng/genie/src/runtime/github-workflow/release-workflow.unit.test.ts`
exercises the helper directly with a LiveStore-shaped input and asserts:

- the canonical job ids + dispatch input options,
- per-channel auto-merge case branches,
- per-channel deploy-target case branches,
- `gh release create --notes-file` wiring with default and custom
`releaseNotesPath`,
- template substitution for `prTitleTemplate` / `prBodyTemplate`,
- `trustedPublishing` toggling the NPM_TOKEN fallback,
- `postPublishSteps` gating with both no-op and explicit `if:`
expressions,
- `extraSetupSteps` ordering and the optional `source-policy` job.

The full `.github/workflows/release.yml` comparison stays in the
consumer's own test surface because the exact text depends on the
consumer's `setupSteps` / `validateSteps` / `publishSteps`.
Loading
Loading