diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e96152d00..84a3829cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `_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(N)`, `Schema.Set(T)` as `Set(N)`, plus the `Readonly*` variants. Detected via the `effect/annotation/TypeConstructor` annotation on `Declaration` ASTs. diff --git a/context/workflows/release-workflow.md b/context/workflows/release-workflow.md index b846fa6b99..6ac76aae3c 100644 --- a/context/workflows/release-workflow.md +++ b/context/workflows/release-workflow.md @@ -1,19 +1,21 @@ # 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 @@ -21,34 +23,27 @@ stable-release auto-merge gate). `.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-`, 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-` 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 @@ -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 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 + readonly runner?: GitHubWorkflowJob['runs-on'] } ``` @@ -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-` 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-` 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 `_RELEASE_VERSION`, `_NPM_TAG`, + and `_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 `, then runs `postPublishSteps` + with each step auto-gated on `_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`. diff --git a/genie/ci-workflow/release.ts b/genie/ci-workflow/release.ts index ea8c16c910..9279e367d5 100644 --- a/genie/ci-workflow/release.ts +++ b/genie/ci-workflow/release.ts @@ -1,18 +1,18 @@ /** * Reusable Changesets-based supervised release workflow generator. * - * Skeleton in-progress: this module captures the shape and contract that - * `effect-utils` will codify for the LiveStore release flow so downstream - * megarepo members (molty, openclaw, …) can adopt the same workflow - * structure by configuration instead of copy-paste. + * Produces a `githubWorkflow({...})` value with the canonical job layout used + * by LiveStore (and future adopters like molty / openclaw): an optional + * `source-policy` job, a `create-release-pr` job that opens the reviewable + * release-plan PR, a `validate-release-plan` job that dry-runs publish on + * the PR, and a `publish-release` job that runs on the post-merge push. * - * See `context/workflows/release-workflow.md` for the design document. + * The helper owns the job skeletons — triggers, permissions, `if:` gates, + * exported env vars, GitHub Release creation, optional auto-merge wiring — + * so consumers only configure typed inputs and pass the repo-specific + * `validateSteps` / `publishSteps`. * - * Status: STUB. The function returns a `githubWorkflow({...})` value with the - * correct triggers, permissions, and job ids/conditions, but the job bodies - * are intentionally placeholders. The next iteration wires the real - * `create-release-pr` / `validate-release-plan` / `publish-release` step - * sequences once the input shape and option naming have been reviewed. + * See `context/workflows/release-workflow.md` for the design document. */ import { @@ -25,9 +25,6 @@ import { bashShellDefaults, linuxX64Runner } from './shared.ts' type WorkflowJob = GitHubWorkflowArgs['jobs'][string] type WorkflowStep = WorkflowJob['steps'][number] -const placeholderRun = (label: string) => - `echo "TODO: ${label} — wired in follow-up; see context/workflows/release-workflow.md"\nexit 1` - // ============================================================================= // Types // ============================================================================= @@ -39,21 +36,27 @@ const placeholderRun = (label: string) => * `manualGate: false` enables GitHub auto-merge for the generated PR * (prerelease / dev releases). * - * Tag-specific deploy targets and validation overrides are intentionally - * left out of the skeleton; the spec doc tracks them as open questions. + * `deployTarget` drives the exported `LIVESTORE_RELEASE_DEPLOY_TARGET` + * (or generally `_RELEASE_DEPLOY_TARGET`) env var and gates + * `postPublishSteps` per channel without forcing consumers to re-derive + * prod-vs-dev from `npmTag` in bash. */ export type ReleaseChannel = { readonly manualGate: boolean + readonly deployTarget: 'prod' | 'dev' | 'none' } /** * Inputs to `releaseWorkflow`. * - * The shape intentionally mirrors the existing LiveStore release.yml so the - * first migration is a faithful encode-then-decode. Options that look - * LiveStore-specific (e.g. devtools artifact repack, prod docs deploy) are - * pushed out to `validateSteps` / `publishSteps` so the helper does not need - * to model them directly. + * Shape choices: + * + * - LiveStore-specific concerns (devtools repack, prod docs deploy, search + * sync) plug in as `validateSteps` / `publishSteps` / `postPublishSteps`. + * - The `create-release-pr` body is rendered from typed inputs (no + * consumer-owned bash heredoc). `${var}` substitution into the templates + * is enough; consumers that need richer templating can render a string + * themselves and pass it as `prBodyTemplate`. */ export type ReleaseWorkflowOptions = { /** @@ -62,13 +65,10 @@ export type ReleaseWorkflowOptions = { readonly name?: string /** - * Workspace identifier used in branch names and commit messages. - * For LiveStore this is `'livestore'`; the generated branch is - * `automation/release-` and the commit subject is - * `Prepare release`. - * - * Open question: should this carry a separate `releaseBranchPrefix` / - * `commitSubject` override, or always derive from `workspaceName`? + * Workspace identifier used in branch names, env var prefixes, and the + * default PR title template. For LiveStore this is `'livestore'`, which + * produces the env vars `LIVESTORE_RELEASE_VERSION`, `LIVESTORE_NPM_TAG`, + * and `LIVESTORE_RELEASE_DEPLOY_TARGET`. */ readonly workspaceName: string @@ -85,18 +85,23 @@ export type ReleaseWorkflowOptions = { readonly releasePlanPath?: string /** - * Files whose change should trigger the `validate-release-plan` job via - * `pull_request`. This is intentionally broader than `releasePlanPath` - * alone because release tooling changes also need to exercise the - * dry-run before they land. + * Files whose change should trigger `validate-release-plan` via + * `pull_request`. Broader than `releasePlanPath` alone because release + * tooling changes also need to exercise the dry-run before they land. */ readonly releasePlanPaths: readonly string[] + /** + * Path to the release notes markdown artifact that the release PR stages + * and the publish job uploads as the GitHub Release body via + * `gh release create --notes-file`. Defaults to + * `'release/release-notes.md'`. + */ + readonly releaseNotesPath?: string + /** * Per-npm-tag release policy. Keys are the npm dist-tags surfaced in the - * workflow_dispatch `npm_tag` input (`latest`, `dev`, `next`, …). - * - * The keys here drive the `choice` options of the `npm_tag` input. + * `workflow_dispatch` `npm_tag` input (`latest`, `dev`, `next`, …). */ readonly releaseChannels: Record @@ -106,50 +111,101 @@ export type ReleaseWorkflowOptions = { */ readonly defaultNpmTag?: string + /** + * Prefix for the automation branch created by `create-release-pr`. + * Defaults to `'automation/release-'`. The version is appended. + */ + readonly releaseBranchPrefix?: string + + /** + * Template for the release PR title. `${workspaceDisplayName}` and + * `${version}` are substituted at render time. + * + * Defaults to `'Prepare ${workspaceDisplayName} ${version} release'`. + */ + readonly prTitleTemplate?: string + + /** + * Template for the release PR body. `${workspaceDisplayName}` and + * `${version}` are substituted at render time. A reasonable default + * covers the rationale and the validate-then-publish handoff. + */ + readonly prBodyTemplate?: string + + /** + * Files to `git add` before committing the release plan PR. Consumers + * specify their full staged file set (release artifacts, package + * manifests, lockfiles, generated docs/examples manifests, …). + */ + readonly stagedFiles: readonly string[] + /** * Steps run at the start of each job after `actions/checkout@v4`. * - * Typically this is the repo's `standardSelfHostedPnpmCiPrepSteps(...)` - * (or `livestoreSetupSteps`) — devenv setup, nix cache restore, pnpm - * install, etc. The helper keeps this opaque on purpose; the release - * workflow only needs *some* working devenv to run `dt` tasks. + * When provided, this fully **replaces** the default empty setup. Use this + * if you want full control over the setup (devenv, nix cache restore, + * pnpm install, …). For LiveStore this is `livestoreSetupSteps`. */ - readonly setupSteps: readonly WorkflowStep[] + readonly setupSteps?: readonly WorkflowStep[] + + /** + * Extra steps appended after `setupSteps` (or after the empty default) + * but before the job's substance. Useful for repo-wide hooks like a + * single extra `actions/cache` call without redefining the entire + * setup sequence. + */ + readonly extraSetupSteps?: readonly WorkflowStep[] /** * Steps that perform the `validate-release-plan` payload. These run after - * `setupSteps` inside the `validate-release-plan` job and must: + * the setup steps and the synthetic-plan selection step. Must: * * - dry-run the actual npm publish (no token writes), and * - exercise any additional release-time repackaging the repo performs * (LiveStore: DevTools artifact repack-dryrun). * - * The helper appends a "select release plan for validation" step before - * this, and a "read release plan" step after, so this list can focus on - * the dry-run substance. + * The helper appends a "read release plan" step **after** these so the + * exported env vars are visible to anything that runs later. */ readonly validateSteps: readonly WorkflowStep[] /** - * Steps that perform the `publish-release` payload. These run after - * `setupSteps` inside the `publish-release` job and must: - * - * - publish the npm package set, and - * - perform any post-publish hooks (docs deploy, search sync, GitHub - * release upload, …). + * Steps that perform the npm publish payload in `publish-release`. These + * run after the setup steps, the "read release plan" step, and the + * (optional) npm token fallback. * - * The helper appends the "read release plan" + npm-token fallback steps - * before this; consumers do not need to repeat them. + * Things specific to particular deploy targets (prod docs deploy, + * production search sync) belong in `postPublishSteps`, not here. */ readonly publishSteps: readonly WorkflowStep[] /** - * If `true`, the publish job skips the explicit `NPM_TOKEN` fallback and - * relies on npm OIDC trusted publishing. LiveStore currently keeps the - * fallback as a safety net; new repos should default to `true`. + * Optional post-publish hooks (docs deploy, search index sync, …). Each + * step is automatically gated by the channel's `deployTarget`: + * + * - Steps in `postPublishSteps` only run when the channel's + * `deployTarget !== 'none'`. + * - Repos that need finer per-target gating (prod-only, dev-only) can + * still set an explicit `if:` on individual steps. * - * Open question: should this be `true` by default for new repos and - * `false` by default for repos that opt-in via `legacyNpmToken: true`? + * The helper exports `_RELEASE_DEPLOY_TARGET` so step-level + * `if:` conditions like `env.LIVESTORE_RELEASE_DEPLOY_TARGET == 'prod'` + * keep working. + */ + readonly postPublishSteps?: readonly WorkflowStep[] + + /** + * Additional step appended right before the "Open release plan PR" step + * runs. Useful for consumers that need to materialize a release artifact + * (e.g. extract release notes from the changelog) into the staged file + * set before the commit / push. + */ + readonly preCreatePrSteps?: readonly WorkflowStep[] + + /** + * If `true`, the publish job skips the explicit `NPM_TOKEN` fallback and + * relies on npm OIDC trusted publishing. New repos should default to + * `true`. LiveStore currently keeps the fallback as a safety net. */ readonly trustedPublishing?: boolean @@ -169,27 +225,33 @@ export type ReleaseWorkflowOptions = { * Additional workflow-level env. Merged with the standard release env. */ readonly env?: Record + + /** + * Runner labels for the `validate-release-plan` and `publish-release` + * jobs. `create-release-pr` always runs on `ubuntu-latest` because it + * only needs `git` + `gh`. Defaults to `linuxX64Runner`. + */ + readonly runner?: GitHubWorkflowJobRunsOn } +type GitHubWorkflowJobRunsOn = WorkflowJob['runs-on'] + // ============================================================================= -// Implementation (skeleton) +// Implementation // ============================================================================= /** * Build a Changesets-based supervised release workflow. * - * The shape is fixed across consumers: + * Job layout: * - * - Jobs: `source-policy` (optional), `create-release-pr`, - * `validate-release-plan`, `publish-release`. - * - Triggers: `workflow_dispatch` (mode input), `pull_request` on - * `releasePlanPaths`, `push` to `main` on `releasePlanPath`. - * - Job `if` conditions wire each job to the right subset of triggers. + * - `source-policy` (optional) + * - `create-release-pr` — `workflow_dispatch` `mode=create-release-pr` + * - `validate-release-plan` — `pull_request` + `workflow_dispatch` `mode=validate-release-plan` + * - `publish-release` — `push` to `main` + `workflow_dispatch` `mode=publish-release` * - * The job bodies are intentionally minimal in this skeleton; see the design - * doc for the planned content and the LiveStore reference implementation at - * `livestorejs/livestore:.github/workflows/release.yml.genie.ts` for the - * shape this generator will eventually produce. + * See `context/workflows/release-workflow.md` for the worked LiveStore + * example and migration notes. */ export const releaseWorkflow = (opts: ReleaseWorkflowOptions) => { const npmTagOptions = Object.keys(opts.releaseChannels) @@ -206,15 +268,116 @@ export const releaseWorkflow = (opts: ReleaseWorkflowOptions) => { } const releasePlanPath = opts.releasePlanPath ?? 'release/release-plan.json' + const releaseNotesPath = opts.releaseNotesPath ?? 'release/release-notes.md' + const releaseBranchPrefix = opts.releaseBranchPrefix ?? 'automation/release-' + const workspaceDisplayName = + opts.workspaceDisplayName ?? capitalizeFirst(opts.workspaceName) + const envPrefix = opts.workspaceName.toUpperCase().replaceAll(/[^A-Z0-9]/g, '_') + const versionEnv = `${envPrefix}_RELEASE_VERSION` + const npmTagEnv = `${envPrefix}_NPM_TAG` + const deployTargetEnv = `${envPrefix}_RELEASE_DEPLOY_TARGET` - // PLACEHOLDER job bodies. The next iteration replaces these `run` blocks - // with the real step sequence from the LiveStore reference workflow: - // - create-release-pr: changeset:version + open/refresh PR + dispatch - // validate workflow + (auto-merge based on channel.manualGate). - // - validate-release-plan: synthetic-plan selection + dryrun publish + - // repo-supplied validateSteps. - // - publish-release: read release plan + (optional) npm token fallback + - // repo-supplied publishSteps. + const prTitleTemplate = + opts.prTitleTemplate ?? `Prepare ${'${workspaceDisplayName}'} ${'${version}'} release` + const prBodyTemplate = + opts.prBodyTemplate ?? + [ + `Prepares a ${'${workspaceDisplayName}'} release group for ${'${version}'} from the pending Changesets.`, + '', + `The release workflow dry-runs the npm publish on this PR. After merge into main, the same workflow publishes the release group. The publish job can also be manually dispatched after an operator verifies that the checked-in release plan is still the intended release.`, + '', + '## Rationale', + '', + `Release cutting is represented as a reviewable data change instead of a local operator action. Changesets provide the release intent and fixed-group version calculation; ${'${workspaceDisplayName}'}'s existing publisher remains responsible for package provenance.`, + ].join('\n') + + const resolvedSetupSteps = opts.setupSteps ?? [] + const extraSetupSteps = opts.extraSetupSteps ?? [] + const setupSteps: readonly WorkflowStep[] = [...resolvedSetupSteps, ...extraSetupSteps] + + const stagedFiles = opts.stagedFiles + if (stagedFiles.length === 0) { + throw new Error('releaseWorkflow: stagedFiles must include at least the release plan path') + } + + // -- create-release-pr ------------------------------------------------------ + + // Channel-policy lookup expressed as bash case branches. Drives the + // auto-merge toggle without consumers having to re-derive it. + const autoMergeCaseBranches = npmTagOptions + .map((tag) => { + const channel = opts.releaseChannels[tag]! + const action = channel.manualGate === true ? 'manual' : 'auto' + return ` ${tag}) policy=${action} ;;` + }) + .join('\n') + + const renderTemplate = (template: string, version = '$RELEASE_VERSION') => + template + .replaceAll('${workspaceDisplayName}', workspaceDisplayName) + .replaceAll('${workspaceName}', opts.workspaceName) + .replaceAll('${version}', version) + + const stagedFilesBlock = stagedFiles.map((f) => ` ${f}`).join(' \\\n') + + const openReleasePrRun = [ + `set -euo pipefail`, + `RELEASE_VERSION="$(jq -r '.version' ${shQuote(releasePlanPath)})"`, + `: "\${RELEASE_VERSION:?Missing generated release version}"`, + `: "\${${npmTagEnv}:?Missing npm tag}"`, + ``, + `git config user.name "github-actions[bot]"`, + `git config user.email "41898282+github-actions[bot]@users.noreply.github.com"`, + ``, + `branch="${releaseBranchPrefix}$RELEASE_VERSION"`, + `git checkout -B "$branch"`, + `git add \\`, + `${stagedFilesBlock}`, + ``, + `if git diff --cached --quiet; then`, + ` echo "Release plan already current."`, + `else`, + ` git commit -m "Prepare ${workspaceDisplayName} $RELEASE_VERSION release"`, + ` git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" || true`, + ` git push --force-with-lease="refs/heads/$branch" origin "$branch"`, + `fi`, + ``, + `title=${shQuote(renderTemplate(prTitleTemplate))}`, + `body=$(cat <<'BODY'`, + `${renderTemplate(prBodyTemplate)}`, + `BODY`, + `)`, + ``, + `if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then`, + ` gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body"`, + `else`, + ` gh pr create \\`, + ` --repo "$GITHUB_REPOSITORY" \\`, + ` --base main \\`, + ` --head "$branch" \\`, + ` --title "$title" \\`, + ` --body "$body"`, + `fi`, + ``, + `gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$branch" || true`, + `gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$branch" \\`, + ` -f mode=validate-release-plan \\`, + ` -f npm_tag="$${npmTagEnv}"`, + ``, + `policy=manual`, + `case "$${npmTagEnv}" in`, + autoMergeCaseBranches, + ` *) policy=manual ;;`, + `esac`, + ``, + `if [ "$policy" = "manual" ]; then`, + ` echo "npm_tag=$${npmTagEnv}: leaving auto-merge disabled; this PR requires a human reviewer."`, + `elif gh pr view "$branch" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest --jq '.autoMergeRequest != null' | grep -qx true; then`, + ` echo "Auto-merge already enabled for $branch."`, + `else`, + ` gh pr merge "$branch" --repo "$GITHUB_REPOSITORY" --auto --merge`, + `fi`, + ].join('\n') const createReleasePrJob: WorkflowJob = { if: "github.event_name == 'workflow_dispatch' && inputs.mode == 'create-release-pr'", @@ -228,38 +391,173 @@ export const releaseWorkflow = (opts: ReleaseWorkflowOptions) => { defaults: bashShellDefaults, steps: [ { name: 'Checkout', uses: 'actions/checkout@v4', with: { ref: 'main' } }, - ...opts.setupSteps, - { name: 'Create release plan PR (placeholder)', run: placeholderRun('create-release-pr') }, + // setupSteps usually starts with its own checkout; drop it on + // create-release-pr because we already checked out main above. + ...setupSteps.slice(setupStepsStartsWithCheckout(setupSteps) ? 1 : 0), + ...(opts.preCreatePrSteps ?? []), + { + name: 'Open release plan PR', + env: { + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}', + [npmTagEnv]: '${{ inputs.npm_tag }}', + }, + run: openReleasePrRun, + }, ], } + // -- validate-release-plan -------------------------------------------------- + + const deployTargetCaseBranches = npmTagOptions + .map((tag) => { + const channel = opts.releaseChannels[tag]! + return ` ${tag}) deploy_target=${channel.deployTarget} ;;` + }) + .join('\n') + + const selectReleasePlanRun = [ + `set -euo pipefail`, + `use_synthetic_plan=false`, + ``, + `if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then`, + ` git fetch origin "\${{ github.base_ref }}" --depth=1`, + ` if ! git diff --name-only "origin/\${{ github.base_ref }}...HEAD" | grep -qx ${shQuote(releasePlanPath)}; then`, + ` use_synthetic_plan=true`, + ` fi`, + `elif [ ! -f ${shQuote(releasePlanPath)} ]; then`, + ` use_synthetic_plan=true`, + `fi`, + ``, + `if [ "$use_synthetic_plan" = "false" ]; then`, + ` exit 0`, + `fi`, + ``, + `mkdir -p "$(dirname ${shQuote(releasePlanPath)})"`, + `# PRs that touch release machinery but do not carry an actual release plan still`, + `# need to exercise package publishing. Use a unique, unpublished prerelease`, + `# version derived from the commit SHA.`, + `short_sha="\${GITHUB_SHA:0:12}"`, + `version="0.0.0-ci.release-validation.$short_sha"`, + `npm_tag=${shQuote(syntheticNpmTag(opts.releaseChannels))}`, + `jq -n \\`, + ` --arg version "$version" \\`, + ` --arg npmTag "$npm_tag" \\`, + ` '{ schemaVersion: 1, version: $version, npmTag: $npmTag }' > ${shQuote(releasePlanPath)}`, + ].join('\n') + + const readReleasePlanWithDeployTargetRun = [ + `set -euo pipefail`, + `release_version="$(jq -r '.version' ${shQuote(releasePlanPath)})"`, + `npm_tag="$(jq -r '.npmTag' ${shQuote(releasePlanPath)})"`, + `: "\${release_version:?Missing release version}"`, + `: "\${npm_tag:?Missing npm tag}"`, + `echo "${versionEnv}=$release_version" >> "$GITHUB_ENV"`, + `echo "${npmTagEnv}=$npm_tag" >> "$GITHUB_ENV"`, + `deploy_target=none`, + `case "$npm_tag" in`, + deployTargetCaseBranches, + ` *) deploy_target=none ;;`, + `esac`, + `echo "${deployTargetEnv}=$deploy_target" >> "$GITHUB_ENV"`, + ].join('\n') + const validateReleasePlanJob: WorkflowJob = { if: "github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'validate-release-plan')", - 'runs-on': linuxX64Runner as unknown as string[], + 'runs-on': opts.runner ?? (linuxX64Runner as unknown as string[]), defaults: bashShellDefaults, steps: [ - ...opts.setupSteps, - { name: 'Select release plan for validation (placeholder)', run: placeholderRun('select release plan') }, + ...setupSteps, + { + name: 'Select release plan for validation', + run: selectReleasePlanRun, + }, ...opts.validateSteps, + { + name: 'Read release plan', + run: readReleasePlanWithDeployTargetRun, + }, ], } + // -- publish-release -------------------------------------------------------- + + const readReleasePlanForPublishRun = [ + `set -euo pipefail`, + `release_version="$(jq -r '.version' ${shQuote(releasePlanPath)})"`, + `npm_tag="$(jq -r '.npmTag' ${shQuote(releasePlanPath)})"`, + `: "\${release_version:?Missing release version}"`, + `: "\${npm_tag:?Missing npm tag}"`, + `echo "${versionEnv}=$release_version" >> "$GITHUB_ENV"`, + `echo "${npmTagEnv}=$npm_tag" >> "$GITHUB_ENV"`, + `deploy_target=none`, + `case "$npm_tag" in`, + deployTargetCaseBranches, + ` *) deploy_target=none ;;`, + `esac`, + `echo "${deployTargetEnv}=$deploy_target" >> "$GITHUB_ENV"`, + ].join('\n') + + const npmTokenFallbackStep: WorkflowStep = { + name: 'Configure npm token fallback', + run: [ + `set -euo pipefail`, + `: "\${NODE_AUTH_TOKEN:?Missing NPM_TOKEN secret}"`, + `npmrc="$HOME/.npmrc"`, + `printf '%s\\n' "always-auth=true" > "$npmrc"`, + `printf '%s\\n' "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> "$npmrc"`, + `printf '%s\\n' "NPM_CONFIG_USERCONFIG=$npmrc" >> "$GITHUB_ENV"`, + `printf '%s\\n' "NPM_CONFIG_REGISTRY=https://registry.npmjs.org/" >> "$GITHUB_ENV"`, + `NPM_CONFIG_USERCONFIG="$npmrc" NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ npm whoami >/dev/null`, + ].join('\n'), + } + + const createGitHubReleaseStep: WorkflowStep = { + name: 'Create GitHub Release', + env: { GH_TOKEN: '${{ github.token }}' }, + run: [ + `set -euo pipefail`, + `: "\${${versionEnv}:?Missing release version}"`, + `if [ ! -f ${shQuote(releaseNotesPath)} ]; then`, + ` echo "::warning::release notes file ${releaseNotesPath} not found; creating release without --notes-file"`, + ` notes_args=()`, + `else`, + ` notes_args=(--notes-file ${shQuote(releaseNotesPath)})`, + `fi`, + `tag="v$${versionEnv}"`, + `if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then`, + ` gh release edit "$tag" --repo "$GITHUB_REPOSITORY" "\${notes_args[@]}"`, + `else`, + ` gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --target "$GITHUB_SHA" "\${notes_args[@]}"`, + `fi`, + ].join('\n'), + } + + const gatedPostPublishSteps: readonly WorkflowStep[] = (opts.postPublishSteps ?? []).map((step) => + gateStepByDeployTarget(step, deployTargetEnv), + ) + const publishReleaseJob: WorkflowJob = { if: "github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-release')", - 'runs-on': linuxX64Runner as unknown as string[], + 'runs-on': opts.runner ?? (linuxX64Runner as unknown as string[]), permissions: { contents: 'write', 'id-token': 'write', }, env: { GH_TOKEN: '${{ github.token }}', - ...(opts.trustedPublishing === false ? { NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' } : {}), + ...(opts.trustedPublishing === true ? {} : { NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' }), }, defaults: bashShellDefaults, steps: [ - ...opts.setupSteps, - { name: 'Read release plan (placeholder)', run: placeholderRun('read release plan') }, + ...setupSteps, + { + name: 'Read release plan', + run: readReleasePlanForPublishRun, + }, + ...(opts.trustedPublishing === true ? [] : [npmTokenFallbackStep]), ...opts.publishSteps, + createGitHubReleaseStep, + ...gatedPostPublishSteps, ], } @@ -313,3 +611,54 @@ export const releaseWorkflow = (opts: ReleaseWorkflowOptions) => { jobs, }) } + +// ============================================================================= +// Internal helpers +// ============================================================================= + +const capitalizeFirst = (value: string) => + value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1) + +/** Single-quote a value for safe inclusion in bash, escaping embedded quotes. */ +const shQuote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'` + +/** + * Pick a sensible npm tag for synthesized release plans during PR validation. + * + * Prefers a non-`latest` channel so the synthesized plan never collides with + * the stable release lane. Falls back to whatever channel exists. + */ +const syntheticNpmTag = (channels: Record) => { + const keys = Object.keys(channels) + const next = keys.find((k) => k === 'next') + if (next !== undefined) return next + const dev = keys.find((k) => k === 'dev') + if (dev !== undefined) return dev + const nonLatest = keys.find((k) => k !== 'latest') + return nonLatest ?? keys[0]! +} + +/** + * If `setupSteps` opens with an `actions/checkout@*` step, the + * `create-release-pr` job skips it (we already checked out `main` with an + * explicit ref). This matches the common pattern where shared setup helpers + * start with `checkoutStep()`. + */ +const setupStepsStartsWithCheckout = (steps: readonly WorkflowStep[]) => { + const first = steps[0] + if (first === undefined) return false + const uses = (first as { uses?: unknown }).uses + return typeof uses === 'string' && uses.startsWith('actions/checkout@') +} + +/** + * Wrap an `if:` around a step so it only runs when the deploy target is + * not `none`. Existing `if:` expressions are preserved via `&&`. + */ +const gateStepByDeployTarget = (step: WorkflowStep, deployTargetEnv: string): WorkflowStep => { + const existing = (step as { if?: unknown }).if + const gate = `env.${deployTargetEnv} != 'none'` + const nextIf = + typeof existing === 'string' && existing.length > 0 ? `(${existing}) && (${gate})` : gate + return { ...step, if: nextIf } as WorkflowStep +} diff --git a/packages/@overeng/genie/src/runtime/github-workflow/release-workflow.unit.test.ts b/packages/@overeng/genie/src/runtime/github-workflow/release-workflow.unit.test.ts new file mode 100644 index 0000000000..f524ecd384 --- /dev/null +++ b/packages/@overeng/genie/src/runtime/github-workflow/release-workflow.unit.test.ts @@ -0,0 +1,146 @@ +/** + * Structural tests for the shared `releaseWorkflow` helper in + * `genie/ci-workflow/release.ts`. + * + * The helper is the consumer-facing seam for the Changesets-based + * supervised release flow; downstream repos like LiveStore (and future + * molty / openclaw) call it directly. These tests read the helper's source + * (`readFileSync`-style, the same pattern as `ci-workflow-helpers.unit.test.ts`) + * so they can run inside the `@overeng/genie` package's `rootDir` while still + * asserting structural invariants on the cross-package source. Behavioral + * end-to-end coverage of the rendered `release.yml` lives in the consumer's + * own test surface, where the consumer's `setupSteps` / `validateSteps` / + * `publishSteps` are concrete. + */ + +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const releaseSource = readFileSync( + new URL('../../../../../../genie/ci-workflow/release.ts', import.meta.url), + 'utf8', +) +const releaseDocSource = readFileSync( + new URL('../../../../../../context/workflows/release-workflow.md', import.meta.url), + 'utf8', +) + +describe('releaseWorkflow input shape', () => { + it('extends ReleaseChannel with deployTarget for prod/dev/none', () => { + expect(releaseSource).toContain('export type ReleaseChannel = {') + expect(releaseSource).toContain('readonly manualGate: boolean') + expect(releaseSource).toContain("readonly deployTarget: 'prod' | 'dev' | 'none'") + }) + + it('adds typed inputs for the create-release-pr body (no consumer-owned bash heredoc)', () => { + expect(releaseSource).toContain('readonly stagedFiles: readonly string[]') + expect(releaseSource).toContain('readonly releaseBranchPrefix?: string') + expect(releaseSource).toContain('readonly prTitleTemplate?: string') + expect(releaseSource).toContain('readonly prBodyTemplate?: string') + expect(releaseSource).toContain('readonly releaseNotesPath?: string') + }) + + it('keeps setupSteps as a fully-replacing default and extraSetupSteps as additive', () => { + expect(releaseSource).toContain('readonly setupSteps?: readonly WorkflowStep[]') + expect(releaseSource).toContain('readonly extraSetupSteps?: readonly WorkflowStep[]') + }) + + it('separates per-channel postPublishSteps via deployTarget gating', () => { + expect(releaseSource).toContain('readonly postPublishSteps?: readonly WorkflowStep[]') + expect(releaseSource).toContain('gateStepByDeployTarget') + }) +}) + +describe('releaseWorkflow generated workflow shape', () => { + it('emits the canonical job ids', () => { + expect(releaseSource).toContain("jobs['create-release-pr']") + expect(releaseSource).toContain("jobs['validate-release-plan']") + expect(releaseSource).toContain("jobs['publish-release']") + }) + + it('drives the npm_tag dispatch choices from releaseChannels keys', () => { + expect(releaseSource).toContain('options: npmTagOptions') + expect(releaseSource).toContain('Object.keys(opts.releaseChannels)') + }) + + it('exports workspace-prefixed release env vars', () => { + expect(releaseSource).toContain('const versionEnv = `${envPrefix}_RELEASE_VERSION`') + expect(releaseSource).toContain('const npmTagEnv = `${envPrefix}_NPM_TAG`') + expect(releaseSource).toContain('const deployTargetEnv = `${envPrefix}_RELEASE_DEPLOY_TARGET`') + }) + + it('renders per-channel auto-merge case branches in the open-pr step', () => { + expect(releaseSource).toContain('autoMergeCaseBranches') + expect(releaseSource).toContain("manualGate === true ? 'manual' : 'auto'") + expect(releaseSource).toContain('gh pr merge "$branch"') + expect(releaseSource).toContain('--auto --merge') + }) + + it('renders per-channel deploy-target case branches for the read-plan step', () => { + expect(releaseSource).toContain('deployTargetCaseBranches') + expect(releaseSource).toContain('deploy_target=${channel.deployTarget}') + expect(releaseSource).toContain('echo "${deployTargetEnv}=$deploy_target" >> "$GITHUB_ENV"') + }) + + it('creates / refreshes a GitHub Release with --notes-file', () => { + expect(releaseSource).toContain('createGitHubReleaseStep') + expect(releaseSource).toContain('gh release create') + expect(releaseSource).toContain('gh release edit') + expect(releaseSource).toContain('--notes-file') + expect(releaseSource).toContain("opts.releaseNotesPath ?? 'release/release-notes.md'") + }) + + it('toggles the NPM_TOKEN fallback off when trustedPublishing is true', () => { + expect(releaseSource).toContain('opts.trustedPublishing === true ? [] : [npmTokenFallbackStep]') + expect(releaseSource).toContain( + "opts.trustedPublishing === true ? {} : { NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' }", + ) + }) + + it('keeps the create-release-pr job on ubuntu-latest (needs only git + gh)', () => { + expect(releaseSource).toContain("'runs-on': 'ubuntu-latest'") + }) + + it('preserves consumer if: when wrapping postPublishSteps with the deploy-target gate', () => { + expect(releaseSource).toContain('gateStepByDeployTarget') + expect(releaseSource).toContain('`(${existing}) && (${gate})`') + }) + + it('skips the leading checkout in setupSteps for create-release-pr (already checked out main)', () => { + expect(releaseSource).toContain('setupStepsStartsWithCheckout') + expect(releaseSource).toContain("uses.startsWith('actions/checkout@')") + }) + + it('renders ${workspaceDisplayName} / ${version} into the PR templates with simple substitution', () => { + expect(releaseSource).toContain('renderTemplate') + expect(releaseSource).toContain("replaceAll('${workspaceDisplayName}', workspaceDisplayName)") + expect(releaseSource).toContain("replaceAll('${version}', version)") + }) + + it('validates inputs upfront', () => { + expect(releaseSource).toContain('at least one releaseChannels entry is required') + expect(releaseSource).toContain("defaultNpmTag '${defaultNpmTag}' is not present") + expect(releaseSource).toContain('stagedFiles must include at least the release plan path') + }) +}) + +describe('release-workflow design doc', () => { + it('declares the helper adoption-ready (no longer skeleton-only)', () => { + expect(releaseDocSource).toMatch(/Status:\s*\*\*adoption-ready/) + expect(releaseDocSource).not.toMatch(/Status:\s*\*\*draft \/ skeleton/) + }) + + it('documents the new deployTarget channel field', () => { + expect(releaseDocSource).toContain("readonly deployTarget: 'prod' | 'dev' | 'none'") + }) + + it('documents stagedFiles + releaseNotesPath in the input shape', () => { + expect(releaseDocSource).toContain('readonly stagedFiles: readonly string[]') + expect(releaseDocSource).toContain('readonly releaseNotesPath?: string') + }) + + it('documents the migration notes for consumers', () => { + expect(releaseDocSource).toContain('## Migration notes for consumers') + }) +})