Add Elsa release skill

This commit is contained in:
Sipke Schoorstra 2026-05-20 20:23:30 +02:00
parent 37cf451ec0
commit d21da0afce
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
4 changed files with 694 additions and 0 deletions

View file

@ -0,0 +1,218 @@
---
name: elsa-release
description: Release Elsa repositories from GitHub tags. Use when Codex needs to create a preview or stable GitHub release for Elsa Core, Elsa Studio, Elsa Extensions, or any similarly configured Elsa repository where releases are driven by Git tags and GitHub release events; supports curated release notes, retagging an existing RC tag as a stable tag, publishing prereleases without NuGet, publishing stable releases that trigger NuGet, and sequencing downstream Elsa repository releases after packages are available.
---
# Elsa Release
## Overview
Use this skill to release an Elsa repository by preparing curated release notes, creating or reusing a Git tag, creating the matching GitHub release, and letting the repository's GitHub Actions pipeline build and publish packages. Stable releases are normal GitHub releases; preview releases are GitHub prereleases.
The bundled helper `scripts/release.py` performs the repeatable release checks and prints the exact Git/GitHub commands before execution. It defaults to dry-run mode. The bundled helper `scripts/release_notes.py` collects commits into a categorized Markdown scaffold for curated release notes.
## Inputs
Collect or infer:
- Repository path or GitHub repo, e.g. `elsa-workflows/elsa-core`.
- Desired release tag, e.g. `3.7.0`.
- Release kind: `stable` or `preview`.
- Source ref, if the desired tag should point at a specific existing ref. For stable-from-RC releases, use the RC tag, e.g. `3.7.0-rc1`.
- Release notes range: previous release tag/ref to desired release tag/ref. For stable releases, compare against the previous stable tag unless the user requests another range.
- Release notes strategy: curated notes by default; generated GitHub notes only when the user explicitly wants the quick path or there is not enough time/context.
If the user asks for "stable 3.7 from RC1", interpret that as:
```bash
python3 .agents/skills/elsa-release/scripts/release.py \
--repo-path /path/to/repo \
--source-ref 3.7.0-rc1 \
--tag 3.7.0 \
--release-kind stable
```
## Release Workflow
1. Inspect the repository's release workflow before acting.
- Confirm it has a `release` trigger.
- Confirm stable vs preview behavior. In Elsa Core, `.github/workflows/packages.yml` publishes to feedz.io for release events and publishes to nuget.org only when `github.event.action == 'published'`.
- Treat draft releases carefully because draft publication can change release event behavior.
2. Prepare the dry run.
- Run the helper without `--execute`.
- Confirm the source commit, destination tag, remote repository, containing remote branches, and release command.
- For Elsa-style pipelines, the source commit should be reachable from `origin/main` or an `origin/release/*` branch unless the user explicitly accepts the risk.
3. Prepare curated release notes.
- Generate a scaffold with `scripts/release_notes.py`.
- Rewrite the scaffold into polished, developer-facing release notes before publishing.
- Store Elsa Core release notes under `doc/changelogs/<version>.md` when working inside this repository.
- Pass the curated notes to `scripts/release.py` with `--notes-file`.
4. Ask for explicit confirmation before live operations.
- Pushing a tag and publishing a GitHub release are production release actions.
- Show the exact source ref, resolved commit, desired tag, release kind, and target GitHub repository.
- Show the release notes file path and compare range.
- Do not use `--execute` until the user confirms.
5. Execute the release.
- Re-run the same command with `--execute`.
- The helper creates an annotated tag if needed, pushes it, then runs `gh release create`.
- Stable releases are created without `--prerelease` and with `--latest`.
- Preview releases are created with `--prerelease` and `--latest=false`.
6. Verify GitHub.
- Check `gh release view <tag> --repo <owner/repo>`.
- Check the repository's Actions tab or `gh run list --repo <owner/repo> --workflow <workflow> --limit 5`.
- Confirm the pipeline started from the `release` event and is using the expected version tag.
7. Sequence Elsa repositories.
- Release Elsa Core first.
- Wait until packages are available in NuGet and/or feedz.io according to the release kind.
- Update Elsa Studio and Elsa Extensions to consume the newly published Elsa Core package versions using their repository-specific dependency update process.
- Release Elsa Studio and Elsa Extensions with the same skill when they use the same tag-and-GitHub-release pattern.
## Curated Release Notes
Recommendation: use curated release notes for stable releases and meaningful previews. GitHub generated notes are useful raw input, but the final release should explain why changes matter to developers consuming Elsa packages.
Use this structure:
```markdown
Compare: <from-ref>...<to-ref>
---
## 🌟 Highlights
---
## ⚠️ Breaking changes / upgrade notes
---
## ✨ New features
### Component or theme
---
## 🔧 Improvements
---
## 🐛 Fixes
---
## 🔒 Security
---
## 🧩 Developer-facing changes
---
## 🧪 Tests
---
## 🔁 CI / Build
---
## 📦 Dependencies
---
## 📦 Full changelog (short)
```
Omit empty sections. Put the highest-signal user-facing changes in `Highlights` first, limited to 3-6 bullets. Keep `Full changelog` comprehensive so every commit or PR in the range is represented somewhere.
Writing rules:
- Prefer PR titles and labels when available; otherwise use commit subjects.
- Do not paste a flat generated changelog as the final result.
- Group related changes under component-oriented subsection headings when that improves scanning, e.g. `#### Workflows`, `#### Shells`, `#### HTTP`, `#### Persistence`.
- Follow the Elsa `3.7.0-rc1` style: compare line first, `---` separators, `##` category headings with small icons, component prefix before the colon, and a short full changelog at the end.
- For breaking changes, include who is affected and what to do.
- For fixes, explain the observable problem that was corrected, not only the implementation detail.
- For dependency/package changes, include package names and versions when available.
- End bullets with a PR number or short SHA when available, e.g. `(#7400)` or `(b88af1e02)`.
- Never invent PR numbers, affected components, migration steps, or known issues.
Generate a scaffold:
```bash
python3 .agents/skills/elsa-release/scripts/release_notes.py \
--repo-path . \
--from-ref 3.6.2 \
--to-ref 3.7.0 \
--version 3.7.0 \
--output doc/changelogs/3.7.0.md
```
Then edit the scaffold into polished notes and release with:
```bash
python3 .agents/skills/elsa-release/scripts/release.py \
--repo-path . \
--source-ref origin/release/3.7.0 \
--tag 3.7.0 \
--release-kind stable \
--notes-file doc/changelogs/3.7.0.md
```
If the GitHub release already exists and only the notes need improvement, update it with:
```bash
gh release edit 3.7.0 \
--repo elsa-workflows/elsa-core \
--notes-file doc/changelogs/3.7.0.md
```
## Helper Usage
Dry run a stable release by retagging an RC:
```bash
python3 .agents/skills/elsa-release/scripts/release.py \
--repo-path . \
--source-ref 3.7.0-rc1 \
--tag 3.7.0 \
--release-kind stable
```
Execute after confirmation:
```bash
python3 .agents/skills/elsa-release/scripts/release.py \
--repo-path . \
--source-ref 3.7.0-rc1 \
--tag 3.7.0 \
--release-kind stable \
--execute
```
Dry run a preview release from the current commit:
```bash
python3 .agents/skills/elsa-release/scripts/release.py \
--repo-path . \
--tag 3.8.0-preview2 \
--release-kind preview
```
Use `--github-repo owner/name` when the local remote is ambiguous. Use `--notes-file path/to/notes.md` to publish supplied release notes instead of generated notes. Use `--notes-start-tag <tag>` to control GitHub's generated-notes comparison range.
## Guardrails
- Never move, delete, or force-update an existing release tag unless the user explicitly requests that exact destructive operation.
- Never publish a stable release when the user asked for preview.
- Do not create a draft release for the normal automated pipeline unless the repository workflow has been reviewed and the user explicitly wants a draft.
- Keep release notes and release titles factual. Prefer the exact tag as the GitHub release title unless the repository has a different convention.
- Do not publish curated notes without reviewing the compare range and confirming all included commits belong in the release.
- If `gh` is unauthenticated, stop and ask the user to authenticate; do not try to handle credentials.
- If the pipeline semantics are unclear, inspect the workflow or ask before publishing.

View file

@ -0,0 +1,4 @@
interface:
display_name: "Elsa Release"
short_description: "Release Elsa repositories from GitHub tags"
default_prompt: "Use $elsa-release to create a stable GitHub release from the existing RC tag."

View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Prepare or execute Elsa-style GitHub releases from Git tags."""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Command:
args: list[str]
cwd: Path | None = None
def main() -> int:
args = parse_args()
repo_path = Path(args.repo_path).expanduser().resolve()
ensure_tool("git")
ensure_tool("gh")
ensure_git_repo(repo_path)
remote = args.remote
github_repo = args.github_repo or infer_github_repo(repo_path, remote)
fetch_command = ["git", "fetch", "--tags", "--prune", remote]
print("$ " + shell_join(fetch_command), flush=True)
run(fetch_command, cwd=repo_path, execute=True)
source_ref = args.source_ref or "HEAD"
source_commit = git(["rev-parse", f"{source_ref}^{{commit}}"], repo_path)
containing_branches = remote_branches_containing(repo_path, source_commit)
validate_containing_branches(containing_branches, args.allow_uncontained)
tag_exists_local = ref_exists(repo_path, f"refs/tags/{args.tag}")
tag_exists_remote = remote_tag_exists(repo_path, remote, args.tag)
if tag_exists_local or tag_exists_remote:
existing_commit = git(["rev-list", "-n", "1", f"{args.tag}^{{commit}}"], repo_path, check=False)
if existing_commit != source_commit:
fail(f"Tag {args.tag} already exists and points to {existing_commit or 'an unknown commit'}, not {source_commit}.")
print(f"Tag {args.tag} already exists at the requested commit; the helper will reuse it.")
title = args.title or args.tag
commands = build_commands(
repo_path=repo_path,
remote=remote,
github_repo=github_repo,
tag=args.tag,
source_commit=source_commit,
title=title,
release_kind=args.release_kind,
notes_file=args.notes_file,
notes_start_tag=args.notes_start_tag,
tag_exists_local=tag_exists_local,
tag_exists_remote=tag_exists_remote,
)
print_summary(
repo_path=repo_path,
github_repo=github_repo,
source_ref=source_ref,
source_commit=source_commit,
tag=args.tag,
release_kind=args.release_kind,
containing_branches=containing_branches,
execute=args.execute,
)
for command in commands:
print("$ " + shell_join(command.args), flush=True)
if args.execute:
run(command.args, cwd=command.cwd, execute=True)
if not args.execute:
print("\nDry run only. Re-run with --execute after explicit release approval.")
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo-path", default=".", help="Local repository path.")
parser.add_argument("--github-repo", help="GitHub repository as owner/name. Inferred from the git remote when omitted.")
parser.add_argument("--remote", default="origin", help="Git remote to fetch and push tags to.")
parser.add_argument("--source-ref", help="Existing tag, branch, or commit for the release tag. Defaults to HEAD.")
parser.add_argument("--tag", required=True, help="Desired release tag, e.g. 3.7.0 or 3.8.0-preview2.")
parser.add_argument("--release-kind", required=True, choices=("stable", "preview"), help="Stable creates a normal release; preview creates a prerelease.")
parser.add_argument("--title", help="GitHub release title. Defaults to the tag.")
parser.add_argument("--notes-file", help="Release notes Markdown file. Defaults to GitHub generated notes.")
parser.add_argument("--notes-start-tag", help="Starting tag for GitHub generated release notes.")
parser.add_argument("--allow-uncontained", action="store_true", help="Allow source commits not contained in origin/main or origin/release/*.")
parser.add_argument("--execute", action="store_true", help="Create/push the tag and publish the GitHub release.")
return parser.parse_args()
def build_commands(
*,
repo_path: Path,
remote: str,
github_repo: str,
tag: str,
source_commit: str,
title: str,
release_kind: str,
notes_file: str | None,
notes_start_tag: str | None,
tag_exists_local: bool,
tag_exists_remote: bool,
) -> list[Command]:
commands: list[Command] = []
if not tag_exists_local:
commands.append(Command(["git", "tag", "-a", tag, source_commit, "-m", f"Release {tag}"], repo_path))
if not tag_exists_remote:
commands.append(Command(["git", "push", remote, f"refs/tags/{tag}"], repo_path))
release = [
"gh",
"release",
"create",
tag,
"--repo",
github_repo,
"--verify-tag",
"--title",
title,
]
if notes_file:
release.extend(["--notes-file", notes_file])
else:
release.append("--generate-notes")
if notes_start_tag:
release.extend(["--notes-start-tag", notes_start_tag])
if release_kind == "preview":
release.extend(["--prerelease", "--latest=false"])
else:
release.append("--latest")
commands.append(Command(release, repo_path))
return commands
def print_summary(
*,
repo_path: Path,
github_repo: str,
source_ref: str,
source_commit: str,
tag: str,
release_kind: str,
containing_branches: list[str],
execute: bool,
) -> None:
mode = "EXECUTE" if execute else "DRY RUN"
print(f"Mode: {mode}")
print(f"Repository path: {repo_path}")
print(f"GitHub repository: {github_repo}")
print(f"Source ref: {source_ref}")
print(f"Source commit: {source_commit}")
print(f"Release tag: {tag}")
print(f"Release kind: {release_kind}")
print("Containing remote branches:")
for branch in containing_branches:
print(f" - {branch}")
print()
def validate_containing_branches(branches: list[str], allow_uncontained: bool) -> None:
if allow_uncontained:
return
for branch in branches:
normalized = branch.strip()
if normalized == "origin/main" or normalized.startswith("origin/release/"):
return
fail("Source commit is not contained in origin/main or origin/release/*. Use --allow-uncontained only after reviewing the workflow risk.")
def remote_branches_containing(repo_path: Path, commit: str) -> list[str]:
output = git(["branch", "--remote", "--contains", commit], repo_path)
return [line.strip().lstrip("* ").strip() for line in output.splitlines() if line.strip()]
def ref_exists(repo_path: Path, ref: str) -> bool:
result = subprocess.run(["git", "rev-parse", "--quiet", "--verify", ref], cwd=repo_path, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return result.returncode == 0
def remote_tag_exists(repo_path: Path, remote: str, tag: str) -> bool:
result = subprocess.run(["git", "ls-remote", "--exit-code", "--tags", remote, f"refs/tags/{tag}"], cwd=repo_path, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return result.returncode == 0
def infer_github_repo(repo_path: Path, remote: str) -> str:
url = git(["remote", "get-url", remote], repo_path)
match = re.search(r"github\.com[:/]([^/]+)/(.+?)(?:\.git)?$", url)
if match:
return f"{match.group(1)}/{match.group(2)}"
fail(f"Could not infer GitHub repository from remote URL: {url}. Pass --github-repo owner/name.")
def ensure_tool(name: str) -> None:
if shutil.which(name) is None:
fail(f"Required tool not found on PATH: {name}")
def ensure_git_repo(path: Path) -> None:
if not path.exists():
fail(f"Repository path does not exist: {path}")
git(["rev-parse", "--git-dir"], path)
def git(args: list[str], cwd: Path, *, check: bool = True) -> str:
result = subprocess.run(["git", *args], cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if check and result.returncode != 0:
fail(result.stderr.strip() or f"git {' '.join(args)} failed")
return result.stdout.strip()
def run(args: list[str], cwd: Path | None, *, execute: bool) -> None:
if not execute:
print("$ " + shell_join(args))
return
result = subprocess.run(args, cwd=cwd)
if result.returncode != 0:
fail(f"Command failed with exit code {result.returncode}: {shell_join(args)}")
def shell_join(args: list[str]) -> str:
return " ".join(quote(arg) for arg in args)
def quote(value: str) -> str:
if re.fullmatch(r"[A-Za-z0-9_./:=@%+-]+", value):
return value
return "'" + value.replace("'", "'\"'\"'") + "'"
def fail(message: str) -> None:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Generate a categorized Markdown scaffold for Elsa release notes."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Commit:
sha: str
subject: str
@property
def short_sha(self) -> str:
return self.sha[:10]
@property
def reference(self) -> str:
pr = extract_pr_number(self.subject)
return f"(#{pr})" if pr else f"({self.short_sha})"
SECTIONS: tuple[tuple[str, str], ...] = (
("breaking", "## ⚠️ Breaking changes / upgrade notes"),
("features", "## ✨ New features"),
("improvements", "## 🔧 Improvements"),
("fixes", "## 🐛 Fixes"),
("security", "## 🔒 Security"),
("developer", "## 🧩 Developer-facing changes"),
("tests", "## 🧪 Tests"),
("ci", "## 🔁 CI / Build"),
("dependencies", "## 📦 Dependencies"),
("docs", "## Documentation"),
("maintenance", "## Maintenance"),
)
def main() -> int:
args = parse_args()
repo_path = Path(args.repo_path).expanduser().resolve()
commits = get_commits(repo_path, args.from_ref, args.to_ref)
notes = render_notes(args.version, args.from_ref, args.to_ref, commits)
if args.output:
output = Path(args.output).expanduser()
if not output.is_absolute():
output = repo_path / output
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(notes, encoding="utf-8")
print(output)
else:
print(notes)
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo-path", default=".", help="Local repository path.")
parser.add_argument("--from-ref", required=True, help="Previous tag/ref for the compare range.")
parser.add_argument("--to-ref", required=True, help="Release tag/ref for the compare range.")
parser.add_argument("--version", required=True, help="Release version for the title.")
parser.add_argument("--output", help="Optional Markdown output path.")
return parser.parse_args()
def get_commits(repo_path: Path, from_ref: str, to_ref: str) -> list[Commit]:
output = git(
[
"log",
"--reverse",
"--no-merges",
"--pretty=format:%H%x1f%s",
f"{from_ref}..{to_ref}",
],
repo_path,
)
commits: list[Commit] = []
for line in output.splitlines():
if not line.strip():
continue
sha, subject = line.split("\x1f", 1)
commits.append(Commit(sha=sha, subject=subject))
return commits
def render_notes(version: str, from_ref: str, to_ref: str, commits: list[Commit]) -> str:
categorized: dict[str, list[Commit]] = {key: [] for key, _ in SECTIONS}
for commit in commits:
categorized[categorize(commit.subject)].append(commit)
lines: list[str] = [f"Compare: `{from_ref}...{to_ref}`", "", "---", "", "## 🌟 Highlights", ""]
for commit in select_highlights(commits):
lines.append(f"- {format_subject(commit.subject)} {commit.reference}")
if not commits:
lines.append("- No commits found in the selected range.")
for key, heading in SECTIONS:
section_commits = categorized[key]
if not section_commits:
continue
lines.extend(["", "---", "", heading, ""])
for commit in section_commits:
lines.append(f"- {format_subject(commit.subject)} {commit.reference}")
lines.extend(["", "---", "", "## 📦 Full changelog (short)", ""])
for commit in commits:
lines.append(f"- {commit.subject} ({commit.short_sha})")
lines.extend(
[
"",
"<!--",
"Review before publishing:",
"- Rewrite bullets so they explain developer impact, not only commit wording.",
"- Promote the most important user-facing items into Highlights.",
"- Add migration notes for breaking changes.",
"- Remove empty or low-value sections.",
"- Verify PR numbers and do not invent missing context.",
"-->",
"",
]
)
return "\n".join(lines)
def select_highlights(commits: list[Commit]) -> list[Commit]:
priority = {"breaking": 0, "features": 1, "fixes": 2, "improvements": 3, "security": 4, "developer": 5}
ranked = sorted(
commits,
key=lambda commit: (
priority.get(categorize(commit.subject), 99),
is_noise(commit.subject),
commit.subject.lower(),
),
)
return ranked[:6]
def categorize(subject: str) -> str:
lower = subject.lower()
conventional = lower.split(":", 1)[0]
if "breaking change" in lower or re.search(r"^[a-z]+(?:\([^)]+\))?!:", lower):
return "breaking"
if "security" in lower or "cve-" in lower or "vulnerab" in lower:
return "security"
if conventional.startswith("feat") or re.search(r"^(add|added|introduce|introduced|new)\b", lower):
return "features"
if conventional.startswith("fix") or contains_any(lower, "fix ", "fixed ", "bug", "issue"):
return "fixes"
if conventional.startswith("test") or " test" in lower or lower.startswith("test"):
return "tests"
if conventional.startswith("docs") or lower.startswith("doc") or "readme" in lower:
return "docs"
if contains_any(lower, "package", "dependency", "dependencies", "nuget", "props"):
return "dependencies"
if is_ci_or_build_change(lower):
return "ci"
if contains_any(lower, "api", "contract", "extension", "attribute", "options"):
return "developer"
if contains_any(lower, "improve", "enhance", "refactor", "optimiz", "cleanup"):
return "improvements"
return "maintenance"
def contains_any(value: str, *needles: str) -> bool:
return any(needle in value for needle in needles)
def is_ci_or_build_change(value: str) -> bool:
return bool(
re.search(r"\b(ci|build|pack|versioning)\b", value)
or "github action" in value
or "github workflow" in value
or ".github/workflows" in value
)
def is_noise(subject: str) -> bool:
return categorize(subject) in {"tests", "ci", "docs", "maintenance"}
def format_subject(subject: str) -> str:
value = re.sub(r"\s+\(#\d+\)$", "", subject).strip()
value = re.sub(r"^[a-z]+(?:\([^)]+\))?!?:\s*", "", value, flags=re.IGNORECASE)
if not value:
return subject
return value[0].upper() + value[1:]
def extract_pr_number(subject: str) -> str | None:
match = re.search(r"\(#(\d+)\)\s*$", subject)
if match:
return match.group(1)
match = re.search(r"merge pull request #(\d+)", subject, flags=re.IGNORECASE)
if match:
return match.group(1)
return None
def git(args: list[str], cwd: Path) -> str:
result = subprocess.run(["git", *args], cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print(result.stderr.strip() or f"git {' '.join(args)} failed", file=sys.stderr)
raise SystemExit(result.returncode)
return result.stdout.strip()
if __name__ == "__main__":
raise SystemExit(main())