Make Elsa release skills self-contained and resumable (#8020)

This commit is contained in:
Sipke Schoorstra 2026-09-05 06:18:52 -07:00 committed by GitHub
parent 53595b95d2
commit 633af5a77c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3949 additions and 581 deletions

View file

@ -1,183 +1,22 @@
---
name: elsa-release-announcements
description: Draft, approve, and publish Elsa release announcements for Discord, LinkedIn, and X after an Elsa Core, Elsa Studio, Elsa Extensions, or similarly configured Elsa release has completed. Use when Codex needs to turn release notes, GitHub release URLs, package/feed availability, and build results into channel-specific community and social posts; supports direct Discord webhook posting and optional Buffer, Typefully, Zapier, Make, or manual publishing workflows for LinkedIn and X.
description: Draft and publish verified Elsa release announcements on Discord, LinkedIn, and X, including safe recovery and publication receipts. Use after Elsa releases or when explicitly asked to announce a release.
---
# Elsa Release Announcements
## Overview
Use after the requested releases and packages are verified. Read [publishing and recovery](references/publishing.md), and use [channel style](references/style.md) while writing the copy.
Use this skill after a release has been published and packages are available. The output is an announcement pack with Discord, LinkedIn, and X copy tailored to each channel, plus optional publishing steps.
## Authorization
Keep this skill separate from `elsa-release`: release execution verifies tags, GitHub releases, and packages; announcements communicate the finished release.
## Recommended Publishing Setup
Use a draft-and-approval workflow by default.
- Discord: post directly with a Discord incoming webhook when `DISCORD_RELEASE_WEBHOOK_URL` is configured. If the target is an Announcement Channel, publish/crosspost the webhook message with `--crosspost` and a bot token in `DISCORD_BOT_TOKEN`.
- LinkedIn + X: prefer Buffer or Typefully for queueing/scheduling when accounts are connected.
- Single orchestration flow: use Zapier or Make when the team wants one approval-triggered workflow that can post Discord plus social channels.
- Manual fallback: produce copy-ready Markdown/plain-text drafts when no publishing service is configured.
Current service fit:
- Buffer supports publishing to LinkedIn and X/Twitter and has API support for creating posts across supported channels.
- Typefully supports multi-platform publishing for X/Twitter and LinkedIn through its API.
- Discord webhooks are the simplest reliable path for posting into a Discord channel.
- No service choice should be hard-coded into the release process; credentials, approval, and account ownership vary by team.
## Inputs
Collect or infer:
- Product/repository: Elsa Core, Elsa Studio, Elsa Extensions, or another Elsa project.
- Version and release kind: stable, preview, or RC.
- GitHub release URL.
- Release notes file or GitHub release body.
- Package availability: NuGet, feedz.io, Docker, npm, or other relevant feeds.
- Important callouts: breaking changes, upgrade notes, known issues, migration docs, docs links.
- Desired publish mode: `draft-only`, `discord`, `buffer`, `typefully`, `zapier`, `make`, or `manual`.
Do not publish anything until the user explicitly approves the final text and target channels.
An end-to-end `$elsa-release` request includes the standard announcements unless the user excludes them. “Announce/publish this release” also authorizes drafting and posting factual channel-specific copy to the configured release channels. Review the concrete text and targets as part of execution; do not ask for repeated approval already covered by the request. A request to draft, assess, or plan remains draft-only. An unexpected account/channel or materially expanded claim requires resolving that choice first.
## Workflow
1. Verify release readiness.
- Confirm the release exists and is public.
- Confirm package/feed publishing completed for the release kind.
- Confirm links work: GitHub release, NuGet/feed package search, docs/changelog.
1. Verify public releases, exact versions and required package/feed results. Use the release train's manifests/reports and current GitHub state. Never announce availability based only on an upload job or an open PR.
2. Generate a scaffold with `scripts/announcement_pack.py` if useful, then curate it using the release notes. Stable copy includes useful upgrade notes; RC/preview copy explicitly asks for testing and does not imply production stability. Claim only features supported by the final release range.
3. Resolve live configured channels. Default Elsa destinations are the Discord releases Announcement Channel, Elsa Workflows on LinkedIn, and sfmskywalker on X, through the configured Buffer organization Valence Works. Read the release profile or an explicit override. Discover current channel IDs and access; never guess private connector IDs or expose credentials.
4. Persist publication intent before sending. Use the Discord helper for bot/webhook posting and `buffer_receipt.py` around connector calls. Publish now for “announce when done”; schedule only when requested. Do not silently replace a required publication with drafts or a queued post.
5. Verify actual sent content, target, public URL, and Discord crossposting. Save the receipts and record them in the release checkpoint. A known message ID is reused on retry; an uncertain create is reconciled before any further send.
2. Generate an announcement pack.
- Use `scripts/announcement_pack.py` with release notes as input when available.
- Treat script output as a scaffold; rewrite it into polished copy.
- Keep factual claims tied to the release notes and package availability.
3. Adapt by channel.
- Discord: rich community post with a strong headline, Discord emoji shortcodes, release links near the top, grouped highlights, practical upgrade notes, and a clear testing/feedback ask for previews or RCs.
- LinkedIn: polished product/developer narrative, stable release value, major improvements, and one clear link.
- X: one compact post or a short thread. Put the release link in the first post and avoid overloading a single post.
4. Ask for approval.
- Show the exact message for each channel.
- State whether posting is direct, queued/scheduled, or manual.
- Do not include secrets or webhook URLs in chat output.
5. Publish or prepare drafts.
- Discord direct: use `scripts/post_discord.py` with `DISCORD_RELEASE_WEBHOOK_URL`.
- Discord Announcement Channels: add `--crosspost` only when `DISCORD_BOT_TOKEN` is configured for a bot that can publish messages in that channel.
- Buffer/Typefully: use their API only when credentials and account/channel IDs are already configured.
- Zapier/Make: POST the approved payload to the configured webhook only when the user has provided the endpoint.
- Manual: save or present the final channel-specific drafts.
6. Verify.
- For direct posts, confirm the API call succeeded.
- For queued posts, confirm the returned queue/schedule status or draft URL when available.
- Record what was posted, where, and when.
## Announcement Shape
Use this structure for the announcement pack:
```markdown
# Elsa <version> Announcement Pack
## Facts
## Discord
## LinkedIn
## X single-post option
## X thread option
## Links
```
Channel guidance:
- Discord can be more direct, celebratory, and useful: mention the release, top changes, package availability, and links. Prefer `:rocket:`, `:point_right:`, `:sparkles:`, `:tools:`, `:test_tube:`, and similar Discord emoji shortcodes over raw Unicode emoji.
- Discord posts should suppress link previews. Use the webhook `SUPPRESS_EMBEDS` message flag and wrap links in angle brackets, for example `<https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0>`.
- Discord stable releases should say the stable version is available and ask for feedback on upgrades or regressions.
- Discord preview/RC releases should explicitly say they are intended for testing and validation before stable release.
- LinkedIn should explain the release in terms of developer value and project momentum, with fewer implementation details.
- X should be concise. Use a thread when there are more than two high-signal points.
- Stable releases may say packages are available on NuGet only after verifying that publish succeeded.
- Preview/RC announcements must clearly say preview/RC and avoid implying production stability.
## Discord Style
Use this shape for Discord drafts and adapt the details to the actual release:
```markdown
:rocket: **Elsa Workflows 3.7.0 is here!**
We've published the stable **Elsa 3.7.0** release across **Elsa Core** and **Elsa Studio**.
:point_right: Core: <https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0>
:point_right: Studio: <https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.0>
This release brings a solid set of improvements around **authentication**, **workflow diagnostics**, **Studio extensibility**, and the **modular server runtime**.
### :sparkles: Highlights
:closed_lock_with_key: **Modern authentication support in Elsa Studio**
Summarize the high-value change in one or two practical sentences.
:compass: **Improved workflow instance diagnostics**
Summarize the most user-visible diagnostics improvements.
:jigsaw: **Modular server runtime improvements**
Summarize the Core/runtime changes.
### :tools: Upgrade notes
Call out compatibility or dependency changes users should validate.
### :raised_hands: Feedback welcome
Ask users to report upgrade issues, regressions, and bugs.
```
## Helper Usage
Generate drafts:
```bash
python3 .agents/skills/elsa-release-announcements/scripts/announcement_pack.py \
--product "Elsa Core" \
--version 3.7.0 \
--release-kind stable \
--release-url https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0 \
--notes-file doc/changelogs/3.7.0.md \
--package-url 'https://www.nuget.org/packages?q=Elsa'
```
Post to Discord after approval:
```bash
DISCORD_RELEASE_WEBHOOK_URL="..." \
python3 .agents/skills/elsa-release-announcements/scripts/post_discord.py \
--message-file announcements/discord-3.7.0.md
```
Post and publish to a Discord Announcement Channel after approval:
```bash
DISCORD_RELEASE_WEBHOOK_URL="..." \
DISCORD_BOT_TOKEN="..." \
python3 .agents/skills/elsa-release-announcements/scripts/post_discord.py \
--message-file announcements/discord-3.7.0.md \
--crosspost
```
For `--crosspost`, the webhook post is sent with `wait=true` so Discord returns the created message ID. The script then calls Discord's crosspost endpoint for that message. The bot must have access to the Announcement Channel; if the bot did not create the message, grant the channel permissions needed to publish another sender's message.
## Guardrails
- Never publish without explicit user approval of the exact channel text.
- Never expose webhook URLs, API tokens, access tokens, or account IDs in final responses.
- Do not claim package availability until verified.
- Do not claim a feature is new unless release notes or commits support it.
- Do not use the same wording blindly across all channels.
- Keep LinkedIn/X posts free of internal build details unless they matter to users.
- If a platform API fails or credentials are missing, fall back to copy-ready drafts.
Keep this skill responsible for communication; the release skill owns version, source, build and package gates. No special image, blog article, extra channel, or mass mention is required for a release announcement unless requested.

View file

@ -0,0 +1,66 @@
# Publishing and recovery
Read the release profile at `../../elsa-release/references/elsa-profile.json` for the default destination names, Discord channel ID, and token environment name. Verify those against live account/channel discovery. Store run state outside the Git repositories beside the release checkpoint. Never store tokens, webhook URLs, or private author/account details in the receipts.
## Discord
Prefer the configured direct bot path when the release webhook is absent. The default environment name is `DISCORD_BOT_TOKEN_SUPPORT`; `--bot-token-env` permits an explicit alternative. A configured `DISCORD_RELEASE_WEBHOOK_URL` is also supported. Verify the release channel and bot permissions before sending. Do not reuse the blog channel just because its credentials exist.
Dry-run:
```bash
python3 <announcement-skill>/scripts/post_discord.py \
--message-file <run>/discord.md --channel-id <verified-channel-ID> \
--state-file <run>/discord-state.json --crosspost
```
Review the exact payload, then repeat with `--execute` under existing authorization. Webhook mode omits `--channel-id` and reads the webhook environment variable. Use environment variables for secrets, not CLI token flags.
The helper rejects oversize messages, suppresses embeds, disables mentions, records intent before creation and the message ID before crossposting. Repeating the command verifies and reuses the recorded message. After ambiguous bot creation it reconciles by nonce; if it cannot prove the outcome, inspect the channel before any new create. An ambiguous webhook create needs explicit reconciliation because webhook creation has no reliable deduplication key. Never delete state merely to retry.
The helper's final verified message ID/channel/crosspost result can be normalized to a release-train receipt with `id`, `url`, `text`, `status: published`, `error: null`, `crossposted: true`. Derive the guild/channel/message URL from the live channel/message result. The final bot GET must confirm content and the CROSSPOSTED flag.
## LinkedIn and X through Buffer
1. Discover callable Buffer tools, then `get_account`, `list_channels`, and `get_channel`. Use the exact returned channel IDs for the configured organization and account names. Preserve an explicit user override. Check connectivity before reaching this phase during release preflight.
2. Write the curated message to a file. Persist the intent:
```bash
python3 <announcement-skill>/scripts/buffer_receipt.py begin \
--state-file <run>/linkedin-state.json --platform linkedin \
--channel-id <discovered-ID> --message-file <run>/linkedin.txt
```
3. Only an output action of `publish` authorizes the next already-requested create attempt. Call the connector `create_post` with the exact channel/text, `mode: shareNow`, and `schedulingType: automatic`. Capture the returned ID immediately. Fetch that ID using `get_post`; save the raw tool result or its JSON payload locally.
4. Verify and normalize the connector result:
```bash
python3 <announcement-skill>/scripts/buffer_receipt.py record \
--state-file <run>/linkedin-state.json \
--response-file <run>/linkedin-get-post.json \
--receipt-file <run>/linkedin-receipt.json
```
The helper checks sent state, nonempty public URL, target channel and exact content hash. Record the receipt with `release_train.py record-announcement`. Repeat for `--platform x`.
## Interrupted or uncertain Buffer calls
`begin` on an existing pending intent returns `reconcile`, never `publish`. Inspect a known post ID with `get_post`; if the response was lost, use `list_posts` for the exact channel and creation window, paginate completely, and compare exact text/content hash. One matching sent post can be recorded. A sending/queued post must be watched using its ID. Conflicting/multiple matches require inspection, not another create.
Only after the connector proves that no post was created may the agent run `begin --absence-evidence <run>/absence.json` to allow another create attempt. The helper requires fresh, complete query evidence and rejects pagination gaps, filtered-out statuses, the wrong channel or creation window, and any matching post. Authentication failures, observation timeouts and missing local output are not proof of absence. Preserve discovered post IDs in the state with `note-id` as soon as a create returns, even before sent verification.
A connector outage does not complete a requested announcement. Keep the actual remaining action in the checkpoint and report a genuine access failure if necessary. For a user-requested draft-only task, return the files and do not begin publication state.
The absence-evidence file has this shape; `request` and `response` are the actual connector call inputs and returned payload for each page, in order. `observed_at` is the time the last response was received. Include all pages until `hasNextPage` is false; the query must cover all statuses from at or before the saved intent time through now.
```json
{
"observed_at": "<ISO-8601 timestamp with timezone>",
"pages": [{
"request": {"channelIds": ["<verified channel>"], "createdAt": {"start": "<intent time or earlier>"}},
"response": {"edges": [], "pageInfo": {"hasNextPage": false, "endCursor": null}}
}]
}
```
Do not manufacture an empty response or treat a connector error as an empty result. If the tool response shape changes, inspect it and update the adapter before publication.

View file

@ -0,0 +1,62 @@
## Announcement Shape
Use this structure for the announcement pack:
```markdown
# Elsa <version> Announcement Pack
## Facts
## Discord
## LinkedIn
## X single-post option
## X thread option
## Links
```
Channel guidance:
- Discord can be more direct, celebratory, and useful: mention the release, top changes, package availability, and links. Prefer `:rocket:`, `:point_right:`, `:sparkles:`, `:tools:`, `:test_tube:`, and similar Discord emoji shortcodes over raw Unicode emoji.
- Discord posts should suppress link previews. Use the webhook `SUPPRESS_EMBEDS` message flag and wrap links in angle brackets, for example `<https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0>`.
- Discord stable releases should say the stable version is available and ask for feedback on upgrades or regressions.
- Discord preview/RC releases should explicitly say they are intended for testing and validation before stable release.
- LinkedIn should explain the release in terms of developer value and project momentum, with fewer implementation details.
- X should be concise. Use a thread when there are more than two high-signal points.
- Stable releases may say packages are available on NuGet only after verifying that publish succeeded.
- Preview/RC announcements must clearly say preview/RC and avoid implying production stability.
## Discord Style
Use this shape for Discord drafts and adapt the details to the actual release:
```markdown
:rocket: **Elsa Workflows 3.7.0 is here!**
We've published the stable **Elsa 3.7.0** release across **Elsa Core** and **Elsa Studio**.
:point_right: Core: <https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0>
:point_right: Studio: <https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.0>
This release brings a solid set of improvements around **authentication**, **workflow diagnostics**, **Studio extensibility**, and the **modular server runtime**.
### :sparkles: Highlights
:closed_lock_with_key: **Modern authentication support in Elsa Studio**
Summarize the high-value change in one or two practical sentences.
:compass: **Improved workflow instance diagnostics**
Summarize the most user-visible diagnostics improvements.
:jigsaw: **Modular server runtime improvements**
Summarize the Core/runtime changes.
### :tools: Upgrade notes
Call out compatibility or dependency changes users should validate.
### :raised_hands: Feedback welcome
Ask users to report upgrade issues, regressions, and bugs.
```

View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Persist intent and validate Buffer connector receipts; this helper never posts."""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import sys
import tempfile
from datetime import datetime, timezone
def sha(text):
return hashlib.sha256(text.strip().encode()).hexdigest()
def read(path):
return json.loads(Path(path).read_text())
def save(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,delete=False) as file:
json.dump(value,file,indent=2)
file.write('\n'); file.flush(); os.fsync(file.fileno())
temporary=file.name
os.replace(temporary,path)
def payload(value):
if 'content' in value:
texts = [c['text'] for c in value['content'] if c.get('type') == 'text']
if len(texts) != 1:
raise ValueError('Expected one connector JSON response')
return json.loads(texts[0])
return value
def timestamp(value):
result = datetime.fromisoformat(value.replace('Z', '+00:00'))
if result.tzinfo is None:
raise ValueError('Reconciliation timestamps must include timezone')
return result
def validate_absence(state, path):
evidence = read(path)
age = (datetime.now(timezone.utc) - timestamp(evidence['observed_at'])).total_seconds()
if not 0 <= age <= 300:
raise ValueError('Reconciliation evidence must be from a fresh completed query')
pages = evidence.get('pages', [])
if not pages:
raise ValueError('Reconciliation requires actual paginated connector responses')
cursor = None
for index, page in enumerate(pages):
request = page['request']
if request.get('channelIds') != [state['channel_id']] or request.get('after') != cursor:
raise ValueError('Reconciliation query has wrong channel or a pagination gap')
if request.get('status') or request.get('tagIds') or request.get('dueAt'):
raise ValueError('Reconciliation cannot exclude post statuses, tags or schedules')
created = request['createdAt']
if created.get('end') or timestamp(created['start']) > timestamp(state['created_at']):
raise ValueError('Reconciliation query does not cover the complete creation window')
response = payload(page['response'])
for edge in response['edges']:
post = edge['node']
channel = post.get('channelId') or post.get('channel', {}).get('id')
if channel != state['channel_id'] or not isinstance(post.get('text'), str):
raise ValueError('Unexpected or incomplete post in reconciliation response')
if sha(post['text']) == state['content_sha256']:
raise ValueError('A matching post exists; record or inspect its ID instead of creating another')
info = response['pageInfo']
if info['hasNextPage']:
cursor = info['endCursor']
if not cursor or index == len(pages)-1:
raise ValueError('Reconciliation pagination is incomplete')
elif index != len(pages)-1:
raise ValueError('Unexpected extra reconciliation pages')
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def begin(args):
text = args.message_file.read_text().strip()
if not text:
raise ValueError('Empty announcement')
identity = {'platform':args.platform,'channel_id':args.channel_id,'content_sha256':sha(text)}
if args.state_file.exists():
state=read(args.state_file)
if any(state.get(k)!=v for k,v in identity.items()):
raise ValueError('Existing intent differs from channel or content; inspect before changing it')
if state['status']=='sent':
return {'action':'verify-existing','post_id':state['post_id']}
if not args.absence_evidence:
return {'action':'reconcile','post_id':state.get('post_id'),'created_at':state['created_at']}
if state.get('post_id'):
raise ValueError('A known post ID must be inspected; absence cannot clear it')
state['absence_evidence_sha256'] = validate_absence(state,args.absence_evidence)
else:
state=dict(identity,created_at=datetime.now(timezone.utc).isoformat())
state['status']='intent'
save(args.state_file,state)
return {'action':'publish','channel_id':args.channel_id,'content_sha256':sha(text)}
def record(args):
state=read(args.state_file)
post=payload(read(args.response_file))
if post.get('status')!='sent' or post.get('error') or not post.get('externalLink') or not post.get('sentAt') or not post.get('id'):
raise ValueError('Connector result is not a verified sent post with a public link')
if post.get('channelId')!=state['channel_id'] or sha(post.get('text',''))!=state['content_sha256']:
raise ValueError('Connector post differs from the intended channel or text')
if state.get('post_id') and state['post_id']!=post['id']:
raise ValueError('A different post ID is already recorded')
receipt={'id':post['id'],'url':post['externalLink'],'text':post['text'],'status':'sent','error':None,'sent_at':post['sentAt'],'platform':state['platform']}
state.update(status='sent',post_id=post['id'],url=post['externalLink'])
save(args.state_file,state)
save(args.receipt_file,receipt)
return receipt
def note_id(args):
state=read(args.state_file)
if not args.post_id or (state.get('post_id') and state['post_id']!=args.post_id):
raise ValueError('Conflicting or empty post ID')
state['post_id']=args.post_id
save(args.state_file,state)
return {'action':'verify-existing','post_id':args.post_id}
def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
sub=parser.add_subparsers(dest='action',required=True)
p=sub.add_parser('begin')
p.add_argument('--platform',choices=['linkedin','x'],required=True)
p.add_argument('--channel-id',required=True)
p.add_argument('--message-file',type=Path,required=True)
p.add_argument('--absence-evidence',type=Path,help='Fresh complete connector list_posts query evidence proving no matching post exists')
p=sub.add_parser('record')
p.add_argument('--response-file',type=Path,required=True)
p.add_argument('--receipt-file',type=Path,required=True)
p=sub.add_parser('note-id')
p.add_argument('--post-id',required=True)
for p in sub.choices.values():
p.add_argument('--state-file',type=Path,required=True)
args=parser.parse_args(argv)
try:
args.state_file=args.state_file.expanduser().resolve()
args.state_file.parent.mkdir(parents=True,exist_ok=True)
with args.state_file.with_suffix('.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
print(json.dumps(globals()[args.action.replace('-','_')](args),indent=2))
return 0
except (ValueError,KeyError,OSError) as error:
print(f'error: {error}',file=sys.stderr)
return 1
if __name__=='__main__':
raise SystemExit(main())

View file

@ -1,13 +1,16 @@
#!/usr/bin/env python3
"""Post an approved release announcement to Discord via webhook."""
"""Post an approved Discord announcement with safe, resumable delivery."""
from __future__ import annotations
import argparse
import errno
# The release workflow currently targets macOS/Linux; fcntl gives crash-safe advisory locks there.
import fcntl
import hashlib
import json
import os
import shutil
import subprocess
import socket
import sys
import tempfile
import urllib.error
@ -17,151 +20,491 @@ from pathlib import Path
from typing import Any
def main() -> int:
args = parse_args()
webhook_url = args.webhook_url or os.getenv("DISCORD_RELEASE_WEBHOOK_URL")
if not webhook_url:
print("error: provide --webhook-url or DISCORD_RELEASE_WEBHOOK_URL", file=sys.stderr)
API_BASE_URL = "https://discord.com/api/v10"
STATE_VERSION = 1
SUPPRESS_EMBEDS = 4
CROSSPOSTED = 1
MAX_CONTENT_LENGTH = 2000
class AnnouncementError(Exception):
"""An expected, actionable announcement failure."""
class AmbiguousRequest(AnnouncementError):
"""The server may have accepted a request before the client lost its response."""
class FileLock:
def __init__(self, path: Path):
self.path = path
self._file: Any | None = None
def __enter__(self) -> "FileLock":
try:
self._file = self.path.open("a+")
fcntl.flock(self._file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if self._file is not None:
self._file.close()
self._file = None
if exc.errno in {errno.EACCES, errno.EAGAIN}:
raise AnnouncementError(
f"state lock is held: {self.path}; inspect the other publisher before retrying"
) from exc
raise AnnouncementError(f"could not acquire state lock {self.path}: {exc}") from exc
return self
def __exit__(self, *_: object) -> None:
if self._file is not None:
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
self._file.close()
self._file = None
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
return run(args)
except AnnouncementError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
message = Path(args.message_file).expanduser().read_text(encoding="utf-8").strip()
if not message:
print("error: message file is empty", file=sys.stderr)
return 1
bot_token = args.bot_token or os.getenv("DISCORD_BOT_TOKEN")
if args.crosspost and not bot_token:
print("error: provide --bot-token or DISCORD_BOT_TOKEN when using --crosspost", file=sys.stderr)
return 1
def run(args: argparse.Namespace) -> int:
content = read_message(args.message_file)
mode, webhook_url = resolve_mode(args)
target = resolve_target(args, mode, webhook_url)
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
operation_nonce = operation_nonce_for(mode, target, args.crosspost, content_hash)
payload = {
"content": content,
"allowed_mentions": {"parse": []},
"flags": SUPPRESS_EMBEDS,
"nonce": operation_nonce,
"enforce_nonce": True,
}
payload = json.dumps({"content": message[:2000], "flags": 4}).encode("utf-8")
status, body = post_payload(webhook_url, payload, wait=args.crosspost)
if status not in (200, 204):
print(f"error: Discord returned HTTP {status}", file=sys.stderr)
return 1
if not args.execute:
print(
json.dumps(
{
"dryRun": True,
"mode": mode,
"target": target,
"operationNonce": operation_nonce,
"crosspost": args.crosspost,
"payload": payload,
},
indent=2,
sort_keys=True,
)
)
return 0
if args.crosspost:
message_data = parse_json_body(body)
channel_id = message_data.get("channel_id")
message_id = message_data.get("id")
if not channel_id or not message_id:
print("error: Discord did not return a message id for crossposting", file=sys.stderr)
return 1
if not args.state_file:
raise AnnouncementError("--state-file is required with --execute")
crosspost_status, crosspost_body = crosspost_message(channel_id, message_id, bot_token or "")
if crosspost_status not in (200, 204):
print(f"error: Discord crosspost returned HTTP {crosspost_status}", file=sys.stderr)
if crosspost_body:
print(crosspost_body, file=sys.stderr)
return 1
token = resolve_token(args, mode)
state_path = Path(args.state_file).expanduser().resolve()
state_path.parent.mkdir(parents=True, exist_ok=True)
with FileLock(lock_path(state_path)):
state_was_new = not state_path.exists()
state = load_or_initialize_state(
state_path,
mode=mode,
target=target,
crosspost=args.crosspost,
content=content,
content_hash=content_hash,
operation_nonce=operation_nonce,
)
message_id = state.get("messageId")
if not state_was_new and state["status"] in {"intent", "ambiguous"}:
state, message_id = reconcile_ambiguous_create(
state_path,
state,
mode=mode,
target=target,
token=token,
)
if args.crosspost:
print("Posted and published Discord announcement.")
else:
print("Posted Discord announcement.")
if not message_id:
state["status"] = "intent"
state.pop("lastError", None)
atomic_write_json(state_path, state)
try:
response = create_message(
mode=mode,
target=target,
webhook_url=webhook_url,
token=token,
payload=payload,
)
except AmbiguousRequest as exc:
state["status"] = "ambiguous"
state["lastError"] = str(exc)
atomic_write_json(state_path, state)
raise
except AnnouncementError as exc:
state["status"] = "failed"
state["lastError"] = str(exc)
atomic_write_json(state_path, state)
raise
message_id = require_message_id(response)
state["messageId"] = message_id
state["channelId"] = response.get("channel_id") or target.get("channelId")
if not state["channelId"]:
state["status"] = "ambiguous"
state["lastError"] = "Discord did not return a channel id; the create may have completed"
atomic_write_json(state_path, state)
raise AmbiguousRequest("Discord did not return a channel id; the create may have completed")
state["status"] = "created"
state.pop("lastError", None)
# This write must happen before any crosspost request.
atomic_write_json(state_path, state)
initial_message = verify_message(
mode=mode,
target=target,
webhook_url=webhook_url,
token=token,
message_id=str(message_id),
state=state,
content=content,
require_crosspost=False,
)
if args.crosspost and not state.get("crossposted", False):
if int(initial_message.get("flags", 0)) & CROSSPOSTED:
state["crossposted"] = True
state["status"] = "crossposted"
state.pop("lastError", None)
atomic_write_json(state_path, state)
else:
try:
crosspost_message(str(state["channelId"]), str(message_id), token)
except AnnouncementError as exc:
state["status"] = "created"
state["lastError"] = str(exc)
atomic_write_json(state_path, state)
raise
state["crossposted"] = True
state["status"] = "crossposted"
state.pop("lastError", None)
atomic_write_json(state_path, state)
verify_message(
mode=mode,
target=target,
webhook_url=webhook_url,
token=token,
message_id=str(message_id),
state=state,
content=content,
require_crosspost=args.crosspost,
)
state["status"] = "verified"
state.pop("lastError", None)
atomic_write_json(state_path, state)
print(
json.dumps(
{
"status": "verified",
"mode": mode,
"messageId": str(message_id),
"operationNonce": operation_nonce,
"crossposted": bool(args.crosspost),
},
sort_keys=True,
)
)
return 0
def post_payload(webhook_url: str, payload: bytes, *, wait: bool) -> tuple[int, str]:
url = append_query(webhook_url, {"wait": "true"}) if wait else webhook_url
if shutil.which("curl"):
return post_with_curl(url, payload)
request = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"User-Agent": "ElsaReleaseAnnouncements/1.0",
},
method="POST",
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--message-file", required=True, help="Approved Discord message file.")
parser.add_argument("--webhook-url", help="Discord webhook URL. Prefer DISCORD_RELEASE_WEBHOOK_URL.")
parser.add_argument("--channel-id", help="Discord channel ID for direct bot mode.")
parser.add_argument(
"--bot-token-env",
default="DISCORD_BOT_TOKEN_SUPPORT",
help="Environment variable containing the bot token (default: DISCORD_BOT_TOKEN_SUPPORT).",
)
parser.add_argument("--bot-token", help="Bot token override; never written to state.")
parser.add_argument("--crosspost", action="store_true", help="Publish the message from an Announcement Channel.")
parser.add_argument("--state-file", help="JSON checkpoint file; required with --execute.")
parser.add_argument("--execute", action="store_true", help="Post the approved message.")
return parser.parse_args(argv)
def read_message(path_value: str) -> str:
try:
content = Path(path_value).expanduser().read_text(encoding="utf-8").strip()
except OSError as exc:
raise AnnouncementError(f"could not read message file: {exc}") from exc
if not content:
raise AnnouncementError("message file is empty")
if len(content) > MAX_CONTENT_LENGTH:
raise AnnouncementError(f"Discord content is {len(content)} characters; the maximum is {MAX_CONTENT_LENGTH}")
return content
def resolve_mode(args: argparse.Namespace) -> tuple[str, str | None]:
if args.channel_id and args.webhook_url:
raise AnnouncementError("choose either --channel-id or --webhook-url, not both")
if args.channel_id:
return "bot", None
webhook_url = args.webhook_url or os.getenv("DISCORD_RELEASE_WEBHOOK_URL")
if webhook_url:
return "webhook", webhook_url
raise AnnouncementError("provide --channel-id or --webhook-url/DISCORD_RELEASE_WEBHOOK_URL")
def resolve_target(args: argparse.Namespace, mode: str, webhook_url: str | None) -> dict[str, str]:
if mode == "bot":
channel_id = str(args.channel_id).strip()
if not channel_id:
raise AnnouncementError("--channel-id cannot be empty")
return {"kind": "channel", "channelId": channel_id}
assert webhook_url is not None
return {
"kind": "webhook",
"fingerprint": hashlib.sha256(webhook_url.encode("utf-8")).hexdigest(),
}
def resolve_token(args: argparse.Namespace, mode: str) -> str | None:
if not args.execute:
return None
token = args.bot_token or os.getenv(args.bot_token_env)
needs_token = mode == "bot" or args.crosspost
if needs_token and not token:
raise AnnouncementError(f"provide --bot-token or set {args.bot_token_env}")
return token
def operation_nonce_for(mode: str, target: dict[str, str], crosspost: bool, content_hash: str) -> str:
canonical_target = json.dumps(target, sort_keys=True, separators=(",", ":"))
material = f"{mode}|{canonical_target}|{int(crosspost)}|{content_hash}"
return "elsa-release-" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
def load_or_initialize_state(
path: Path,
*,
mode: str,
target: dict[str, str],
crosspost: bool,
content: str,
content_hash: str,
operation_nonce: str,
) -> dict[str, Any]:
if not path.exists():
state: dict[str, Any] = {
"schemaVersion": STATE_VERSION,
"status": "intent",
"mode": mode,
"target": target,
"crosspost": crosspost,
"contentSha256": content_hash,
"contentLength": len(content),
"operationNonce": operation_nonce,
"messageId": None,
"channelId": target.get("channelId"),
"crossposted": False,
}
atomic_write_json(path, state)
return state
try:
state = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise AnnouncementError(f"could not read state file {path}: {exc}") from exc
if not isinstance(state, dict):
raise AnnouncementError(f"state file {path} must contain a JSON object")
expected = {
"schemaVersion": STATE_VERSION,
"mode": mode,
"target": target,
"crosspost": crosspost,
"contentSha256": content_hash,
"operationNonce": operation_nonce,
}
for key, value in expected.items():
if state.get(key) != value:
raise AnnouncementError(f"state file {path} does not match the current payload or target ({key})")
if state.get("status") not in {"intent", "ambiguous", "failed", "created", "crossposted", "verified"}:
raise AnnouncementError(f"state file {path} has unsupported status {state.get('status')!r}")
return state
def reconcile_ambiguous_create(
path: Path,
state: dict[str, Any],
*,
mode: str,
target: dict[str, str],
token: str | None,
) -> tuple[dict[str, Any], str | None]:
if mode != "bot":
raise AnnouncementError(
f"state file {path} records an unresolved create; webhook mode cannot list messages by nonce. "
"Inspect Discord before deleting the state file; no repost was attempted."
)
assert token is not None
url = channel_messages_url(target["channelId"]) + "?limit=100"
try:
messages = request_json(url, method="GET", token=token)
except AnnouncementError as exc:
raise AnnouncementError(f"could not reconcile nonce {state['operationNonce']}: {exc}") from exc
if not isinstance(messages, list):
raise AnnouncementError("Discord returned an invalid message list while reconciling the unresolved create")
matches = [message for message in messages if str(message.get("nonce")) == state["operationNonce"]]
if len(matches) > 1:
raise AnnouncementError(
f"multiple Discord messages match nonce {state['operationNonce']}; inspect before retrying"
)
if not matches:
raise AnnouncementError(
f"state file {path} records an unresolved create and no message with nonce {state['operationNonce']} "
"was found. Inspect Discord and reconcile manually; no repost was attempted."
)
message = matches[0]
message_hash = hashlib.sha256(str(message.get("content", "")).encode("utf-8")).hexdigest()
if message_hash != state["contentSha256"] or message.get("channel_id") != target["channelId"]:
raise AnnouncementError("the message found by nonce does not match the saved target or content")
state["messageId"] = require_message_id(message)
state["channelId"] = target["channelId"]
state["status"] = "created"
state.pop("lastError", None)
atomic_write_json(path, state)
return state, str(state["messageId"])
def create_message(
*, mode: str, target: dict[str, str], webhook_url: str | None, token: str | None, payload: dict[str, Any]
) -> dict[str, Any]:
if mode == "bot":
response = request_json(
channel_messages_url(target["channelId"]), method="POST", token=token, payload=payload, ambiguous=True
)
else:
assert webhook_url is not None
response = request_json(
append_query(webhook_url, {"wait": "true"}), method="POST", token=None, payload=payload, ambiguous=True
)
if not isinstance(response, dict) or not response.get("id"):
raise AmbiguousRequest("Discord create response had no message id; the create may have completed")
return response
def crosspost_message(channel_id: str, message_id: str, token: str | None) -> dict[str, Any]:
if not token:
raise AnnouncementError("a bot token is required for crossposting")
return request_json(crosspost_url(channel_id, message_id), method="POST", token=token, payload={})
def verify_message(
*,
mode: str,
target: dict[str, str],
webhook_url: str | None,
token: str | None,
message_id: str,
state: dict[str, Any],
content: str,
require_crosspost: bool,
) -> dict[str, Any]:
if mode == "bot" or require_crosspost:
message_url = channel_message_url(str(state["channelId"]), message_id)
message = request_json(message_url, method="GET", token=token)
else:
assert webhook_url is not None
message = request_json(webhook_message_url(webhook_url, message_id), method="GET", token=None)
if not isinstance(message, dict):
raise AnnouncementError("Discord returned an invalid message while verifying the announcement")
if message.get("content") != content:
raise AnnouncementError("Discord message content does not match the approved payload")
if message.get("channel_id") != state.get("channelId"):
raise AnnouncementError("Discord message target channel does not match the saved target")
try:
flags = int(message.get("flags", 0))
except (TypeError, ValueError) as exc:
raise AnnouncementError("Discord returned invalid message flags") from exc
if not flags & SUPPRESS_EMBEDS:
raise AnnouncementError("Discord message is missing the required suppress-embeds flag")
if require_crosspost and not flags & CROSSPOSTED:
raise AnnouncementError("Discord message is not crossposted")
return message
def request_json(
url: str,
*,
method: str,
token: str | None = None,
payload: dict[str, Any] | None = None,
ambiguous: bool = False,
) -> Any:
data = None if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
headers = {"User-Agent": "ElsaReleaseAnnouncements/2.0", "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bot {token}"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.status, response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="replace")
def post_with_curl(webhook_url: str, payload: bytes) -> tuple[int, str]:
with tempfile.NamedTemporaryFile() as payload_file, tempfile.NamedTemporaryFile() as body_file:
payload_file.write(payload)
payload_file.flush()
# Feed curl its config via stdin so the webhook URL does not appear in
# command output or shell history.
curl_config = "\n".join(
[
f'url = "{webhook_url}"',
'request = "POST"',
'header = "Content-Type: application/json"',
f'data-binary = "@{payload_file.name}"',
'write-out = "%{http_code}"',
"silent",
"show-error",
f'output = "{body_file.name}"',
]
)
result = subprocess.run(
["curl", "--config", "-"],
input=curl_config,
text=True,
capture_output=True,
check=False,
)
body_file.seek(0)
body = body_file.read().decode("utf-8", errors="replace")
if result.returncode != 0:
print(result.stderr.strip() or "error: curl failed", file=sys.stderr)
return 0, ""
return int(result.stdout.strip()), body
def crosspost_message(channel_id: str, message_id: str, bot_token: str) -> tuple[int, str]:
url = f"https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}/crosspost"
if shutil.which("curl"):
with tempfile.NamedTemporaryFile() as body_file:
curl_config = "\n".join(
[
f'url = "{url}"',
'request = "POST"',
f'header = "Authorization: Bot {bot_token}"',
'write-out = "%{http_code}"',
"silent",
"show-error",
f'output = "{body_file.name}"',
]
)
result = subprocess.run(
["curl", "--config", "-"],
input=curl_config,
text=True,
capture_output=True,
check=False,
)
body_file.seek(0)
body = body_file.read().decode("utf-8", errors="replace")
if result.returncode != 0:
print(result.stderr.strip() or "error: curl failed", file=sys.stderr)
return 0, ""
return int(result.stdout.strip()), body
request = urllib.request.Request(
url,
headers={
"Authorization": f"Bot {bot_token}",
"User-Agent": "ElsaReleaseAnnouncements/1.0",
},
method="POST",
)
raw = response.read()
status = response.status
except urllib.error.HTTPError as exc:
if ambiguous and exc.code >= 500:
raise AmbiguousRequest(f"Discord returned HTTP {exc.code}; the create may have completed") from exc
raise AnnouncementError(f"Discord returned HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError, socket.timeout, ConnectionError) as exc:
if ambiguous:
raise AmbiguousRequest(f"Discord connection ended before create completion: {type(exc).__name__}") from exc
raise AnnouncementError(f"Discord request failed: {type(exc).__name__}") from exc
if not raw:
return {}
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.status, response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="replace")
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as exc:
if ambiguous:
raise AmbiguousRequest("Discord returned invalid create JSON; the create may have completed") from exc
raise AnnouncementError(f"Discord returned invalid JSON (HTTP {status})") from exc
def require_message_id(message: Any) -> str:
if not isinstance(message, dict) or not message.get("id"):
raise AnnouncementError("Discord did not return a message id")
return str(message["id"])
def api_url(path: str) -> str:
return API_BASE_URL.rstrip("/") + path
def channel_messages_url(channel_id: str) -> str:
return api_url(f"/channels/{urllib.parse.quote(channel_id, safe='')}/messages")
def channel_message_url(channel_id: str, message_id: str) -> str:
return channel_messages_url(channel_id) + f"/{urllib.parse.quote(message_id, safe='')}"
def crosspost_url(channel_id: str, message_id: str) -> str:
return channel_message_url(channel_id, message_id) + "/crosspost"
def webhook_message_url(webhook_url: str, message_id: str) -> str:
parts = urllib.parse.urlsplit(webhook_url)
path = parts.path.rstrip("/") + f"/messages/{urllib.parse.quote(message_id, safe='')}"
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", ""))
def append_query(url: str, query: dict[str, str]) -> str:
@ -173,21 +516,41 @@ def append_query(url: str, query: dict[str, str]) -> str:
)
def parse_json_body(body: str) -> dict[str, Any]:
def lock_path(state_path: Path) -> Path:
return state_path.with_name(state_path.name + ".lock")
def atomic_write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary: str | None = None
try:
parsed = json.loads(body)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--message-file", required=True, help="Approved Discord message file.")
parser.add_argument("--webhook-url", help="Discord webhook URL. Prefer DISCORD_RELEASE_WEBHOOK_URL.")
parser.add_argument("--crosspost", action="store_true", help="Publish the created message from an Announcement Channel.")
parser.add_argument("--bot-token", help="Discord bot token for crossposting. Prefer DISCORD_BOT_TOKEN.")
return parser.parse_args()
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False
) as handle:
temporary = handle.name
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
temporary = None
try:
directory_fd = os.open(path.parent, os.O_RDONLY)
except OSError:
directory_fd = None
if directory_fd is not None:
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except OSError as exc:
raise AnnouncementError(f"could not atomically write state file {path}: {exc}") from exc
finally:
if temporary:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
if __name__ == "__main__":

View file

@ -0,0 +1,62 @@
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from datetime import datetime,timezone
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
import buffer_receipt as helper
class BufferReceiptTests(unittest.TestCase):
def setUp(self):
temporary=tempfile.TemporaryDirectory();self.addCleanup(temporary.cleanup)
self.root=Path(temporary.name)
self.message=self.root/'message.txt';self.message.write_text('Elsa 3.9.0 is available')
self.args=SimpleNamespace(state_file=self.root/'state.json',platform='linkedin',channel_id='channel',message_file=self.message,absence_evidence=None)
def test_resume_requires_reconciliation_and_known_id_cannot_be_discarded(self):
self.assertEqual('publish',helper.begin(self.args)['action'])
self.assertEqual('reconcile',helper.begin(self.args)['action'])
self.args.post_id='post';helper.note_id(self.args)
self.args.absence_evidence=self.root/'absence.json'
with self.assertRaisesRegex(ValueError,'known post ID'):helper.begin(self.args)
def test_confirmed_absence_allows_retry_but_changed_target_does_not(self):
helper.begin(self.args);self.args.absence_evidence=self.root/'absence.json'
state=helper.read(self.args.state_file)
helper.save(self.args.absence_evidence,{'observed_at':datetime.now(timezone.utc).isoformat(),'pages':[{'request':{'channelIds':['channel'],'createdAt':{'start':state['created_at']}},'response':{'edges':[],'pageInfo':{'hasNextPage':False,'endCursor':None}}}]})
self.assertEqual('publish',helper.begin(self.args)['action'])
self.args.channel_id='wrong'
with self.assertRaisesRegex(ValueError,'differs'):helper.begin(self.args)
def test_incomplete_or_matching_reconciliation_cannot_authorize_repost(self):
helper.begin(self.args)
self.args.absence_evidence=self.root/'absence.json'
state=helper.read(self.args.state_file)
evidence={'observed_at':datetime.now(timezone.utc).isoformat(),'pages':[{'request':{'channelIds':['channel'],'createdAt':{'start':state['created_at']}},'response':{'edges':[],'pageInfo':{'hasNextPage':True,'endCursor':'more'}}}]}
helper.save(self.args.absence_evidence,evidence)
with self.assertRaisesRegex(ValueError,'incomplete'):
helper.begin(self.args)
response=evidence['pages'][0]['response'];response['pageInfo']['hasNextPage']=False
response['edges']=[{'node':{'id':'existing','channelId':'channel','text':self.message.read_text()}}]
helper.save(self.args.absence_evidence,evidence)
with self.assertRaisesRegex(ValueError,'matching post exists'):
helper.begin(self.args)
def test_only_exact_sent_connector_post_produces_receipt(self):
helper.begin(self.args)
self.args.response_file=self.root/'get-post.json';self.args.receipt_file=self.root/'receipt.json'
post={'id':'post','channelId':'channel','text':self.message.read_text(),'externalLink':'https://linkedin.com/post','sentAt':'2026-09-05','status':'sending'}
helper.save(self.args.response_file,post)
with self.assertRaisesRegex(ValueError,'verified sent'):helper.record(self.args)
post['status']='sent';post['channelId']='wrong';helper.save(self.args.response_file,post)
with self.assertRaisesRegex(ValueError,'differs'):helper.record(self.args)
post['channelId']='channel';helper.save(self.args.response_file,post)
receipt=helper.record(self.args)
self.assertEqual('sent',receipt['status'])
self.assertEqual('verify-existing',helper.begin(self.args)['action'])
self.assertEqual(receipt,helper.record(self.args))
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,366 @@
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import os
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "post_discord.py"
SPEC = importlib.util.spec_from_file_location("post_discord", SCRIPT)
assert SPEC and SPEC.loader
post_discord = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(post_discord)
class ServerState:
def __init__(self) -> None:
self.messages: dict[str, dict[str, object]] = {}
self.create_count = 0
self.list_count = 0
self.crosspost_count = 0
self.ambiguous_once = False
self.create_response_mode: str | None = None
self.crosspost_failures = 0
class DiscordHandler(BaseHTTPRequestHandler):
server: "DiscordServer"
def log_message(self, *_: object) -> None:
pass
def _json(self, status: int, value: object) -> None:
body = json.dumps(value).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body(self) -> dict[str, object]:
length = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(length) or b"{}")
def do_POST(self) -> None:
path = urlsplit(self.path).path
state = self.server.state
body = self._body()
if path == "/api/v10/channels/123/messages":
state.create_count += 1
message = {
"id": "message-1",
"channel_id": "123",
"content": body["content"],
"flags": body["flags"],
"nonce": body["nonce"],
}
state.messages["message-1"] = message
if state.ambiguous_once:
state.ambiguous_once = False
self._json(500, {"message": "simulated connection ambiguity"})
return
if state.create_response_mode == "invalid-json":
state.create_response_mode = None
body = b"not-json"
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if state.create_response_mode == "missing-id":
state.create_response_mode = None
self._json(200, {key: value for key, value in message.items() if key != "id"})
return
self._json(200, message)
return
if path == "/api/webhooks/123/secret-token":
state.create_count += 1
message = {
"id": "message-1",
"channel_id": "123",
"content": body["content"],
"flags": body["flags"],
"nonce": body["nonce"],
}
state.messages["message-1"] = message
self._json(200, message)
return
if path == "/api/v10/channels/123/messages/message-1/crosspost":
state.crosspost_count += 1
if state.crosspost_failures:
state.crosspost_failures -= 1
self._json(500, {"message": "simulated crosspost failure"})
return
state.messages["message-1"]["flags"] = 5
self._json(200, state.messages["message-1"])
return
self._json(404, {"message": "not found"})
def do_GET(self) -> None:
path = urlsplit(self.path).path
state = self.server.state
if path == "/api/v10/channels/123/messages":
state.list_count += 1
self._json(200, list(state.messages.values()))
return
if path == "/api/v10/channels/123/messages/message-1":
message = state.messages.get("message-1")
self._json(200 if message else 404, message or {"message": "not found"})
return
if path == "/api/webhooks/123/secret-token/messages/message-1":
message = state.messages.get("message-1")
self._json(200 if message else 404, message or {"message": "not found"})
return
self._json(404, {"message": "not found"})
class DiscordServer(ThreadingHTTPServer):
def __init__(self) -> None:
super().__init__(("127.0.0.1", 0), DiscordHandler)
self.state = ServerState()
@property
def api_base(self) -> str:
return f"http://127.0.0.1:{self.server_port}/api/v10"
@property
def webhook_url(self) -> str:
return f"http://127.0.0.1:{self.server_port}/api/webhooks/123/secret-token"
@contextlib.contextmanager
def running_server() -> DiscordServer:
server = DiscordServer()
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
class PostDiscordTests(unittest.TestCase):
def setUp(self) -> None:
self.original_api_base = post_discord.API_BASE_URL
self.original_token = os.environ.get("DISCORD_BOT_TOKEN_SUPPORT")
os.environ["DISCORD_BOT_TOKEN_SUPPORT"] = "test-token"
def tearDown(self) -> None:
post_discord.API_BASE_URL = self.original_api_base
if self.original_token is None:
os.environ.pop("DISCORD_BOT_TOKEN_SUPPORT", None)
else:
os.environ["DISCORD_BOT_TOKEN_SUPPORT"] = self.original_token
def invoke(self, *args: str) -> tuple[int, str, str]:
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
result = post_discord.main(list(args))
return result, stdout.getvalue(), stderr.getvalue()
def message_file(self, directory: str, content: str = "hello") -> str:
path = Path(directory) / "announcement.md"
path.write_text(content, encoding="utf-8")
return str(path)
def execute_args(self, message: str, state: str, channel: str = "123") -> tuple[str, ...]:
return (
"--message-file",
message,
"--channel-id",
channel,
"--state-file",
state,
"--execute",
)
def test_dry_run_is_default_and_has_safe_payload(self) -> None:
with tempfile.TemporaryDirectory() as directory:
message = self.message_file(directory, "hello @everyone")
result, output, error = self.invoke("--message-file", message, "--channel-id", "123")
self.assertEqual(result, 0, error)
data = json.loads(output)
self.assertTrue(data["dryRun"])
self.assertEqual(data["payload"]["allowed_mentions"], {"parse": []})
self.assertEqual(data["payload"]["flags"], 4)
self.assertNotIn("@everyone", data["payload"]["allowed_mentions"])
self.assertNotIn("DISCORD_BOT_TOKEN_SUPPORT", output)
def test_oversize_message_is_rejected_without_truncation(self) -> None:
with tempfile.TemporaryDirectory() as directory:
message = self.message_file(directory, "x" * 2001)
result, output, error = self.invoke("--message-file", message, "--channel-id", "123")
self.assertEqual(result, 1)
self.assertEqual(output, "")
self.assertIn("maximum is 2000", error)
def test_execute_requires_state_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
message = self.message_file(directory)
result, _, error = self.invoke(
"--message-file", message, "--channel-id", "123", "--execute"
)
self.assertEqual(result, 1)
self.assertIn("--state-file is required", error)
def test_held_state_lock_refuses_then_reacquires_after_release(self) -> None:
with running_server() as server, tempfile.TemporaryDirectory() as directory:
post_discord.API_BASE_URL = server.api_base
message = self.message_file(directory)
state = Path(directory) / "state.json"
lock = post_discord.FileLock(post_discord.lock_path(state))
with lock:
result, _, error = self.invoke(*self.execute_args(message, str(state)))
self.assertEqual(result, 1)
self.assertIn("state lock is held", error)
self.assertEqual(server.state.create_count, 0)
self.assertTrue(Path(str(state) + ".lock").exists())
result, _, error = self.invoke(*self.execute_args(message, str(state)))
self.assertEqual(result, 0, error)
self.assertEqual(server.state.create_count, 1)
def test_failed_crosspost_resumes_without_duplicate_create(self) -> None:
with running_server() as server, tempfile.TemporaryDirectory() as directory:
post_discord.API_BASE_URL = server.api_base
server.state.crosspost_failures = 1
message = self.message_file(directory)
state = str(Path(directory) / "state.json")
result, _, error = self.invoke(*self.execute_args(message, state), "--crosspost")
self.assertEqual(result, 1)
self.assertIn("HTTP 500", error)
saved = json.loads(Path(state).read_text(encoding="utf-8"))
self.assertEqual(saved["messageId"], "message-1")
self.assertEqual(saved["status"], "created")
self.assertEqual(server.state.create_count, 1)
result, output, error = self.invoke(*self.execute_args(message, state), "--crosspost")
self.assertEqual(result, 0, error)
self.assertEqual(json.loads(output)["status"], "verified")
self.assertEqual(server.state.create_count, 1)
self.assertEqual(server.state.crosspost_count, 2)
def test_ambiguous_create_reconciles_by_nonce_without_repost(self) -> None:
with running_server() as server, tempfile.TemporaryDirectory() as directory:
post_discord.API_BASE_URL = server.api_base
server.state.ambiguous_once = True
message = self.message_file(directory)
state = str(Path(directory) / "state.json")
result, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(result, 1)
self.assertIn("may have completed", error)
self.assertEqual(json.loads(Path(state).read_text(encoding="utf-8"))["status"], "ambiguous")
result, output, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(result, 0, error)
self.assertEqual(json.loads(output)["messageId"], "message-1")
self.assertEqual(server.state.create_count, 1)
self.assertGreaterEqual(server.state.list_count, 1)
def test_ambiguous_create_without_match_stops_without_repost(self) -> None:
with running_server() as server, tempfile.TemporaryDirectory() as directory:
post_discord.API_BASE_URL = server.api_base
server.state.ambiguous_once = True
message = self.message_file(directory)
state = str(Path(directory) / "state.json")
first, _, _ = self.invoke(*self.execute_args(message, state))
self.assertEqual(first, 1)
server.state.messages.clear()
second, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(second, 1)
self.assertIn("no repost was attempted", error)
self.assertEqual(server.state.create_count, 1)
def test_unusable_create_responses_reconcile_without_repost(self) -> None:
for response_mode in ("invalid-json", "missing-id"):
with (
self.subTest(response_mode=response_mode),
running_server() as server,
tempfile.TemporaryDirectory() as directory,
):
post_discord.API_BASE_URL = server.api_base
server.state.create_response_mode = response_mode
message = self.message_file(directory)
state = str(Path(directory) / "state.json")
result, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(result, 1)
self.assertIn("may have completed", error)
self.assertEqual(json.loads(Path(state).read_text(encoding="utf-8"))["status"], "ambiguous")
result, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(result, 0, error)
self.assertEqual(server.state.create_count, 1)
def test_changed_payload_or_target_is_rejected(self) -> None:
with running_server() as server, tempfile.TemporaryDirectory() as directory:
post_discord.API_BASE_URL = server.api_base
message = self.message_file(directory, "original")
state = str(Path(directory) / "state.json")
result, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(result, 0, error)
Path(message).write_text("changed", encoding="utf-8")
result, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(result, 1)
self.assertIn("does not match", error)
result, _, error = self.invoke(*self.execute_args(message, state, channel="999"))
self.assertEqual(result, 1)
self.assertIn("does not match", error)
self.assertEqual(server.state.create_count, 1)
def test_successful_rerun_does_not_create_duplicate(self) -> None:
with running_server() as server, tempfile.TemporaryDirectory() as directory:
post_discord.API_BASE_URL = server.api_base
message = self.message_file(directory)
state = str(Path(directory) / "state.json")
first, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(first, 0, error)
second, _, error = self.invoke(*self.execute_args(message, state))
self.assertEqual(second, 0, error)
self.assertEqual(server.state.create_count, 1)
def test_webhook_dry_run_does_not_expose_webhook_url(self) -> None:
with tempfile.TemporaryDirectory() as directory:
message = self.message_file(directory)
webhook = "https://discord.com/api/webhooks/123/secret-token"
result, output, error = self.invoke("--message-file", message, "--webhook-url", webhook)
self.assertEqual(result, 0, error)
self.assertEqual(json.loads(output)["mode"], "webhook")
self.assertNotIn(webhook, output)
self.assertNotIn("secret-token", output)
def test_webhook_execute_reuses_state_without_storing_secret(self) -> None:
with running_server() as server, tempfile.TemporaryDirectory() as directory:
message = self.message_file(directory)
state = Path(directory) / "state.json"
args = (
"--message-file",
message,
"--webhook-url",
server.webhook_url,
"--state-file",
str(state),
"--execute",
)
result, _, error = self.invoke(*args)
self.assertEqual(result, 0, error)
result, _, error = self.invoke(*args)
self.assertEqual(result, 0, error)
self.assertEqual(server.state.create_count, 1)
saved = state.read_text(encoding="utf-8")
self.assertNotIn("secret-token", saved)
self.assertNotIn("test-token", saved)
if __name__ == "__main__":
unittest.main()

View file

@ -1,218 +1,37 @@
---
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.
description: Release Elsa Core, Studio, and Extensions in dependency order, including validation, package verification, recovery, and community announcements. Use for requests such as "release Elsa 3.9.0 stable", a named preview or RC, or resuming an interrupted Elsa release.
---
# Elsa Release
## Overview
“Release Elsa <version>” means complete the release across Core, Studio, and Extensions, including the configured announcements. Read [the runbook](references/runbook.md) before execution. It contains the commands, repository profile, prerequisites, validation and recovery procedure. Read [release-note guidance](references/release-notes.md) when preparing notes.
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.
## Interpret the request
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.
- A plain version such as `3.9.0` means stable. `3.9.0-rc1` means RC; `3.9.0-preview.1` means preview. RC and preview are both GitHub prereleases; an unspecified “prerelease” channel means preview. Reject contradictory version/kind combinations. If a prerelease number is omitted, inspect existing tags and use the next unused number for the named series; record the exact choice before mutations.
- Default to all three repositories. Honor a named subset; verify any upstream packages it needs without publishing repositories outside the requested scope.
- Default source is the freshly fetched `origin/release/<base-version>` in each repository. Never use the caller's checkout HEAD as an accidental source. A missing or ambiguous release branch requires resolving the source before publication. Preserve explicit source choices, including promotion from a specified RC.
- Default to curated notes against the previous stable version for stable releases; for RC/preview use the previous release in that version's series, or the previous stable if this is the first. Stable promotion still requires rebuilding downstream repositories with stable dependency references.
- A specific prerequisite such as “after PR #123 merges” is part of this release plan. Check that it is merged and included in the selected source. Do not infer that every open PR is a prerequisite or merge unrelated PRs. “Wait for” does not by itself request implementation of the prerequisite.
## Inputs
## Authorization and defaults
Collect or infer:
An explicit end-to-end release instruction authorizes the defined release operations: required dependency edits, validation and integration into the intended release branches, immutable tags, GitHub publication, and the standard release announcements. Present the concrete source/version/notes and dry-run result as a progress update; do not ask again for authorization already supplied by that instruction. Never interpret a request to assess, plan, or dry-run as authorization to publish.
- 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.
Honor overrides such as “Core only”, “without announcements”, “draft announcements”, or a different source/channel. For a standalone announcements request, use the authorization rules in the announcement skill. Ask only for missing intent that materially affects the release, a genuine new scope decision, or access that prevents completion. Do not weaken checks to avoid a question.
If the user asks for "stable 3.7 from RC1", interpret that as:
## Execution invariant
```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
```
**Core release → verify its configured feeds → align Studio → validate, release and verify Studio → align Extensions → validate, release and verify Extensions → announce and verify posts.**
## Release Workflow
Use the checkpoint helper in the runbook. It reports the next phase from GitHub state and package evidence; it does not publish automatically. Codex performs the indicated step, using `release.py` for publication and connectors for social posts. Keep only one owner for mutations. Preparation and independent review can run in parallel; dependency publication cannot.
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.
## Completion and recovery
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.
- A green upload job is insufficient: verify the expected package inventory, versions, source commits, dependencies, and actual feed content. Verify Studio npm artifacts and `latest`/`next` according to release kind.
- Bind exact source commits and reviewed manifests/notes before publication. Preserve existing tags; matching releases are reusable, conflicting releases require investigation.
- Resume by inspecting live GitHub state and recorded package/post evidence. Do not recreate tags or repost after an uncertain result. Retain run and message IDs; wait on concrete jobs with bounded polling and backoff.
- Assess advisory findings during preflight. Record severity, relevant usage, and disposition. Existing warnings are not automatically blockers or automatically accepted forever. Never insert an unvalidated dependency upgrade into a release to suppress a warning; a new material unresolved risk requires a concrete scope decision.
- After package verification, invoke [Elsa Release Announcements](../elsa-release-announcements/SKILL.md). Default to publishing now on Discord, LinkedIn, and X. Draft-only is an explicit override, not completion of a request to announce.
- Complete only after every selected release and required feed is verified and the requested announcements are verified sent (and Discord crossposted). State limitations accurately. Stop any follow-up monitor when finished.

View file

@ -1,4 +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."
short_description: "Release the Elsa stack and verify publication"
default_prompt: "Use $elsa-release to release Elsa 3.9.0 stable across Core, Studio, and Extensions, then announce it."

View file

@ -0,0 +1,129 @@
{
"schema": 1,
"repositories": [
{
"name": "core",
"directory": "elsa-core",
"github": "elsa-workflows/elsa-core",
"solution": "Elsa.sln",
"workflow": "packages.yml",
"dependencies": [],
"alignment": [],
"required_jobs": [
"Build packages",
"Publish to feedz.io",
"Publish release to nuget.org"
],
"fixed_packages": {
"Elsa.SamplePackage": {
"version": "1.0.1",
"verify_published": false,
"reason": "Sample project has its own fixed version; validate its artifact but do not require republishing an immutable sample version."
}
},
"npm": []
},
{
"name": "studio",
"directory": "elsa-studio",
"github": "elsa-workflows/elsa-studio",
"solution": "Elsa.Studio.sln",
"workflow": "packages.yml",
"dependencies": [
"core"
],
"alignment": [
{
"file": "Directory.Packages.props",
"package": "Elsa.Api.Client"
}
],
"required_jobs": [
"Build packages",
"Publish npm packages to feedz.io",
"Publish npm packages to npmjs.com",
"Publish nuget packages to feedz.io",
"Publish to nuget.org"
],
"fixed_packages": {},
"npm": [
"@elsa-workflows/elsa-studio-wasm",
"@elsa-workflows/elsa-studio-wasm-react"
]
},
{
"name": "extensions",
"directory": "elsa-extensions",
"github": "elsa-workflows/elsa-extensions",
"solution": "Elsa.Extensions.sln",
"workflow": "packages.yml",
"dependencies": [
"core",
"studio"
],
"alignment": [
{
"file": "Directory.Build.props",
"property": "ElsaVersion"
},
{
"file": "Directory.Build.props",
"property": "ElsaStudioVersion"
}
],
"required_jobs": [
"Build packages",
"Publish to feedz.io",
"Publish release to nuget.org"
],
"fixed_packages": {},
"npm": []
}
],
"feeds": [
{
"name": "nuget",
"base_url": "https://api.nuget.org/v3-flatcontainer"
},
{
"name": "feedz",
"base_url": "https://f.feedz.io/elsa-workflows/elsa-3/nuget/v3/packages"
}
],
"release_kinds": {
"stable": {
"feeds": [
"nuget",
"feedz"
],
"npm_dist_tag": "latest"
},
"rc": {
"feeds": [
"nuget",
"feedz"
],
"npm_dist_tag": "next"
},
"preview": {
"feeds": [
"nuget",
"feedz"
],
"npm_dist_tag": "next"
}
},
"announcements": {
"platforms": [
"discord",
"linkedin",
"x"
],
"buffer_organization": "Valence Works",
"linkedin_channel": "Elsa Workflows",
"x_channel": "sfmskywalker",
"discord_channel_id": "1351638836119076996",
"discord_channel_name": "📣-releases",
"discord_bot_token_env": "DISCORD_BOT_TOKEN_SUPPORT"
}
}

View file

@ -0,0 +1,99 @@
## 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
```

View file

@ -0,0 +1,123 @@
# Elsa release runbook
Read this once at the start of a release. Codex operates the procedure; the user supplies a version and optional exceptions. Scripts handle repeatable checks and state, while Codex performs source review, follows repository build instructions, and uses connected social tools. No script assumes it can author release notes or call a connector available only to the agent.
## 1. Resolve the plan and preflight all repositories
Helpers require Python 3.10+ on macOS/Linux, Git, GitHub CLI, and the SDKs required by the source repositories. They use the Python standard library.
Use [elsa-profile.json](elsa-profile.json). This is the maintained default for repository names, dependency declarations, expected jobs, feeds, fixed-version exceptions, and announcement destinations. A custom `--profile` can change these for another environment; do not put credentials in it. Discover repository paths from the workspace/saved projects and verify each GitHub remote. Do not assume the user's saved checkouts are up to date.
Default release branches are `release/<base-version>`. Fetch and inspect all required repositories before publishing Core. An explicit source such as `core=3.9.0-rc1` overrides only that source. An absent branch or conflicting version needs a concrete source decision; the helper must not fall back to `HEAD` or manufacture a branch from an unrelated release line.
Check:
- GitHub authentication, repository access, branch policies, current tags/releases, workflow configuration, supported SDKs, feeds in `NuGet.Config`, and the configured announcement tools/accounts. Report missing credentials without printing them. GitHub publishing uses its existing OIDC/secrets; local NuGet publishing credentials are not required.
- Named prerequisite PRs are merged and included in the intended source. Waiting on a specified PR means monitoring that PR; implementing or merging it requires authorization for that work. No other open PR becomes a gate automatically.
- All three workflow `base_version` values match the new release line before branch CI. Version tags drive named release artifacts, but the branch preview version must also be correct. Make this routine preparation change on the intended release branch when needed.
- The workflow subscribes once to `release: types: [published]`. GitHub `published` covers both stable and prereleases. Use `release.prerelease` or parsed package versions for distinctions, never `event.action == published` as a stable-only test. [GitHub reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#release).
- The profile intentionally publishes named stable, RC, and preview GitHub releases to NuGet and Feedz. Automated branch previews are separate, Feedz-only builds. Studio named prereleases use npm `next`; stable uses `latest`. If the source workflow disagrees with this policy, resolve the mismatch before release; do not weaken verification to match missing output.
- Identify advisories and build/test prerequisites early. Assess actual usage and document disposition; a known warning from a prior release is evidence, not permanent acceptance. Keep unrelated upgrades out of the release. New material unresolved risks or failures require an explicit resolution before irreversible publication.
Use a persistent directory outside the repositories, for example `~/.codex/releases/elsa/3.9.0`. It holds source bindings, notes, artifact manifests/downloads, reports, and post receipts. Keep one release owner; the checkpoint uses an OS lock for concurrent updates. Do not run multiple publishing agents for the same version.
Initialize the plan (local state only):
```bash
python3 <skill>/scripts/release_train.py --state <run>/state.json init \
--version 3.9.0 --repos-root <parent-of-the-three-repositories>
```
Optional arguments: `--kind stable|rc|preview`, `--repositories core studio`, `--source core=3.9.0-rc1`, repeated `--pr <URL>`, `--no-announcements`, `--profile <JSON>`. For “draft announcements”, use `--no-announcements` for publication tracking and retain the explicit draft requirement in the task notes. A named subset includes its upstream repositories as verification-only dependencies; do not publish those implicitly.
Repeating `init` with identical inputs preserves progress. Conflicting inputs fail rather than overwrite an in-flight plan.
## 2. Follow the next phase
```bash
python3 <skill>/scripts/release_train.py --state <run>/state.json status
```
The helper inspects current GitHub releases, resolved tag SHAs, exact release-event workflow runs, and successful required jobs. Verified package receipts are bound to manifest/report hashes and the immutable source. Stored `running` text is never evidence of a live job. Recheck actual package feeds on a long-delayed resume or any provenance concern.
`adopt-existing` → reconstruct the binding at the returned immutable tag SHA, recover its published notes, generate the source inventory, download that release run’s artifacts and verify them; do not create another release. `prepare` → create an isolated worktree from the selected source, prepare and validate it. `publish` → run the reviewed release helper. `wait-for-run` → observe the returned run ID. `repair-pipeline` → diagnose that run. `verify-packages` → verify the downloaded artifacts. `wait-for-upstream` → complete the indicated dependency. `announcements` → follow the announcement skill. `missing-upstream-release` for a verification-only dependency requires the missing upstream release to exist, or new authorization to expand scope.
Poll live jobs/feeds at a bounded interval (typically 30–60 seconds, then back off). A timeout is not failure and must not start a replacement run. Announce meaningful changes rather than narrating identical polls. Use the environment's persistent goal or a single supported heartbeat if waiting beyond the active turn; preserve state and stop the monitor at completion.
## 3. Prepare and validate one repository
Preserve the saved checkout. Create an isolated `codex/release-<version>` worktree from the intended source. Validate Core first. Only after verified upstream publication may a downstream worktree's package references change:
```bash
python3 <skill>/scripts/release_train.py --state <run>/state.json align \
--repo studio --repo-path <studio-worktree>
# Review the declared files, then repeat with --execute.
```
The helper updates only the configured declarations and checks upstream evidence live. Studio uses `Directory.Packages.props` → `Elsa.Api.Client`. Extensions uses `Directory.Build.props` → `ElsaVersion` and `ElsaStudioVersion`. It rejects missing/duplicate declarations and dirty tracked worktrees. Repeated alignment of already-correct values is harmless. Do not update dependency caches or source npm placeholder versions as substitutes for the configured package references.
Use the source repository's current AGENTS/build/workflow instructions. As of this profile:
- Core: relevant regression coverage plus the branch workflow's unit/integration/component and package build checks; real host/API smoke tests when the prerequisite touches endpoint discovery or hosting.
- Studio: build the JavaScript assets as its workflow does; fresh solution restore, Release solution build and tests for the release version.
- Extensions: fresh solution restore, Release solution build and tests for the release version.
Use `--force --no-cache` restores for changed upstream packages and inspect `project.assets.json` for exact resolved versions and package provenance. Verify all supported target frameworks via the repository's build. Record commands, exit results and intentional service-dependent test skips against the exact commit. Resolve failures; do not replace broad required checks with a convenient small passing subset.
Review and commit only intended changes. Integrate into the intended release branch according to actual branch protection: ordinary fast-forward where permitted, otherwise PR plus required checks/review and authorized merge. The explicit full-release instruction includes routine dependency/version integration. It does not authorize bypassing branch protection, unrelated fixes, or changing existing release tags. Wait for branch CI build/test/pack success before tagging. An independent preview feed upload need not delay a stable tag once that validation passed.
## 4. Freeze notes, package inventory, and source
Generate and curate notes using [release-note guidance](release-notes.md). `release_notes.py` refuses overwriting an existing file unless explicitly requested. Remove its scaffold marker after review, retain the version metadata, and include every commit/PR in the selected range. Use the tested SHA for gathering commits and the destination version in the displayed comparison. Notes must describe the final source, including stable dependency alignment.
After validation and integration, generate the expected inventory by evaluating the solution's project properties, independently of the downloaded artifact set:
```bash
python3 <skill>/scripts/package_manifest.py --state <run>/state.json \
--repo core --repo-path <core-worktree> --output <run>/core-manifest.json
python3 <skill>/scripts/release_train.py --state <run>/state.json bind \
--repo core --repo-path <core-worktree> --commit <tested-SHA> \
--manifest <run>/core-manifest.json --notes-file <run>/core-notes.md
```
Repeat for Studio and Extensions at their proper turn. The manifest records expected NuGet IDs/versions, source SHA, feeds, upstream dependency versions, and npm IDs/dist-tags. The Core sample has its own fixed source version; its explicitly documented artifact-only exception must remain anchored to that source declaration. Do not silently discard unexpected artifacts or change the expected package count to match an incomplete download.
Binding checks the worktree, source, notes and policy. A prepublication correction can use `bind --replace` after review, fresh validation and integration; it refuses replacement once the remote tag or release exists. After tagging, repair only publication/infrastructure without changing source. A source fix needs a new release version and a concrete user decision.
## 5. Publish and verify before proceeding
Show the resolved repository, SHA, tag, kind and notes in a progress update. Run the helper dry-run, inspect it, then execute under the user's existing release authorization:
```bash
python3 <skill>/scripts/release.py --repo-path <worktree> \
--source-ref <bound-SHA> --tag <version> --release-kind <kind> \
--notes-file <bound-notes-file>
# Same arguments plus --execute after reviewing the dry-run.
```
Matching existing tags/releases are reused. A different SHA, version/kind, or draft status fails. Do not force-push or recreate a tag. Re-run `status`; retain the returned release run ID. It must be a release-event run at the exact tag and SHA, with all configured publishing jobs successful.
Download every artifact configured by that source workflow into a dedicated `<run>/<repo>-artifacts/` directory using `gh run download <run-id> --repo <owner/repo> --name <artifact-name> --dir <directory>`. Download NuGet and Studio's two npm archives in separate subdirectories of that artifact root. Verify:
```bash
python3 <skill>/scripts/release_train.py --state <run>/state.json verify \
--repo core --artifacts <run>/core-artifacts
```
`verify_packages.py` compares the explicit manifest with local artifacts and actual published feed content, handles NuGet repository signing, and checks npm integrity/dist-tags. Each attempt writes a report even when indexing is incomplete. Retry missing/not-yet-indexed packages with backoff; diagnose provenance mismatches rather than calling them propagation delays. Never accept a queued/uploaded state as package availability. Once Core verifies, prepare Studio; once Studio verifies, prepare Extensions.
## 6. Announce, recover, and finish
Invoke [Elsa Release Announcements](../../elsa-release-announcements/SKILL.md) with the verified releases and notes. Use configured Discord, LinkedIn, and X destinations; discover and verify live accounts. The release request includes these standard posts unless explicitly overridden. Draft and review factual copy as part of the task, then publish now. Retain per-channel intent/message IDs before retrying, and verify actual sent state, exact text, public URLs and Discord crossposting.
The connector's verified result can be recorded as a small JSON receipt containing `id`, `url`, `text`, `status: sent` (or `published`), `error: null`, and for Discord `crossposted: true`. Record it:
```bash
python3 <skill>/scripts/release_train.py --state <run>/state.json record-announcement \
--platform linkedin --receipt <run>/linkedin-receipt.json \
--message-file <run>/linkedin.txt
```
The checkpoint validates receipt content and hashes, but does not replace the connector's live `get_post` verification. On an interrupted social publish, reconcile the recorded intent and current channel posts before sending anything again. Verify Discord with the bot API and LinkedIn/X with their connector. Queue status is only completion when the user requested scheduling.
Finally rerun `status`, audit the exact release/version/source/workflow and feed evidence, verify all requested social results, save a concise completion record, stop any heartbeat, and report release/post URLs plus any material limitation. Complete a persistent goal only after this audit.

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Evaluate solution projects to declare expected packages before downloading CI output."""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor
import json
from pathlib import Path
import re
import sys
import xml.etree.ElementTree as ET
from release_train import command, config, entry, read, save
from release_support import parse_version
def evaluate_project(path, version, fixed_packages):
properties = json.loads(command(['dotnet', 'msbuild', str(path), '-nologo', f'-p:Version={version}', '-p:Configuration=Release', '-getProperty:IsPackable,PackageId,PackageVersion']))['Properties']
if properties['IsPackable'].lower() != 'true':
return None
package = {'id': properties['PackageId'], 'version': properties['PackageVersion']}
if not package['id']:
raise ValueError(f'Empty evaluated PackageId: {path}')
fixed = fixed_packages.get(package['id'])
if fixed:
# An exception must still be anchored to an explicit source declaration.
versions = [n.text for n in ET.parse(path).getroot().iter() if n.tag.split('}')[-1] in ('Version','PackageVersion')]
if fixed['version'] not in versions:
raise ValueError(f'Fixed version changed in {path}; review the profile exception')
package.update(fixed)
elif package['version'] != version:
raise ValueError(f"Unexpected evaluated package version {package} in {path}")
return package
def generate(state, name, path):
cfg = config(state, name)
item = entry(state, name)
version = parse_version(state['version'], state['kind'])
sha = command(['git', 'rev-parse', 'HEAD'], path)
listing = command(['dotnet', 'sln', cfg['solution'], 'list'], path)
projects = [path / line.strip().replace('\\','/') for line in listing.splitlines() if line.strip().endswith('.csproj')]
if not projects:
raise ValueError('No solution projects found')
with ThreadPoolExecutor(max_workers=4) as pool:
packages = list(pool.map(lambda p: evaluate_project(p, version.version, cfg['fixed_packages']), projects))
packages = sorted((p for p in packages if p), key=lambda p: p['id'])
if not packages or len({p['id'].lower() for p in packages}) != len(packages):
raise ValueError('Empty or duplicate expected package IDs')
if set(cfg['fixed_packages']) - {p['id'] for p in packages}:
raise ValueError('A configured fixed-version package disappeared; review the package inventory')
policy = state['profile']['release_kinds'][version.kind]
dependencies = {}
for upstream in cfg['dependencies']:
binding = entry(state, upstream).get('binding')
if not binding:
raise ValueError(f'Bind upstream {upstream} before evaluating this manifest')
dependencies.update({p['id']:p.get('version',state['version']) for p in read(binding['manifest'])['nuget']})
return {
'version': version.version, 'source_commit': sha,
'nuget': packages, 'expected_dependencies': dependencies,
'feeds': [f for f in state['profile']['feeds'] if f['name'] in policy['feeds']],
'npm': [{'name': n, 'version': version.version, 'dist_tag': policy['npm_dist_tag'], 'registry': 'https://registry.npmjs.org'} for n in cfg['npm']],
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--state', required=True, type=Path)
parser.add_argument('--repo', required=True)
parser.add_argument('--repo-path', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
try:
result = generate(read(args.state), args.repo, args.repo_path.resolve())
if args.output.exists() and read(args.output) != result:
raise ValueError('Existing manifest differs; use a new file and review the source/package changes')
save(args.output, result)
print(json.dumps({'manifest':str(args.output), 'nuget_packages':len(result['nuget']), 'npm_packages':len(result['npm'])}))
return 0
except (ValueError, OSError, KeyError) as e:
print(f'error: {e}', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
@ -11,6 +12,11 @@ import sys
from dataclasses import dataclass
from pathlib import Path
try:
from release_support import ReleaseVersion, parse_version
except ImportError: # pragma: no cover - supports importing as a package in tests
from .release_support import ReleaseVersion, parse_version
@dataclass(frozen=True)
class Command:
@ -18,33 +24,51 @@ class Command:
cwd: Path | None = None
@dataclass(frozen=True)
class ExistingRelease:
tag_name: str
target_commitish: str
is_draft: bool
is_prerelease: bool
def main() -> int:
args = parse_args()
release_version = parse_release_version(args.tag, args.release_kind)
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)
source_ref = args.source_ref or intended_source_ref(release_version, remote)
validate_source_override(source_ref, release_version)
notes_file = validate_notes_file(repo_path, args.notes_file, release_version.version)
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)
# All checks below are read-only. Mutating commands are built and run only
# after the source, tag, release, and containment checks succeed.
source_commit = resolve_source_commit(repo_path, source_ref, remote)
containing_branches = remote_branches_containing(repo_path, source_commit)
validate_containing_branches(containing_branches, args.allow_uncontained)
if not containing_branches and is_remote_branch_ref(source_ref, remote):
containing_branches = [source_ref]
validate_containing_branches(containing_branches, args.allow_uncontained, remote=remote)
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.")
local_tag_commit = local_tag_target(repo_path, args.tag)
remote_tag_commit = remote_tag_target(repo_path, remote, args.tag)
validate_existing_tag(args.tag, source_commit, local_tag_commit, remote_tag_commit)
repository_visible = verify_github_repo(repo_path, github_repo)
existing_release = inspect_release(repo_path, github_repo, args.tag, repository_visible=repository_visible)
if existing_release is not None:
if remote_tag_commit is None:
fail(f"GitHub release {args.tag} exists but its remote tag is missing; refusing to recreate or move the tag.")
validate_existing_release(existing_release, args.tag, source_commit, release_version, remote_tag_commit)
print(f"GitHub release {args.tag} already exists at the requested commit; the helper will reuse it.")
tag_exists_local = local_tag_commit is not None
tag_exists_remote = remote_tag_commit is not None
title = args.title or args.tag
commands = build_commands(
repo_path=repo_path,
@ -53,11 +77,13 @@ def main() -> int:
tag=args.tag,
source_commit=source_commit,
title=title,
release_kind=args.release_kind,
notes_file=args.notes_file,
release_kind=release_version.kind,
notes_file=str(notes_file) if notes_file is not None else None,
notes_start_tag=args.notes_start_tag,
tag_exists_local=tag_exists_local,
tag_exists_remote=tag_exists_remote,
release_exists=existing_release is not None,
source_ref=source_ref,
)
print_summary(
@ -66,7 +92,7 @@ def main() -> int:
source_ref=source_ref,
source_commit=source_commit,
tag=args.tag,
release_kind=args.release_kind,
release_kind=release_version.kind,
containing_branches=containing_branches,
execute=args.execute,
)
@ -86,10 +112,10 @@ 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("--remote", default="origin", help="Git remote to inspect and push tags to.")
parser.add_argument("--source-ref", help="Existing tag, branch, or commit for the release tag. Defaults to origin/release/{base}.")
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("--release-kind", choices=("stable", "rc", "preview"), help="Release kind. When omitted, infer it from the SemVer tag.")
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.")
@ -98,6 +124,54 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
def parse_release_version(tag: str, kind: str | None) -> ReleaseVersion:
try:
return parse_version(tag, kind)
except ValueError as error:
fail(str(error))
def intended_source_ref(version: ReleaseVersion, remote: str = "origin") -> str:
return f"{remote}/release/{version.base}"
def validate_source_override(source_ref: str, target: ReleaseVersion) -> None:
"""Reject an RC source for a stable tag when its base version differs."""
candidate = source_ref.removeprefix("refs/tags/").removesuffix("^{}")
candidate = candidate.rsplit("/", 1)[-1]
try:
source_version = parse_version(candidate)
except ValueError:
return
if target.kind == "stable" and source_version.kind == "rc" and source_version.base != target.base:
fail(
f"RC source {source_ref} has base {source_version.base}, but stable release {target.version} has base {target.base}."
)
def validate_notes_file(repo_path: Path, notes_file: str | None, requested_version: str | None = None) -> Path | None:
if notes_file is None:
return None
path = Path(notes_file).expanduser()
if not path.is_absolute():
path = repo_path / path
if not path.is_file():
fail(f"Release notes file does not exist: {path}")
content = path.read_text(encoding="utf-8")
if not content.strip():
fail(f"Release notes file is empty: {path}")
version_metadata = re.search(r"<!--\s*elsa-release-version:\s*([^\s]+)\s*-->", content)
if version_metadata is not None and requested_version is not None and version_metadata.group(1) != requested_version:
fail(
f"Release notes version metadata is {version_metadata.group(1)}, not requested version {requested_version}: {path}"
)
if "Review before publishing:" in content or "Rewrite bullets so they explain developer impact" in content:
fail(f"Release notes still contain the generated scaffold review marker: {path}")
return path
def build_commands(
*,
repo_path: Path,
@ -111,40 +185,52 @@ def build_commands(
notes_start_tag: str | None,
tag_exists_local: bool,
tag_exists_remote: bool,
release_exists: bool = False,
source_ref: str | None = None,
) -> 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))
# A remote-only source may not have a local object. Fetching is planned
# only after all validation has passed and is skipped for release reuse.
if source_ref and not ref_exists(repo_path, f"{source_ref}^{{commit}}") and not release_exists:
fetch_remote = source_ref.split("/", 1)[0] if "/" in source_ref else remote
if fetch_remote == remote and is_remote_branch_ref(source_ref, remote):
commands.append(Command(["git", "fetch", "--tags", "--prune", remote], repo_path))
if not tag_exists_remote:
commands.append(Command(["git", "push", remote, f"refs/tags/{tag}"], repo_path))
if not release_exists:
if not tag_exists_local:
commands.append(Command(["git", "tag", "-a", tag, source_commit, "-m", f"Release {tag}"], repo_path))
release = [
"gh",
"release",
"create",
tag,
"--repo",
github_repo,
"--verify-tag",
"--title",
title,
]
if not tag_exists_remote:
commands.append(Command(["git", "push", remote, f"refs/tags/{tag}"], repo_path))
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])
release = [
"gh",
"release",
"create",
tag,
"--repo",
github_repo,
"--verify-tag",
"--target",
source_commit,
"--title",
title,
]
if release_kind == "preview":
release.extend(["--prerelease", "--latest=false"])
else:
release.append("--latest")
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])
commands.append(Command(release, repo_path))
if release_kind in {"preview", "rc"}:
release.extend(["--prerelease", "--latest=false"])
else:
release.append("--latest")
commands.append(Command(release, repo_path))
return commands
@ -173,21 +259,201 @@ def print_summary(
print()
def validate_containing_branches(branches: list[str], allow_uncontained: bool) -> None:
def validate_containing_branches(branches: list[str], allow_uncontained: bool, *, remote: str = "origin") -> None:
if allow_uncontained:
return
accepted_remotes = {"origin", remote}
for branch in branches:
normalized = branch.strip()
if normalized == "origin/main" or normalized.startswith("origin/release/"):
prefix, separator, suffix = normalized.partition("/")
if separator and prefix in accepted_remotes and (suffix == "main" or suffix.startswith("release/")):
return
fail("Source commit is not contained in origin/main or origin/release/*. Use --allow-uncontained only after reviewing the workflow risk.")
fail(
f"Source commit is not contained in origin/main, origin/release/*, {remote}/main, or {remote}/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()]
result = subprocess.run(
["git", "branch", "--remote", "--contains", commit],
cwd=repo_path,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
if re.search(r"malformed object|not a valid object|unknown revision|bad object", result.stderr, re.IGNORECASE):
return []
fail(result.stderr.strip() or f"git branch --remote --contains {commit} failed")
return [line.strip().lstrip("* ").strip() for line in result.stdout.splitlines() if line.strip()]
def resolve_source_commit(repo_path: Path, source_ref: str, remote: str) -> str:
if is_remote_branch_ref(source_ref, remote):
branch = source_ref[len(remote) + 1 :]
output = git_remote(repo_path, remote, [f"refs/heads/{branch}"])
remote_commit = None
for line in output.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}":
remote_commit = parts[0]
break
if remote_commit is None:
fail(f"Source ref does not exist on remote {remote}: {source_ref}")
local = git(["rev-parse", "--verify", f"{source_ref}^{{commit}}"], repo_path, check=False)
if local and local != remote_commit:
fail(
f"Local source ref {source_ref} points to stale commit {local}; remote points to {remote_commit}. "
"Refresh the remote-tracking ref before releasing."
)
return remote_commit
local = git(["rev-parse", "--verify", f"{source_ref}^{{commit}}"], repo_path, check=False)
if local:
return local
fail(f"Source ref does not resolve to a commit: {source_ref}")
def is_remote_branch_ref(source_ref: str, remote: str) -> bool:
return source_ref.startswith(f"{remote}/") and not source_ref.startswith(f"{remote}/tags/")
def local_tag_target(repo_path: Path, tag: str) -> str | None:
value = git(["rev-list", "-n", "1", f"refs/tags/{tag}^{{commit}}"], repo_path, check=False)
return value or None
def remote_tag_target(repo_path: Path, remote: str, tag: str) -> str | None:
output = git_remote(repo_path, remote, [f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"])
if not output:
return None
peeled = None
direct = None
for line in output.splitlines():
parts = line.split()
if len(parts) != 2:
continue
if parts[1] == f"refs/tags/{tag}^{{}}":
peeled = parts[0]
elif parts[1] == f"refs/tags/{tag}":
direct = parts[0]
return peeled or direct
def validate_existing_tag(tag: str, source_commit: str, local_commit: str | None, remote_commit: str | None) -> None:
for location, existing_commit in (("local", local_commit), ("remote", remote_commit)):
if existing_commit is not None and existing_commit != source_commit:
fail(f"Tag {tag} already exists {location} and points to {existing_commit}, not {source_commit}.")
if local_commit is not None and remote_commit is not None and local_commit != remote_commit:
fail(f"Tag {tag} differs between the local repository ({local_commit}) and remote ({remote_commit}).")
def verify_github_repo(repo_path: Path, github_repo: str) -> bool:
result = subprocess.run(
["gh", "repo", "view", github_repo, "--json", "nameWithOwner"],
cwd=repo_path,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
fail(result.stderr.strip() or result.stdout.strip() or f"Could not access GitHub repository {github_repo}.")
try:
payload = json.loads(result.stdout)
actual = str(payload["nameWithOwner"])
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
fail(f"GitHub returned invalid repository metadata for {github_repo}: {error}")
if actual.lower() != github_repo.lower():
fail(f"GitHub repository lookup returned {actual}, not requested repository {github_repo}.")
return True
def inspect_release(
repo_path: Path,
github_repo: str,
tag: str,
*,
repository_visible: bool = False,
) -> ExistingRelease | None:
result = subprocess.run(
["gh", "release", "view", tag, "--repo", github_repo, "--json", "tagName,targetCommitish,isDraft,isPrerelease"],
cwd=repo_path,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
if is_github_not_found(result.stdout, result.stderr, repository_visible=repository_visible):
return None
fail(result.stderr.strip() or result.stdout.strip() or "Could not inspect the GitHub release.")
try:
payload = json.loads(result.stdout)
return ExistingRelease(
tag_name=str(payload["tagName"]),
target_commitish=str(payload["targetCommitish"]),
is_draft=bool(payload["isDraft"]),
is_prerelease=bool(payload["isPrerelease"]),
)
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
fail(f"GitHub returned invalid release metadata for {tag}: {error}")
def is_github_not_found(stdout: str, stderr: str, *, repository_visible: bool = False) -> bool:
text = f"{stdout}\n{stderr}".lower()
if any(marker in text for marker in ("401", "403", "unauthorized", "forbidden", "rate limit", "timed out", "could not resolve")):
return False
if re.search(r"\bhttp(?: error)?\s*404\b|\bstatus(?: code)?\s*[:=]?\s*404\b|\b404\s+not found\b", text):
return True
return repository_visible and "release not found" in text
def validate_existing_release(
release: ExistingRelease,
tag: str,
source_commit: str,
expected: ReleaseVersion,
remote_tag_commit: str | None = None,
) -> None:
if release.tag_name != tag:
fail(f"GitHub returned release {release.tag_name} while looking up {tag}.")
if remote_tag_commit is not None and remote_tag_commit != source_commit:
fail(
f"Remote tag {tag} resolves to {remote_tag_commit}, not the requested commit {source_commit}."
)
if _looks_like_commit(release.target_commitish) and not source_commit.lower().startswith(release.target_commitish.lower()):
fail(
f"GitHub release {tag} targets {release.target_commitish}, not the requested commit {source_commit}."
)
if release.is_prerelease != expected.prerelease:
actual_kind = "preview/rc" if release.is_prerelease else "stable"
fail(f"GitHub release {tag} has kind {actual_kind}, not {expected.kind}.")
if release.is_draft:
fail(f"GitHub release {tag} is a draft; refusing to reuse or edit it.")
def _looks_like_commit(value: str) -> bool:
return bool(re.fullmatch(r"[0-9a-fA-F]{7,40}", value))
def git_remote(repo_path: Path, remote: str, refs: list[str]) -> str:
command = ["git", "ls-remote"]
if all(ref.startswith("refs/tags/") for ref in refs):
command.append("--tags")
command.extend([remote, *refs])
result = subprocess.run(command, cwd=repo_path, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
fail(result.stderr.strip() or f"Could not inspect remote {remote}.")
return result.stdout.strip()
def remote_tag_exists(repo_path: Path, remote: str, tag: str) -> bool:
return remote_tag_target(repo_path, remote, tag) is not None
def ref_exists(repo_path: Path, ref: str) -> bool:
@ -195,11 +461,6 @@ def ref_exists(repo_path: Path, ref: str) -> bool:
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)

View file

@ -10,6 +10,8 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from release_support import parse_version
@dataclass(frozen=True)
class Commit:
@ -43,6 +45,7 @@ SECTIONS: tuple[tuple[str, str], ...] = (
def main() -> int:
args = parse_args()
parse_version(args.version)
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)
@ -51,6 +54,9 @@ def main() -> int:
output = Path(args.output).expanduser()
if not output.is_absolute():
output = repo_path / output
if output.exists() and not args.overwrite:
print("error: notes already exist; use --overwrite only to intentionally replace them", file=sys.stderr)
return 1
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(notes, encoding="utf-8")
print(output)
@ -67,6 +73,7 @@ def parse_args() -> argparse.Namespace:
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.")
parser.add_argument("--overwrite", action="store_true", help="Intentionally replace an existing notes file")
return parser.parse_args()
@ -95,7 +102,7 @@ def render_notes(version: str, from_ref: str, to_ref: str, commits: list[Commit]
for commit in commits:
categorized[categorize(commit.subject)].append(commit)
lines: list[str] = [f"Compare: `{from_ref}...{to_ref}`", "", "---", "", "## 🌟 Highlights", ""]
lines: list[str] = [f"Compare: `{from_ref}...{to_ref}`", f"<!-- elsa-release-version: {version} -->", "", "---", "", "## 🌟 Highlights", ""]
for commit in select_highlights(commits):
lines.append(f"- {format_subject(commit.subject)} {commit.reference}")

View file

@ -0,0 +1,84 @@
"""Shared version parsing and release-kind support for the Elsa release helper."""
from __future__ import annotations
import re
from dataclasses import dataclass
_SEMVER = re.compile(
r"^(?P<major>0|[1-9][0-9]*)\."
r"(?P<minor>0|[1-9][0-9]*)\."
r"(?P<patch>0|[1-9][0-9]*)"
r"(?:-(?P<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
)
_NUMERIC_IDENTIFIER = re.compile(r"0|[1-9][0-9]*$")
_RC_TOKEN = re.compile(r"rc(?:0|[1-9][0-9]*)$")
_RC_DOTTED_TOKEN = re.compile(r"rc\.(?:0|[1-9][0-9]*)$")
_PREVIEW_TOKEN = re.compile(r"preview(?:0|[1-9][0-9]*)$")
_PREVIEW_DOTTED_TOKEN = re.compile(r"preview\.(?:0|[1-9][0-9]*)$")
@dataclass(frozen=True)
class ReleaseVersion:
"""A validated release version and its Elsa release channel."""
version: str
base: str
kind: str
@property
def prerelease(self) -> bool:
return self.kind != "stable"
@property
def npm_tag(self) -> str:
return "next" if self.prerelease else "latest"
def parse_version(value: str, kind: str | None = None) -> ReleaseVersion:
"""Parse a strict SemVer release tag and infer or validate its kind."""
if not isinstance(value, str) or not value:
raise ValueError("release version must be a non-empty string")
if "+" in value:
raise ValueError(f"build metadata is not supported in release version: {value}")
match = _SEMVER.fullmatch(value)
if match is None:
raise ValueError(f"invalid SemVer release version: {value}")
requested_kind = kind.lower() if isinstance(kind, str) else None
if requested_kind is not None and requested_kind not in {"stable", "rc", "preview"}:
raise ValueError(f"invalid release kind: {kind}")
prerelease = match.group("prerelease")
base = ".".join(match.group(name) for name in ("major", "minor", "patch"))
if prerelease is None:
inferred_kind = "stable"
else:
_validate_prerelease_identifiers(prerelease)
inferred_kind = _infer_prerelease_kind(prerelease)
if requested_kind is not None and requested_kind != inferred_kind:
raise ValueError(
f"release version {value} has kind {inferred_kind}, not requested {requested_kind}"
)
return ReleaseVersion(version=value, base=base, kind=requested_kind or inferred_kind)
def _validate_prerelease_identifiers(prerelease: str) -> None:
for identifier in prerelease.split("."):
if identifier.isdigit() and _NUMERIC_IDENTIFIER.fullmatch(identifier) is None:
raise ValueError(f"invalid SemVer prerelease identifier: {prerelease}")
def _infer_prerelease_kind(prerelease: str) -> str:
if prerelease in {"rc", "preview"}:
raise ValueError("choose an explicit unused RC or preview number before release")
if _RC_TOKEN.fullmatch(prerelease) or _RC_DOTTED_TOKEN.fullmatch(prerelease):
return "rc"
if _PREVIEW_TOKEN.fullmatch(prerelease) or _PREVIEW_DOTTED_TOKEN.fullmatch(prerelease):
return "preview"
return "preview"

View file

@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Checkpoint and verify an agent-operated Elsa release; never publish implicitly."""
from __future__ import annotations
import argparse
import contextlib
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from release_support import parse_version
HERE = Path(__file__).resolve().parent
DEFAULT_PROFILE = HERE.parent / 'references' / 'elsa-profile.json'
def command(args, cwd=None):
result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, timeout=120)
if result.returncode:
raise ValueError(result.stderr.strip() or f'Command failed: {args[0]}')
return result.stdout.strip()
def gh(*args):
return json.loads(command(['gh', *args]))
def read(path):
return json.loads(Path(path).read_text())
def digest(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def save(path, value):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode='w', dir=path.parent, delete=False) as f:
json.dump(value, f, indent=2, ensure_ascii=False)
f.write('\n')
f.flush()
os.fsync(f.fileno())
temporary = f.name
os.replace(temporary, path)
@contextlib.contextmanager
def locked(path):
path.parent.mkdir(parents=True, exist_ok=True)
with path.with_suffix('.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ValueError('Another process is updating this release state')
yield
def config(state, name):
return next(r for r in state['profile']['repositories'] if r['name'] == name)
def entry(state, name):
if name not in state['repositories']:
raise ValueError(f'{name} is outside this release scope')
return state['repositories'][name]
def init(args):
version = parse_version(args.version, args.kind)
profile = read(args.profile)
names = [r['name'] for r in profile['repositories']]
selected = args.repositories or names
sources = dict(x.split('=', 1) for x in (args.source or []))
if not set(sources) <= set(selected):
raise ValueError('Source overrides must name a selected repository')
for source in sources.values():
try:
source_version = parse_version(source.removesuffix('^{}').rsplit('/', 1)[-1])
except ValueError:
if re.match(r'^\d+\.\d+\.\d+(?:-|$)', source.rsplit('/', 1)[-1]):
raise ValueError('Versioned source override is invalid or lacks a release number')
continue
if source_version.base != version.base:
raise ValueError('Versioned source override belongs to a different release line')
if not set(selected) <= set(names):
raise ValueError('Unknown repository selection')
needed = set(selected)
for r in reversed(profile['repositories']):
if r['name'] in needed:
needed.update(r['dependencies'])
state = {
'schema': 1, 'version': version.version, 'kind': version.kind,
'profile': profile, 'prerequisites': args.pr or [],
'announce': not args.no_announcements, 'announcements': {},
'repositories': {r['name']: {
'path': str((Path(args.repos_root).expanduser().resolve() / r['directory'])),
'publish': r['name'] in selected,
'source_ref': sources.get(r['name'], f'origin/release/{version.base}'),
} for r in profile['repositories'] if r['name'] in needed},
}
if args.state.exists():
existing = read(args.state)
# Never erase a partially completed train by repeating init.
for field in ['version', 'kind', 'profile', 'prerequisites', 'announce']:
if existing[field] != state[field]:
raise ValueError(f'Existing state has a different {field}; use its recorded inputs')
if {k: (v['publish'], v['path'], v['source_ref']) for k,v in existing['repositories'].items()} != {k: (v['publish'], v['path'], v['source_ref']) for k,v in state['repositories'].items()}:
raise ValueError('Existing state has different repository scope or paths')
return existing
save(args.state, state)
return state
def check_prerequisites(state):
for url in state['prerequisites']:
pr = gh('pr', 'view', url, '--json', 'state,mergeCommit,url')
if pr['state'] != 'MERGED':
raise ValueError(f'Prerequisite is not merged: {url}')
# Inclusion in the selected source is verified when binding that repository.
def published_release(state, name):
cfg = config(state, name)
releases = gh('api', '--paginate', '--slurp', f"repos/{cfg['github']}/releases?per_page=100")
release = next((r for page in releases for r in page if r['tag_name'] == state['version']), None)
if release is None:
return None
version = parse_version(state['version'], state['kind'])
if release['draft'] or release['prerelease'] != version.prerelease:
raise ValueError(f'{name}: existing release kind/draft mismatch')
obj = gh('api', f"repos/{cfg['github']}/git/ref/tags/{state['version']}")['object']
for _ in range(4):
if obj['type'] == 'commit':
return dict(release, commit=obj['sha'])
if obj['type'] != 'tag':
break
obj = gh('api', f"repos/{cfg['github']}/git/tags/{obj['sha']}")['object']
raise ValueError(f'{name}: tag does not resolve to a commit')
def inspect_release(state, name):
item = entry(state, name)
cfg = config(state, name)
release = published_release(state, name)
if 'binding' not in item:
if release:
return {'phase':'adopt-existing','commit':release['commit'],'release':release['html_url']}
return {'phase': 'prepare', 'publish': item['publish']}
sha = item['binding']['commit']
if release is None:
return {'phase': 'publish' if item['publish'] else 'missing-upstream-release', 'commit': sha}
if release['commit'] != sha:
raise ValueError(f'{name}: published tag points to a different commit')
runs = gh('api', '--paginate', '--slurp', f"repos/{cfg['github']}/actions/workflows/{cfg['workflow']}/runs?event=release&head_sha={sha}&per_page=100")
candidates = [r for page in runs for r in page['workflow_runs'] if r['head_sha'] == sha and r['head_branch'] == state['version'] and r['event'] == 'release']
if not candidates:
return {'phase': 'wait-for-run', 'release': release['html_url'], 'commit': sha}
run = max(candidates, key=lambda r: (r['run_number'], r.get('run_attempt', 1)))
result = {'phase': 'wait-for-run', 'run_id': run['id'], 'run_url': run['html_url'], 'commit': sha, 'release': release['html_url']}
if run['status'] != 'completed':
return result
if run['conclusion'] != 'success':
return dict(result, phase='repair-pipeline', conclusion=run['conclusion'])
jobs = gh('api', '--paginate', '--slurp', f"repos/{cfg['github']}/actions/runs/{run['id']}/jobs?filter=latest&per_page=100")
complete = {j['name'] for page in jobs for j in page['jobs'] if j['conclusion'] == 'success'}
missing = set(cfg['required_jobs']) - complete
if missing:
raise ValueError(f'{name}: required jobs did not succeed: {sorted(missing)}')
result['phase'] = 'verified' if receipt_valid(item) else 'verify-packages'
return result
def receipt_valid(item):
receipt = item.get('verification')
binding = item.get('binding')
if not receipt or not binding or receipt['commit'] != binding['commit']:
return False
try:
if any(digest(binding[k]) != binding[k + '_sha256'] for k in ('manifest', 'notes')):
return False
report = read(receipt['report'])
return digest(receipt['report']) == receipt['report_sha256'] and report.get('verified') is True and report.get('source_commit') == binding['commit'] and report.get('version') == read(binding['manifest'])['version']
except (OSError, ValueError, KeyError):
return False
def require_upstreams(state, name):
check_prerequisites(state)
for upstream in config(state, name)['dependencies']:
observed = inspect_release(state, upstream)
if observed['phase'] != 'verified':
raise ValueError(f"{name} waits for {upstream}: {observed['phase']}")
def aligned_text(text, rule, version):
if 'property' in rule:
tag = re.escape(rule['property'])
pattern = rf'(<{tag}>)([^<]*)(</{tag}>)'
else:
package = re.escape(rule['package'])
pattern = rf'(<PackageVersion\b[^>]*\bInclude=[\"\']{package}[\"\'][^>]*\bVersion=[\"\'])([^\"\']*)([\"\'])'
value, count = re.subn(pattern, lambda m: m[1] + version + m[3], text)
if count != 1:
raise ValueError(f'Expected one dependency declaration for {rule}, found {count}; inspect changed repository structure')
return value
def align(state, args):
item = entry(state, args.repo)
if not item['publish']:
raise ValueError('Cannot edit a verification-only upstream repository')
require_upstreams(state, args.repo)
path = args.repo_path.resolve()
# Binding a release freezes the source; changing it requires an explicit new plan.
if 'binding' in item:
raise ValueError('Repository is already bound; do not edit a frozen release source')
if command(['git', 'status', '--porcelain', '--untracked-files=no'], path):
raise ValueError('Alignment requires a clean tracked worktree')
expected = config(state, args.repo)['github']
remote = command(['git', 'remote', 'get-url', 'origin'], path)
if not re.search(r'github\.com[:/]' + re.escape(expected) + r'(?:\.git)?$', remote):
raise ValueError('Worktree remote does not match repository profile')
files = {}
for rule in config(state, args.repo)['alignment']:
file = path / rule['file']
files[file] = aligned_text(files.get(file, file.read_text()), rule, state['version'])
changed = [str(p) for p,t in files.items() if p.read_text() != t]
if args.execute:
for file,text in files.items():
file.write_text(text)
return {'mode': 'execute' if args.execute else 'dry-run', 'changed_files': changed}
def validate_manifest(state, name, manifest, commit):
cfg = config(state, name)
if manifest['version'] != state['version'] or manifest['source_commit'] != commit or not manifest.get('nuget'):
raise ValueError('Manifest does not match selected version/source or has no expected packages')
policy = state['profile']['release_kinds'][state['kind']]
expected_feeds = [f for f in state['profile']['feeds'] if f['name'] in policy['feeds']]
if manifest.get('feeds') != expected_feeds:
raise ValueError('Manifest feed policy differs from release profile')
if sorted(x['name'] for x in manifest.get('npm', [])) != sorted(cfg['npm']):
raise ValueError('Manifest npm package set differs from release profile')
if any(x['dist_tag'] != policy['npm_dist_tag'] for x in manifest.get('npm', [])):
raise ValueError('Manifest npm dist-tag differs from release profile')
for package in manifest['nuget']:
if package.get('version', state['version']) != state['version'] or package.get('verify_published') is False:
exception = cfg['fixed_packages'].get(package['id'])
if not exception or any(package.get(k) != v for k,v in exception.items()):
raise ValueError('Unconfigured fixed-version/package-verification exception')
def bind(state, args):
item = entry(state, args.repo)
require_upstreams(state, args.repo)
path = args.repo_path.resolve()
sha = command(['git', 'rev-parse', args.commit + '^{commit}'], path)
if command(['git', 'rev-parse', 'HEAD'], path) != sha or command(['git', 'status', '--porcelain', '--untracked-files=no'], path):
raise ValueError('Bind a clean worktree checked out at the tested commit')
cfg = config(state, args.repo)
remote = command(['git', 'remote', 'get-url', 'origin'], path)
if not re.search(r'github\.com[:/]' + re.escape(cfg['github']) + r'(?:\.git)?$', remote):
raise ValueError('Worktree remote does not match repository profile')
existing = published_release(state, args.repo)
if existing and existing['commit'] != sha:
raise ValueError('Existing release must be adopted at its exact immutable tag commit')
if not existing and item['publish'] and command(['git', 'rev-parse', item['source_ref'] + '^{commit}'], path) != sha:
raise ValueError('Tested commit differs from the intended release source; fetch and inspect the source before binding')
for rule in cfg['alignment']:
text = (path / rule['file']).read_text()
if aligned_text(text, rule, state['version']) != text:
raise ValueError('Downstream dependency references are not aligned to this release')
for url in state['prerequisites']:
pr = gh('pr', 'view', url, '--json', 'state,mergeCommit,url')
if f"github.com/{cfg['github']}/pull/" in pr['url']:
command(['git', 'merge-base', '--is-ancestor', pr['mergeCommit']['oid'], sha], path)
manifest = read(args.manifest)
validate_manifest(state, args.repo, manifest, sha)
notes = args.notes_file.resolve()
if not notes.read_text().strip() or 'Review before publishing:' in notes.read_text():
raise ValueError('Curate the release notes before binding')
binding = {'commit': sha, 'worktree': str(path), 'manifest': str(args.manifest.resolve()), 'notes': str(notes)}
binding.update({k+'_sha256': digest(binding[k]) for k in ('manifest','notes')})
if 'binding' in item and item['binding'] != binding:
if not args.replace:
raise ValueError('Existing binding differs; use --replace only for a reviewed, unpublished source change')
if command(['git', 'ls-remote', '--tags', 'origin', f"refs/tags/{state['version']}"], path):
raise ValueError('Cannot replace a binding after the remote tag exists')
releases = gh('api', '--paginate', '--slurp', f"repos/{cfg['github']}/releases?per_page=100")
if any(r['tag_name'] == state['version'] for page in releases for r in page):
raise ValueError('Cannot replace a binding after release creation')
item.pop('verification', None)
item['binding'] = binding
return binding
def verify(state, args):
item = entry(state, args.repo)
require_upstreams(state, args.repo)
observed = inspect_release(state, args.repo)
if observed['phase'] not in ('verify-packages', 'verified'):
raise ValueError(f"Cannot verify packages yet: {observed}")
binding = item['binding']
if digest(binding['manifest']) != binding['manifest_sha256']:
raise ValueError('Manifest changed since source binding')
report = args.state.parent / f'{args.repo}-packages.json'
result = subprocess.run([sys.executable, str(HERE/'verify_packages.py'), '--manifest', binding['manifest'], '--artifacts', str(args.artifacts.resolve()), '--output', str(report)], check=False)
if result.returncode:
item.pop('verification', None)
return {'phase': 'wait-for-packages', 'report': str(report), 'exit_code': result.returncode}
data = read(report)
if not data.get('verified') or data.get('source_commit') != binding['commit'] or data.get('version') != state['version']:
raise ValueError('Verifier returned inconsistent evidence')
item['verification'] = {'commit': binding['commit'], 'report': str(report), 'report_sha256': digest(report), 'verified_at': datetime.now(timezone.utc).isoformat(), 'run_id': observed['run_id']}
return {'phase': 'verified', 'report': str(report)}
def status(state):
check_prerequisites(state)
results = {}
for name in state['repositories']:
upstreams = config(state, name)['dependencies']
if any(results[u]['phase'] != 'verified' for u in upstreams):
results[name] = {'phase': 'wait-for-upstream', 'upstreams': upstreams}
else:
results[name] = inspect_release(state, name)
ready = all(x['phase'] == 'verified' for x in results.values())
required = state['profile']['announcements']['platforms'] if state['announce'] else []
missing = []
for platform in required:
receipt = state['announcements'].get(platform)
if not receipt or not Path(receipt['receipt']).is_file() or digest(receipt['receipt']) != receipt['sha256']:
missing.append(platform)
return {'repositories': results, 'next': 'complete' if ready and not missing else 'announcements' if ready else 'repositories', 'missing_announcements': missing if ready else []}
def record_announcement(state, args):
if status(state)['next'] not in ('announcements', 'complete'):
raise ValueError('All selected repositories and upstream packages must be verified before announcements')
if not state['announce']:
raise ValueError('Announcements were explicitly disabled for this release')
receipt = read(args.receipt)
required = ['id', 'url', 'text', 'status']
if any(not receipt.get(k) for k in required) or receipt['status'] not in ('sent','published') or receipt.get('error'):
raise ValueError('Receipt must come from verified publication, not a draft or queue acknowledgment')
if receipt['text'].strip() != args.message_file.read_text().strip():
raise ValueError('Published text differs from the intended announcement')
if args.platform == 'discord' and receipt.get('crossposted') is not True:
raise ValueError('Discord announcement is not crossposted')
if state['version'] not in receipt['text']:
raise ValueError('Announcement does not identify this release version')
value = {'receipt': str(args.receipt.resolve()), 'sha256': digest(args.receipt), 'url': receipt['url'], 'id': receipt['id']}
prior = state['announcements'].get(args.platform)
if prior and prior != value:
raise ValueError('A different announcement is already recorded; inspect before changing it')
state['announcements'][args.platform] = value
return value
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--state', required=True, type=Path)
sub = parser.add_subparsers(dest='action', required=True)
p = sub.add_parser('init')
p.add_argument('--version', required=True)
p.add_argument('--kind', choices=['stable','rc','preview'])
p.add_argument('--profile', type=Path, default=DEFAULT_PROFILE)
p.add_argument('--repos-root', required=True)
p.add_argument('--repositories', nargs='+', choices=['core','studio','extensions'])
p.add_argument('--source', action='append', help='Explicit source override, e.g. core=3.9.0-rc1')
p.add_argument('--pr', action='append', help='Explicit release prerequisite PR URL; never discovers arbitrary open PRs')
p.add_argument('--no-announcements', action='store_true')
sub.add_parser('status')
p = sub.add_parser('align')
p.add_argument('--repo', required=True)
p.add_argument('--repo-path', required=True, type=Path)
p.add_argument('--execute', action='store_true')
p = sub.add_parser('bind')
p.add_argument('--repo', required=True)
p.add_argument('--repo-path', required=True, type=Path)
p.add_argument('--commit', required=True)
p.add_argument('--manifest', required=True, type=Path)
p.add_argument('--notes-file', required=True, type=Path)
p.add_argument('--replace', action='store_true', help='Replace a reviewed binding only before any remote tag/release exists')
p = sub.add_parser('verify')
p.add_argument('--repo', required=True)
p.add_argument('--artifacts', required=True, type=Path)
p = sub.add_parser('record-announcement')
p.add_argument('--platform', required=True, choices=['discord','linkedin','x'])
p.add_argument('--receipt', required=True, type=Path)
p.add_argument('--message-file', required=True, type=Path)
args = parser.parse_args()
args.state = args.state.expanduser().resolve()
try:
with locked(args.state):
if args.action == 'init':
result = init(args)
else:
state = read(args.state)
result = status(state) if args.action == 'status' else globals()[args.action.replace('-','_')](state, args)
if args.action != 'status':
save(args.state, state)
print(json.dumps(result, indent=2, ensure_ascii=False))
return 1 if isinstance(result,dict) and result.get('phase') == 'wait-for-packages' else 0
except (ValueError, OSError, KeyError, subprocess.TimeoutExpired) as e:
print(f'error: {e}', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,796 @@
#!/usr/bin/env python3
"""Verify release package artifacts and their published provenance.
The verifier deliberately uses only the Python standard library. It validates
the package files first, then downloads the exact package from every configured
NuGet feed and queries npm's JSON API for every configured npm package. A
report is written even when validation fails so a delayed feed or malformed
artifact leaves useful evidence for the next attempt.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import http.client
import io
import json
import posixpath
import sys
import tarfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
from typing import Any, Iterable
DEFAULT_TIMEOUT = 25.0
DEFAULT_MAX_BYTES = 256 * 1024 * 1024
CHUNK_SIZE = 64 * 1024
MAX_ARCHIVE_MEMBERS = 10_000
MAX_JSON_BYTES = 16 * 1024 * 1024
class VerificationError(Exception):
"""A bounded, user-facing verification failure."""
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _is_prerelease(version: str) -> bool:
return "-" in version
def _version_for(entry: dict[str, Any], default: str) -> str:
version = entry.get("version", default)
if not isinstance(version, str) or not version.strip():
raise VerificationError("package version must be a non-empty string")
return version.strip()
def _safe_archive_name(name: str) -> bool:
"""Reject archive paths that could escape if a caller later extracts them."""
normalized = posixpath.normpath(name.replace("\\", "/"))
return not (normalized == ".." or normalized.startswith("../") or name.startswith("/"))
def _read_limited(stream: Any, max_bytes: int) -> bytes:
content = bytearray()
while True:
chunk = stream.read(min(CHUNK_SIZE, max_bytes + 1 - len(content)))
if not chunk:
return bytes(content)
content.extend(chunk)
if len(content) > max_bytes:
raise VerificationError(f"response exceeds max bytes ({max_bytes})")
def _json_bytes(data: bytes, description: str) -> dict[str, Any]:
if len(data) > MAX_JSON_BYTES:
raise VerificationError(f"{description} exceeds JSON size limit")
try:
value = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise VerificationError(f"{description} is not valid JSON: {exc}") from exc
if not isinstance(value, dict):
raise VerificationError(f"{description} must be a JSON object")
return value
def _archive_payload_hash(data: bytes, max_bytes: int) -> str:
"""Hash uncompressed package payload, excluding NuGet's signing entry."""
digest = hashlib.sha256()
total = 0
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
infos = archive.infolist()
if len(infos) > MAX_ARCHIVE_MEMBERS:
raise VerificationError("nupkg contains too many archive members")
members: list[tuple[str, bytes]] = []
for info in infos:
if not _safe_archive_name(info.filename):
raise VerificationError(f"unsafe nupkg member path: {info.filename}")
if info.is_dir() or info.filename.lower().endswith(".signature.p7s"):
continue
if info.file_size > max_bytes or total + info.file_size > max_bytes:
raise VerificationError("nupkg uncompressed payload exceeds max bytes")
payload = archive.read(info)
total += len(payload)
members.append((info.filename, payload))
except (zipfile.BadZipFile, OSError, RuntimeError) as exc:
raise VerificationError(f"invalid nupkg zip: {exc}") from exc
for name, payload in sorted(members, key=lambda pair: pair[0]):
# Include names and lengths so concatenated files cannot collide.
encoded_name = name.encode("utf-8")
digest.update(len(encoded_name).to_bytes(8, "big"))
digest.update(encoded_name)
digest.update(len(payload).to_bytes(8, "big"))
digest.update(payload)
return digest.hexdigest()
def _parse_nuspec(data: bytes, max_bytes: int) -> dict[str, Any]:
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
infos = archive.infolist()
if len(infos) > MAX_ARCHIVE_MEMBERS:
raise VerificationError("nupkg contains too many archive members")
nuspec_infos = [
info
for info in infos
if not info.is_dir() and info.filename.lower().endswith(".nuspec")
]
if len(nuspec_infos) != 1:
raise VerificationError("nupkg must contain exactly one nuspec")
info = nuspec_infos[0]
if not _safe_archive_name(info.filename) or info.file_size > max_bytes:
raise VerificationError("nuspec is unsafe or exceeds max bytes")
nuspec = archive.read(info)
except (zipfile.BadZipFile, OSError, RuntimeError) as exc:
raise VerificationError(f"invalid nupkg zip: {exc}") from exc
try:
root = ET.fromstring(nuspec)
except ET.ParseError as exc:
raise VerificationError(f"invalid nuspec XML: {exc}") from exc
metadata = next((node for node in root.iter() if _local_name(node.tag) == "metadata"), None)
if metadata is None:
raise VerificationError("nuspec has no metadata element")
values: dict[str, Any] = {
"id": None,
"version": None,
"repository_commit": None,
"dependencies": [],
"nuspec": info.filename,
}
for child in metadata:
name = _local_name(child.tag)
if name in {"id", "version"}:
values[name] = (child.text or "").strip()
elif name == "repository":
values["repository_commit"] = (child.attrib.get("commit") or "").strip() or None
for dependency in metadata.iter():
if _local_name(dependency.tag) != "dependency":
continue
dep_id = (dependency.attrib.get("id") or "").strip()
dep_version = (dependency.attrib.get("version") or "").strip()
if dep_id:
values["dependencies"].append({"id": dep_id, "version": dep_version})
if not values["id"] or not values["version"]:
raise VerificationError("nuspec metadata must contain id and version")
values["payload_sha256"] = _archive_payload_hash(data, max_bytes)
return values
def _parse_nuget_artifact(path: Path, max_bytes: int) -> dict[str, Any]:
try:
size = path.stat().st_size
except OSError as exc:
raise VerificationError(f"cannot stat artifact: {exc}") from exc
if size > max_bytes:
raise VerificationError(f"artifact exceeds max bytes ({max_bytes})")
try:
data = path.read_bytes()
except OSError as exc:
raise VerificationError(f"cannot read artifact: {exc}") from exc
parsed = _parse_nuspec(data, max_bytes)
parsed.update({"path": str(path), "file": path.name})
return parsed
def _package_json_from_tgz(data: bytes, max_bytes: int) -> dict[str, Any]:
if len(data) > max_bytes:
raise VerificationError(f"npm tarball exceeds max bytes ({max_bytes})")
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive:
members = archive.getmembers()
if len(members) > MAX_ARCHIVE_MEMBERS:
raise VerificationError("npm tarball contains too many archive members")
total = 0
for member in members:
if not _safe_archive_name(member.name):
raise VerificationError(f"unsafe npm tarball member path: {member.name}")
if member.size < 0 or member.size > max_bytes or total + member.size > max_bytes:
raise VerificationError("npm tarball uncompressed payload exceeds max bytes")
total += member.size
package_member = next(
(member for member in members if member.name == "package/package.json"), None
)
if package_member is None or not package_member.isfile():
raise VerificationError("npm tarball has no regular package/package.json")
if not _safe_archive_name(package_member.name) or package_member.size > max_bytes:
raise VerificationError("npm package metadata is unsafe or exceeds max bytes")
extracted = archive.extractfile(package_member)
if extracted is None:
raise VerificationError("cannot read npm package metadata")
return _json_bytes(_read_limited(extracted, max_bytes), "npm package metadata")
except (tarfile.TarError, OSError, KeyError, ValueError) as exc:
raise VerificationError(f"invalid npm tarball: {exc}") from exc
def _parse_npm_artifact(path: Path, max_bytes: int) -> dict[str, Any]:
try:
size = path.stat().st_size
if size > max_bytes:
raise VerificationError(f"artifact exceeds max bytes ({max_bytes})")
data = path.read_bytes()
except OSError as exc:
raise VerificationError(f"cannot read artifact: {exc}") from exc
package = _package_json_from_tgz(data, max_bytes)
return {
"path": str(path),
"file": path.name,
"name": package.get("name"),
"version": package.get("version"),
"package": package,
"sha512_integrity": "sha512-"
+ base64.b64encode(hashlib.sha512(data).digest()).decode("ascii"),
}
def _request(
url: str,
*,
method: str,
timeout: float,
max_bytes: int,
accept: str = "*/*",
) -> tuple[int, dict[str, str], bytes]:
request = urllib.request.Request(
url,
method=method,
headers={"Accept": accept, "User-Agent": "elsa-release-package-verifier/1"},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
headers = {key.lower(): value for key, value in response.headers.items()}
if method == "HEAD":
return response.status, headers, b""
length = headers.get("content-length")
if length:
try:
if int(length) > max_bytes:
raise VerificationError(f"response exceeds max bytes ({max_bytes})")
except ValueError:
pass
return response.status, headers, _read_limited(response, max_bytes)
except urllib.error.HTTPError:
raise
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException) as exc:
raise VerificationError(f"request failed: {type(exc).__name__}: {exc}") from exc
def _http_error_record(exc: BaseException) -> dict[str, Any]:
if isinstance(exc, urllib.error.HTTPError):
exc.close()
return {
"error": type(exc).__name__,
"status": getattr(exc, "code", None),
"message": str(exc),
}
def _download_nupkg(url: str, timeout: float, max_bytes: int) -> tuple[dict[str, Any], bytes]:
"""Download a published nupkg, using HEAD only as a bounded preflight."""
try:
status, _, _ = _request(
url, method="HEAD", timeout=timeout, max_bytes=max_bytes, accept="application/octet-stream"
)
if status < 200 or status >= 400:
raise VerificationError(f"unexpected HEAD status {status}")
except urllib.error.HTTPError as exc:
if exc.code not in {405, 501}:
raise
exc.close()
# Feedz and a number of private NuGet feeds reject HEAD. A complete
# GET is the authoritative availability and provenance check.
status, headers, data = _request(
url, method="GET", timeout=timeout, max_bytes=max_bytes, accept="application/octet-stream"
)
if status < 200 or status >= 300:
raise VerificationError(f"unexpected GET status {status}")
return _parse_nuspec(data, max_bytes), data
def _download_json(url: str, timeout: float, max_bytes: int) -> dict[str, Any]:
status, _, data = _request(url, method="GET", timeout=timeout, max_bytes=max_bytes, accept="application/json")
if status < 200 or status >= 300:
raise VerificationError(f"unexpected JSON status {status}")
return _json_bytes(data, url)
def _download_bytes(url: str, timeout: float, max_bytes: int) -> bytes:
status, _, data = _request(
url, method="GET", timeout=timeout, max_bytes=max_bytes, accept="application/octet-stream"
)
if status < 200 or status >= 300:
raise VerificationError(f"unexpected tarball status {status}")
return data
def _nuget_url(base_url: str, package_id: str, version: str) -> str:
base = base_url.rstrip("/")
package = urllib.parse.quote(package_id.lower(), safe="")
encoded_version = urllib.parse.quote(version.lower(), safe="")
filename = urllib.parse.quote(f"{package_id.lower()}.{version.lower()}.nupkg", safe="")
return f"{base}/{package}/{encoded_version}/{filename}"
def _npm_url(registry: str, name: str, suffix: str = "") -> str:
# npm's JSON API uses an encoded scoped package path. quote leaves no
# slash in the package name, which also prevents path confusion.
package = urllib.parse.quote(name, safe="")
base = registry.rstrip("/")
return f"{base}/{package}{suffix}"
def _validate_manifest(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise VerificationError("manifest must be a JSON object")
version = raw.get("version")
source_commit = raw.get("source_commit")
if not isinstance(version, str) or not version.strip():
raise VerificationError("manifest version must be a non-empty string")
if not isinstance(source_commit, str) or not source_commit.strip():
raise VerificationError("manifest source_commit must be a non-empty string")
result = dict(raw)
result["version"] = version.strip()
result["source_commit"] = source_commit.strip()
for field in ("nuget", "feeds", "npm"):
value = raw.get(field, [])
if not isinstance(value, list):
raise VerificationError(f"manifest {field} must be an array")
result[field] = value
if not result["nuget"] and not result["npm"]:
raise VerificationError("manifest must contain at least one expected package")
expected_dependencies = raw.get("expected_dependencies", {})
if not isinstance(expected_dependencies, dict):
raise VerificationError("manifest expected_dependencies must be an object")
for dependency_id, dependency_version in expected_dependencies.items():
if (
not isinstance(dependency_id, str)
or not dependency_id.strip()
or not isinstance(dependency_version, str)
or not dependency_version.strip()
):
raise VerificationError("manifest expected_dependencies values must be non-empty strings")
result["expected_dependencies"] = expected_dependencies
for item in result["nuget"]:
if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not item["id"].strip():
raise VerificationError("each nuget entry requires a non-empty id")
item["id"] = item["id"].strip()
has_explicit_version = "version" in item
item["version"] = _version_for(item, result["version"])
verify_published = item.get("verify_published", True)
if not isinstance(verify_published, bool):
raise VerificationError(f"nuget {item['id']} verify_published must be boolean")
item["verify_published"] = verify_published
if not verify_published:
reason = item.get("reason")
if not has_explicit_version:
raise VerificationError(
f"nuget {item['id']} publication skips require an explicit fixed version"
)
if not isinstance(reason, str) or not reason.strip():
raise VerificationError(f"nuget {item['id']} publication skips require a reason")
if item["version"] == result["version"]:
raise VerificationError(
f"release-version nuget {item['id']} must have verify_published=true"
)
item["reason"] = reason.strip()
for item in result["feeds"]:
if (
not isinstance(item, dict)
or not isinstance(item.get("name"), str)
or not item["name"].strip()
or not isinstance(item.get("base_url"), str)
or not item["base_url"].strip()
):
raise VerificationError("each feed requires non-empty name and base_url")
item["name"] = item["name"].strip()
item["base_url"] = item["base_url"].strip()
for item in result["npm"]:
if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"].strip():
raise VerificationError("each npm entry requires a non-empty name")
if not isinstance(item.get("dist_tag"), str) or not item["dist_tag"].strip():
raise VerificationError(f"npm {item['name']} requires a non-empty dist_tag")
item["name"] = item["name"].strip()
item["dist_tag"] = item["dist_tag"].strip()
item["version"] = _version_for(item, result["version"])
if "registry" in item and (
not isinstance(item["registry"], str) or not item["registry"].strip()
):
raise VerificationError(f"npm {item['name']} registry must be a non-empty string")
item["registry"] = item.get("registry", "https://registry.npmjs.org").strip()
for field, key in (("nuget", "id"), ("npm", "name")):
names = [item[key].lower() for item in result[field]]
duplicate_names = sorted(name for name, count in Counter(names).items() if count > 1)
if duplicate_names:
raise VerificationError(f"manifest {field} has duplicate entries: {duplicate_names}")
for name in result["feeds"]:
if sum(1 for item in result["feeds"] if item["name"].lower() == name["name"].lower()) > 1:
raise VerificationError(f"manifest feeds has duplicate name: {name['name']}")
return result
def _check_nuget_metadata(
metadata: dict[str, Any],
expected_id: str,
expected_version: str,
source_commit: str,
manifest: dict[str, Any],
*,
artifact_label: str,
) -> list[str]:
errors: list[str] = []
if str(metadata.get("id", "")).lower() != expected_id.lower():
errors.append(f"{artifact_label}: nuspec id {metadata.get('id')!r} != {expected_id!r}")
if metadata.get("version") != expected_version:
errors.append(
f"{artifact_label}: nuspec version {metadata.get('version')!r} != {expected_version!r}"
)
commit = metadata.get("repository_commit")
if commit is None or commit.lower() != source_commit.lower():
errors.append(f"{artifact_label}: repository commit {commit!r} != {source_commit!r}")
monitored: dict[str, str] = {
str(item["id"]).lower(): str(item["version"])
for item in manifest["nuget"]
}
monitored.update({str(key).lower(): str(value) for key, value in manifest["expected_dependencies"].items()})
stable = not _is_prerelease(manifest["version"])
for dependency in metadata.get("dependencies", []):
dep_id = str(dependency.get("id", ""))
dep_version = str(dependency.get("version", ""))
normalized_dep_id = dep_id.lower()
is_internal_elsa = normalized_dep_id == "elsa" or normalized_dep_id.startswith("elsa.")
if not is_internal_elsa and normalized_dep_id not in {
str(key).lower() for key in manifest["expected_dependencies"]
}:
continue
expected_dependency = monitored.get(normalized_dep_id)
if expected_dependency is None:
continue
if stable and _is_prerelease(dep_version):
errors.append(f"{artifact_label}: stable dependency {dep_id} is prerelease {dep_version}")
if expected_dependency and dep_version != expected_dependency:
errors.append(
f"{artifact_label}: dependency {dep_id} version {dep_version!r} != {expected_dependency!r}"
)
return errors
def _discover_artifacts(artifacts_dir: Path, suffix: str) -> list[Path]:
if not artifacts_dir.is_dir():
raise VerificationError(f"artifacts directory does not exist: {artifacts_dir}")
return sorted(path for path in artifacts_dir.rglob(f"*{suffix}") if path.is_file())
def _verify_nuget(
manifest: dict[str, Any], artifacts_dir: Path, timeout: float, max_bytes: int
) -> tuple[dict[str, Any], list[str]]:
entries = {item["id"].lower(): item for item in manifest["nuget"]}
result: dict[str, Any] = {
"expected": [item["id"] for item in manifest["nuget"]],
"artifacts": [],
"missing": [],
"extra": [],
"duplicates": [],
"feeds": [],
}
errors: list[str] = []
artifacts_by_id: dict[str, list[dict[str, Any]]] = {}
for path in _discover_artifacts(artifacts_dir, ".nupkg"):
try:
artifact = _parse_nuget_artifact(path, max_bytes)
artifact_errors = _check_nuget_metadata(
artifact,
str(artifact.get("id")),
str(artifact.get("version")),
manifest["source_commit"],
manifest,
artifact_label=str(path),
)
artifact["errors"] = artifact_errors
errors.extend(artifact_errors)
key = str(artifact.get("id", "")).lower()
artifacts_by_id.setdefault(key, []).append(artifact)
except VerificationError as exc:
record = {"path": str(path), "file": path.name, "errors": [str(exc)]}
result["artifacts"].append(record)
errors.append(f"{path}: {exc}")
for key, records in artifacts_by_id.items():
result["artifacts"].extend(records)
if len(records) > 1:
result["duplicates"].append(records[0].get("id", key))
for key, entry in entries.items():
records = artifacts_by_id.get(key, [])
if not records:
result["missing"].append(entry["id"])
errors.append(f"missing NuGet artifact: {entry['id']}")
continue
for artifact in records:
comparison_errors = _check_nuget_metadata(
artifact,
entry["id"],
entry["version"],
manifest["source_commit"],
manifest,
artifact_label=artifact["path"],
)
artifact["errors"] = sorted(set(artifact.get("errors", []) + comparison_errors))
errors.extend(comparison_errors)
if len(records) > 1:
errors.append(f"duplicate NuGet artifact: {entry['id']}")
for key, records in artifacts_by_id.items():
if key not in entries:
name = str(records[0].get("id", key))
result["extra"].append(name)
errors.append(f"unexpected NuGet artifact: {name}")
for feed in manifest["feeds"]:
feed_result: dict[str, Any] = {"name": feed["name"], "base_url": feed["base_url"], "packages": []}
for entry in manifest["nuget"]:
package_result: dict[str, Any] = {
"id": entry["id"],
"version": entry["version"],
"url": _nuget_url(feed["base_url"], entry["id"], entry["version"]),
"verified": False,
}
if not entry["verify_published"]:
package_result.update({"skipped": True, "reason": entry["reason"], "verified": True})
feed_result["packages"].append(package_result)
continue
records = artifacts_by_id.get(entry["id"].lower(), [])
if len(records) != 1:
package_result["errors"] = ["published comparison requires exactly one local artifact"]
errors.append(f"{feed['name']}: cannot compare {entry['id']} without one artifact")
feed_result["packages"].append(package_result)
continue
local = records[0]
try:
published, _ = _download_nupkg(
package_result["url"], timeout=timeout, max_bytes=max_bytes
)
package_result.update(
{
"status": 200,
"published_payload_sha256": published["payload_sha256"],
"local_payload_sha256": local.get("payload_sha256"),
"payload_matches": published["payload_sha256"] == local.get("payload_sha256"),
}
)
metadata_errors = _check_nuget_metadata(
published,
entry["id"],
entry["version"],
manifest["source_commit"],
manifest,
artifact_label=f"{feed['name']}:{entry['id']}",
)
package_result["metadata"] = {
"id": published.get("id"),
"version": published.get("version"),
"repository_commit": published.get("repository_commit"),
"dependencies": published.get("dependencies", []),
}
package_result["errors"] = metadata_errors
package_result["verified"] = not metadata_errors and package_result["payload_matches"]
if not package_result["verified"]:
errors.extend(metadata_errors)
if not package_result["payload_matches"]:
errors.append(
f"{feed['name']}:{entry['id']}: published payload differs from local artifact"
)
except (urllib.error.HTTPError, VerificationError) as exc:
package_result.update(_http_error_record(exc))
package_result["errors"] = [str(exc)]
errors.append(f"{feed['name']}:{entry['id']}: {exc}")
feed_result["packages"].append(package_result)
feed_result["verified"] = all(package["verified"] for package in feed_result["packages"])
result["feeds"].append(feed_result)
if any(entry["verify_published"] for entry in manifest["nuget"]) and not manifest["feeds"]:
errors.append("NuGet publication verification requires at least one feed")
result["verified"] = not errors and all(feed["verified"] for feed in result["feeds"])
return result, errors
def _verify_npm(
manifest: dict[str, Any], artifacts_dir: Path, timeout: float, max_bytes: int
) -> tuple[dict[str, Any], list[str]]:
entries = {item["name"].lower(): item for item in manifest["npm"]}
result: dict[str, Any] = {
"expected": [item["name"] for item in manifest["npm"]],
"artifacts": [],
"missing": [],
"extra": [],
"duplicates": [],
"packages": [],
}
errors: list[str] = []
artifacts_by_name: dict[str, list[dict[str, Any]]] = {}
for path in _discover_artifacts(artifacts_dir, ".tgz"):
try:
artifact = _parse_npm_artifact(path, max_bytes)
result["artifacts"].append(artifact)
key = str(artifact.get("name", "")).lower()
artifacts_by_name.setdefault(key, []).append(artifact)
except VerificationError as exc:
record = {"path": str(path), "file": path.name, "errors": [str(exc)]}
result["artifacts"].append(record)
errors.append(f"{path}: {exc}")
for key, records in artifacts_by_name.items():
if len(records) > 1:
result["duplicates"].append(records[0].get("name", key))
errors.append(f"duplicate npm artifact: {records[0].get('name', key)}")
for key, entry in entries.items():
records = artifacts_by_name.get(key, [])
if not records:
result["missing"].append(entry["name"])
errors.append(f"missing npm artifact: {entry['name']}")
continue
for artifact in records:
artifact_errors: list[str] = []
if artifact.get("name") != entry["name"]:
artifact_errors.append(
f"npm artifact name {artifact.get('name')!r} != {entry['name']!r}"
)
if artifact.get("version") != entry["version"]:
artifact_errors.append(
f"npm artifact {entry['name']} version {artifact.get('version')!r} != {entry['version']!r}"
)
artifact["errors"] = sorted(set(artifact.get("errors", []) + artifact_errors))
errors.extend(artifact_errors)
if len(records) > 1:
continue
package_result: dict[str, Any] = {
"name": entry["name"],
"version": entry["version"],
"dist_tag": entry["dist_tag"],
"registry": entry["registry"],
"verified": False,
}
try:
version_metadata_url = _npm_url(entry["registry"], entry["name"], f"/{urllib.parse.quote(entry['version'], safe='')}")
packument_url = _npm_url(entry["registry"], entry["name"])
published = _download_json(version_metadata_url, timeout, max_bytes)
packument = _download_json(packument_url, timeout, max_bytes)
dist = published.get("dist")
if not isinstance(dist, dict):
raise VerificationError("published npm metadata has no dist object")
published_name = published.get("name")
published_version = published.get("version")
if published_name != entry["name"]:
raise VerificationError(f"published npm name {published_name!r} != {entry['name']!r}")
if published_version != entry["version"]:
raise VerificationError(
f"published npm version {published_version!r} != {entry['version']!r}"
)
dist_tags = packument.get("dist-tags")
if not isinstance(dist_tags, dict) or dist_tags.get(entry["dist_tag"]) != entry["version"]:
raise VerificationError(
f"npm dist-tag {entry['dist_tag']!r} does not point to {entry['version']!r}"
)
if _is_prerelease(entry["version"]) and dist_tags.get("latest") == entry["version"]:
raise VerificationError("npm prerelease must not occupy the latest dist-tag")
tarball_url = dist.get("tarball")
integrity = dist.get("integrity")
if not isinstance(tarball_url, str) or not tarball_url:
raise VerificationError("published npm metadata has no tarball URL")
if not isinstance(integrity, str) or not integrity.startswith("sha512-"):
raise VerificationError("published npm metadata has no sha512 integrity")
published_bytes = _download_bytes(tarball_url, timeout, max_bytes)
published_integrity = "sha512-" + base64.b64encode(hashlib.sha512(published_bytes).digest()).decode("ascii")
if published_integrity != integrity:
raise VerificationError("published npm tarball does not match dist.integrity")
local = records[0]
if local.get("sha512_integrity") != integrity:
raise VerificationError("local npm artifact does not match published dist.integrity")
published_package = _package_json_from_tgz(published_bytes, max_bytes)
if published_package.get("name") != entry["name"] or published_package.get("version") != entry["version"]:
raise VerificationError("published npm tarball package metadata does not match manifest")
package_result.update(
{
"version_metadata_url": version_metadata_url,
"packument_url": packument_url,
"tarball_url": tarball_url,
"integrity": integrity,
"dist_tags": dist_tags,
"verified": True,
}
)
except (urllib.error.HTTPError, VerificationError) as exc:
package_result.update(_http_error_record(exc))
package_result["errors"] = [str(exc)]
errors.append(f"{entry['name']}: {exc}")
result["packages"].append(package_result)
for key, records in artifacts_by_name.items():
if key not in entries:
name = str(records[0].get("name", key))
result["extra"].append(name)
errors.append(f"unexpected npm artifact: {name}")
result["verified"] = not errors and all(package["verified"] for package in result["packages"])
return result, errors
def verify(manifest_path: Path, artifacts_dir: Path, timeout: float, max_bytes: int) -> dict[str, Any]:
report: dict[str, Any] = {
"verified": False,
"version": None,
"source_commit": None,
"nuget": {},
"npm": {},
"errors": [],
}
try:
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest = _validate_manifest(raw)
report["version"] = manifest["version"]
report["source_commit"] = manifest["source_commit"]
nuget_result, nuget_errors = _verify_nuget(manifest, artifacts_dir, timeout, max_bytes)
npm_result, npm_errors = _verify_npm(manifest, artifacts_dir, timeout, max_bytes)
report["nuget"] = nuget_result
report["npm"] = npm_result
report["errors"] = nuget_errors + npm_errors
report["verified"] = not report["errors"] and nuget_result["verified"] and npm_result["verified"]
except (OSError, UnicodeError, json.JSONDecodeError, VerificationError) as exc:
report["errors"] = [str(exc)]
return report
def _write_report(path: Path, report: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--artifacts", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
parser.add_argument("--max-bytes", "--maxbytes", dest="max_bytes", type=int, default=DEFAULT_MAX_BYTES)
args = parser.parse_args(argv)
if args.timeout <= 0:
parser.error("--timeout must be greater than zero")
if args.max_bytes <= 0:
parser.error("--max-bytes must be greater than zero")
report = verify(args.manifest, args.artifacts, args.timeout, args.max_bytes)
try:
_write_report(args.output, report)
except OSError as exc:
print(json.dumps({"verified": False, "errors": [f"cannot write report: {exc}"]}))
return 1
summary = {
"verified": report["verified"],
"version": report["version"],
"source_commit": report["source_commit"],
"errors": len(report["errors"]),
}
print(json.dumps(summary, sort_keys=True))
return 0 if report["verified"] else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,129 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest.mock import patch
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
import release # noqa: E402
from release_support import parse_version # noqa: E402
class ReleaseVersionTests(unittest.TestCase):
def test_supported_versions_and_channel_properties(self) -> None:
stable = parse_version("3.9.0")
rc = parse_version("3.9.0-rc1")
dotted_rc = parse_version("3.9.0-rc.1", "rc")
preview = parse_version("3.9.0-preview.1")
generic = parse_version("3.9.0-beta.1")
self.assertEqual((stable.base, stable.kind, stable.prerelease, stable.npm_tag), ("3.9.0", "stable", False, "latest"))
self.assertEqual((rc.base, rc.kind, rc.prerelease, rc.npm_tag), ("3.9.0", "rc", True, "next"))
self.assertEqual(dotted_rc.version, "3.9.0-rc.1")
self.assertEqual(preview.kind, "preview")
self.assertEqual(generic.kind, "preview")
def test_invalid_and_mismatched_versions_are_rejected(self) -> None:
for value in ("3.09.0", "3.9", "v3.9.0", "3.9.0+build.1", "3.9.0-rc.01"):
with self.subTest(value=value), self.assertRaises(ValueError):
parse_version(value)
with self.assertRaises(ValueError):
parse_version("3.9.0-rc1", "preview")
with self.assertRaises(ValueError):
parse_version("3.9.0-preview.1", "rc")
with self.assertRaises(ValueError):
parse_version("3.9.0", "preview")
class ReleaseSafetyTests(unittest.TestCase):
def test_default_source_is_release_branch_for_base_version(self) -> None:
version = parse_version("3.9.0-rc1")
self.assertEqual(release.intended_source_ref(version), "origin/release/3.9.0")
self.assertEqual(release.intended_source_ref(version, "upstream"), "upstream/release/3.9.0")
def test_remote_source_uses_fresh_remote_sha_and_rejects_stale_local_ref(self) -> None:
with patch.object(release, "git_remote", return_value=f"{'a' * 40}\trefs/heads/release/3.9.0"), patch.object(release, "git", return_value="b" * 40):
with self.assertRaises(SystemExit):
release.resolve_source_commit(Path("."), "origin/release/3.9.0", "origin")
def test_remote_only_source_sha_is_usable_before_fetch(self) -> None:
with patch.object(release, "git_remote", return_value=f"{'a' * 40}\trefs/heads/release/3.9.0"), patch.object(release, "git", return_value=""):
self.assertEqual(release.resolve_source_commit(Path("."), "origin/release/3.9.0", "origin"), "a" * 40)
def test_remote_only_object_does_not_turn_preflight_into_a_mutation(self) -> None:
completed = CompletedProcess(["git"], 128, "", "fatal: malformed object name aaaa")
with tempfile.TemporaryDirectory() as directory, patch.object(release.subprocess, "run", return_value=completed):
self.assertEqual(release.remote_branches_containing(Path(directory), "a" * 40), [])
def test_stable_rc_source_with_wrong_base_fails_before_any_mutation(self) -> None:
with patch.object(sys, "argv", ["release.py", "--tag", "3.9.0", "--release-kind", "stable", "--source-ref", "3.8.0-rc1", "--execute"]), patch.object(release, "run") as run:
with self.assertRaises(SystemExit):
release.main()
run.assert_not_called()
def test_containment_accepts_origin_and_configured_remote(self) -> None:
release.validate_containing_branches(["origin/main"], False, remote="upstream")
release.validate_containing_branches(["upstream/release/3.9.0"], False, remote="upstream")
with self.assertRaises(SystemExit):
release.validate_containing_branches(["fork/main"], False, remote="upstream")
def test_existing_tag_mismatch_is_rejected_without_retagging(self) -> None:
with self.assertRaises(SystemExit):
release.validate_existing_tag("3.9.0", "a" * 40, "b" * 40, "b" * 40)
def test_matching_existing_release_is_reused_without_create_or_edit(self) -> None:
release_metadata = release.ExistingRelease("3.9.0", "a" * 40, False, False)
args = ["release.py", "--tag", "3.9.0", "--release-kind", "stable", "--github-repo", "owner/repo", "--execute"]
with patch.object(sys, "argv", args), patch.object(release, "ensure_tool"), patch.object(release, "ensure_git_repo"), patch.object(release, "resolve_source_commit", return_value="a" * 40), patch.object(release, "remote_branches_containing", return_value=["origin/main"]), patch.object(release, "local_tag_target", return_value="a" * 40), patch.object(release, "remote_tag_target", return_value="a" * 40), patch.object(release, "verify_github_repo", return_value=True), patch.object(release, "inspect_release", return_value=release_metadata), patch.object(release, "run") as run:
self.assertEqual(release.main(), 0)
run.assert_not_called()
def test_legacy_release_with_branch_target_metadata_reuses_matching_remote_tag(self) -> None:
expected = parse_version("3.9.0")
release.validate_existing_release(
release.ExistingRelease("3.9.0", "main", False, False),
"3.9.0",
"a" * 40,
expected,
"a" * 40,
)
def test_kind_and_draft_mismatches_fail_for_existing_release(self) -> None:
expected = parse_version("3.9.0")
with self.assertRaises(SystemExit):
release.validate_existing_release(release.ExistingRelease("3.9.0", "a" * 40, False, True), "3.9.0", "a" * 40, expected)
with self.assertRaises(SystemExit):
release.validate_existing_release(release.ExistingRelease("3.9.0", "a" * 40, True, False), "3.9.0", "a" * 40, expected)
with self.assertRaises(SystemExit):
release.validate_existing_release(release.ExistingRelease("3.9.0", "b" * 40, False, False), "3.9.0", "a" * 40, expected)
def test_remote_error_is_not_treated_as_missing_tag(self) -> None:
completed = CompletedProcess(["git"], 128, "", "fatal: could not read from remote repository")
with tempfile.TemporaryDirectory() as directory, patch.object(release.subprocess, "run", return_value=completed):
with self.assertRaises(SystemExit):
release.git_remote(Path(directory), "origin", ["refs/tags/3.9.0"])
def test_not_found_release_is_distinguished_from_authentication_error(self) -> None:
self.assertTrue(release.is_github_not_found("", "release not found (HTTP 404)"))
self.assertFalse(release.is_github_not_found("", "repository not found"))
self.assertTrue(release.is_github_not_found("", "release not found", repository_visible=True))
self.assertFalse(release.is_github_not_found("", "HTTP 401: authentication required"))
self.assertFalse(release.is_github_not_found("", "HTTP 503: service unavailable"))
def test_notes_version_metadata_must_match_requested_release(self) -> None:
with tempfile.TemporaryDirectory() as directory:
notes = Path(directory) / "notes.md"
notes.write_text("<!-- elsa-release-version: 3.8.0 -->\n\n## Fixes\n", encoding="utf-8")
with self.assertRaises(SystemExit):
release.validate_notes_file(Path(directory), "notes.md", "3.9.0")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,182 @@
import copy
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
import release_train as train
import package_manifest
import release_notes
from release_support import parse_version
class TrainTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root=Path(self.temp.name)
self.args=SimpleNamespace(version='3.9.0',kind=None,profile=train.DEFAULT_PROFILE,repositories=None,repos_root=str(self.root),pr=None,source=None,no_announcements=False,state=self.root/'state.json')
self.state=train.init(self.args)
self.remote_existing=False
def bind_fixture(self,name):
path=self.root/name;path.mkdir(exist_ok=True)
manifest=path/'manifest.json';notes=path/'notes.md';report=path/'report.json'
train.save(manifest,{'version':'3.9.0','source_commit':'a'*40})
notes.write_text('Release 3.9.0\n')
train.save(report,{'verified':True,'source_commit':'a'*40,'version':'3.9.0'})
self.state['repositories'][name].update(binding={'commit':'a'*40,'manifest':str(manifest),'notes':str(notes),'manifest_sha256':train.digest(manifest),'notes_sha256':train.digest(notes)},verification={'commit':'a'*40,'report':str(report),'report_sha256':train.digest(report)})
def github(self,*args):
url=args[-1]
if '/releases?' in url:
name=next(r['name'] for r in self.state['profile']['repositories'] if r['github'] in url)
if not self.remote_existing and 'binding' not in self.state['repositories'][name]:
return [[]]
return [[{'tag_name':'3.9.0','draft':False,'prerelease':False,'html_url':'https://github.com/release'}]]
if '/git/ref/' in url:
return {'object':{'type':'tag','sha':'tag-object'}}
if '/git/tags/' in url:
return {'object':{'type':'commit','sha':'a'*40}}
if '/workflows/' in url:
return [{'workflow_runs':[{'id':42,'head_sha':'a'*40,'head_branch':'3.9.0','event':'release','run_number':42,'run_attempt':1,'status':'completed','conclusion':'success','html_url':'https://github.com/run'}]}]
if '/jobs?' in url:
cfg=next(r for r in self.state['profile']['repositories'] if r['github'] in url)
return [{'jobs':[{'name':n,'conclusion':'success'} for n in cfg['required_jobs']]}]
self.fail(f'Unexpected GitHub call {args}')
def test_unnumbered_rc_and_preview_need_a_resolved_version(self):
for value in ['3.9.0-rc','3.9.0-preview']:
with self.assertRaisesRegex(ValueError,'explicit unused'):
parse_version(value)
def test_init_resumes_without_erasing_progress_and_rejects_scope_change(self):
self.state['repositories']['core']['marker']='preserve'
train.save(self.args.state,self.state)
self.assertEqual('preserve',train.init(self.args)['repositories']['core']['marker'])
self.args.no_announcements=True
with self.assertRaisesRegex(ValueError,'different announce'):
train.init(self.args)
def test_wrong_release_line_source_is_rejected_before_checkpoint(self):
self.args.state=self.root/'wrong-source.json';self.args.source=['core=3.8.0-rc1']
with self.assertRaisesRegex(ValueError,'different release line'):
train.init(self.args)
self.assertFalse(self.args.state.exists())
def test_fresh_state_adopts_existing_tag_instead_of_republishing(self):
self.remote_existing=True
with patch.object(train,'gh',side_effect=self.github):
observed=train.status(self.state)
self.assertEqual('adopt-existing',observed['repositories']['core']['phase'])
self.assertEqual('a'*40,observed['repositories']['core']['commit'])
self.assertEqual('wait-for-upstream',observed['repositories']['studio']['phase'])
def test_subset_adds_verification_only_upstreams(self):
self.args.state=self.root/'subset.json';self.args.repositories=['extensions']
state=train.init(self.args)
self.assertFalse(state['repositories']['core']['publish'])
self.assertFalse(state['repositories']['studio']['publish'])
self.assertTrue(state['repositories']['extensions']['publish'])
def test_dependency_order_and_tampered_receipt_blocks_progress(self):
with patch.object(train,'gh',side_effect=self.github):
first=train.status(self.state)
self.assertEqual('prepare',first['repositories']['core']['phase'])
self.assertEqual('wait-for-upstream',first['repositories']['studio']['phase'])
self.bind_fixture('core')
second=train.status(self.state)
self.assertEqual('prepare',second['repositories']['studio']['phase'])
self.bind_fixture('studio');self.bind_fixture('extensions')
self.assertEqual('announcements',train.status(self.state)['next'])
binding=self.state['repositories']['core']['binding']
Path(binding['manifest']).write_text('{}')
self.assertEqual('verify-packages',train.status(self.state)['repositories']['core']['phase'])
self.assertEqual('wait-for-upstream',train.status(self.state)['repositories']['extensions']['phase'])
def test_exact_tag_and_required_job_fail_closed(self):
self.bind_fixture('core')
def wrong_tag(*args):
if '/git/tags/' in args[-1]:return {'object':{'type':'commit','sha':'b'*40}}
return self.github(*args)
with patch.object(train,'gh',side_effect=wrong_tag),self.assertRaisesRegex(ValueError,'different commit'):
train.inspect_release(self.state,'core')
def skipped_job(*args):
value=self.github(*args)
if '/jobs?' in args[-1]:value[0]['jobs'][-1]['conclusion']='skipped'
return value
with patch.object(train,'gh',side_effect=skipped_job),self.assertRaisesRegex(ValueError,'required jobs'):
train.inspect_release(self.state,'core')
def test_running_job_stays_waiting_and_failed_job_requires_repair(self):
self.bind_fixture('core')
def run_status(status,conclusion):
def respond(*args):
value=self.github(*args)
if '/workflows/' in args[-1]:value[0]['workflow_runs'][0].update(status=status,conclusion=conclusion)
return value
return respond
with patch.object(train,'gh',side_effect=run_status('in_progress',None)):
self.assertEqual('wait-for-run',train.inspect_release(self.state,'core')['phase'])
with patch.object(train,'gh',side_effect=run_status('completed','failure')):
self.assertEqual('repair-pipeline',train.inspect_release(self.state,'core')['phase'])
def test_alignment_only_changes_one_configured_declaration(self):
source='<Project><PackageVersion Include="Elsa.Api.Client" Version="3.8.0" /><PackageVersion Include="Other" Version="1.0" /></Project>'
updated=train.aligned_text(source,{'package':'Elsa.Api.Client'},'3.9.0-rc1')
self.assertIn('Version="3.9.0-rc1"',updated);self.assertIn('Include="Other" Version="1.0"',updated)
self.assertEqual(updated,train.aligned_text(updated,{'package':'Elsa.Api.Client'},'3.9.0-rc1'))
with self.assertRaisesRegex(ValueError,'found 2'):
train.aligned_text(source+source,{'package':'Elsa.Api.Client'},'3.9.0')
def test_announcements_cannot_complete_from_queued_or_changed_receipt(self):
for name in self.state['repositories']:self.bind_fixture(name)
with patch.object(train,'gh',side_effect=self.github):
for platform in ['discord','linkedin','x']:
message=self.root/f'{platform}.txt';message.write_text('Elsa 3.9.0 stable is available')
receipt=self.root/f'{platform}.json'
data={'id':platform,'url':'https://example.com/'+platform,'text':message.read_text(),'status':'scheduled','crossposted':True}
train.save(receipt,data)
args=SimpleNamespace(platform=platform,receipt=receipt,message_file=message)
with self.assertRaisesRegex(ValueError,'verified publication'):
train.record_announcement(self.state,args)
data['status']='sent';train.save(receipt,data)
train.record_announcement(self.state,args)
self.assertEqual('complete',train.status(self.state)['next'])
receipt.write_text('{}')
self.assertEqual('announcements',train.status(self.state)['next'])
def test_manifest_requires_profile_feeds_npm_and_explicit_exceptions(self):
manifest={'version':'3.9.0','source_commit':'a'*40,'nuget':[{'id':'Elsa','version':'3.9.0'}],'feeds':copy.deepcopy(self.state['profile']['feeds']),'npm':[]}
train.validate_manifest(self.state,'core',manifest,'a'*40)
manifest['nuget'][0]['verify_published']=False
with self.assertRaisesRegex(ValueError,'exception'):
train.validate_manifest(self.state,'core',manifest,'a'*40)
manifest['nuget'][0].pop('verify_published');manifest['feeds'].pop()
with self.assertRaisesRegex(ValueError,'feed policy'):
train.validate_manifest(self.state,'core',manifest,'a'*40)
def test_solution_inventory_evaluates_packability_and_fixed_source_version(self):
project=self.root/'Sample.csproj';project.write_text('<Project><PropertyGroup><Version>1.0.1</Version></PropertyGroup></Project>')
fixed={'Sample':{'version':'1.0.1','verify_published':False,'reason':'fixed sample'}}
with patch.object(package_manifest,'command',return_value='{"Properties":{"IsPackable":"true","PackageId":"Sample","PackageVersion":"3.9.0"}}'):
self.assertEqual('1.0.1',package_manifest.evaluate_project(project,'3.9.0',fixed)['version'])
project.write_text('<Project/>')
with self.assertRaisesRegex(ValueError,'Fixed version changed'):
package_manifest.evaluate_project(project,'3.9.0',fixed)
with patch.object(package_manifest,'command',return_value='{"Properties":{"IsPackable":"false"}}'):
self.assertIsNone(package_manifest.evaluate_project(project,'3.9.0',{}))
def test_notes_keep_version_and_refuse_overwriting_reviewed_file(self):
note=self.root/'notes.md';note.write_text('Reviewed notes')
args=SimpleNamespace(version='3.9.0',repo_path=str(self.root),from_ref='3.8.0',to_ref='HEAD',output=str(note),overwrite=False)
with patch.object(release_notes,'parse_args',return_value=args),patch.object(release_notes,'get_commits',return_value=[]):
self.assertEqual(1,release_notes.main())
self.assertEqual('Reviewed notes',note.read_text())
self.assertIn('elsa-release-version: 3.9.0',release_notes.render_notes('3.9.0','3.8.0','HEAD',[]))
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,314 @@
from __future__ import annotations
import base64
import hashlib
import io
import json
import subprocess
import sys
import tarfile
import tempfile
import threading
import unittest
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
SKILL_ROOT = Path(__file__).parents[1]
SCRIPT = SKILL_ROOT / "scripts" / "verify_packages.py"
SOURCE_COMMIT = "0123456789abcdef0123456789abcdef01234567"
VERSION = "9.4.0"
def nupkg(package_id: str, version: str, commit: str, *, signed: bool = False, dependency: str | None = None) -> bytes:
nuspec = f'''<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>{package_id}</id><version>{version}</version>
<repository type="git" url="https://github.com/example/repo" commit="{commit}" />
<dependencies><group><dependency id="Elsa.Core" version="{dependency or version}" /></group></dependencies>
</metadata>
</package>'''.encode()
output = io.BytesIO()
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr(f"{package_id}.nuspec", nuspec)
archive.writestr("lib/net8.0/package.dll", b"package payload")
if signed:
archive.writestr(".signature.p7s", b"different signing bytes")
return output.getvalue()
def npm_tgz(name: str, version: str) -> bytes:
output = io.BytesIO()
with tarfile.open(fileobj=output, mode="w:gz") as archive:
package = json.dumps({"name": name, "version": version}).encode()
info = tarfile.TarInfo("package/package.json")
info.size = len(package)
archive.addfile(info, io.BytesIO(package))
return output.getvalue()
class PackageRegistry:
def __init__(self) -> None:
self.nupkgs: dict[tuple[str, str, str], tuple[int, bytes]] = {}
self.npm: dict[str, dict[str, object]] = {}
self.tarballs: dict[str, bytes] = {}
self.head_405_feeds: set[str] = set()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.server.server_port}"
def close(self) -> None:
self.server.shutdown()
self.thread.join(timeout=5)
self.server.server_close()
def _handler(self):
registry = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args) -> None:
return
def do_HEAD(self) -> None:
path = unquote(urlparse(self.path).path)
if path.startswith("/feed/"):
feed = path.split("/", 3)[2]
if feed in registry.head_405_feeds:
self.send_error(405)
return
payload, status, content_type = registry.resolve(path)
self.send_response(status)
if payload is not None:
self.send_header("Content-Length", str(len(payload)))
self.send_header("Content-Type", content_type)
self.end_headers()
def do_GET(self) -> None:
path = unquote(urlparse(self.path).path)
payload, status, content_type = registry.resolve(path)
self.send_response(status)
if payload is not None:
self.send_header("Content-Length", str(len(payload)))
self.send_header("Content-Type", content_type)
self.end_headers()
if payload is not None:
self.wfile.write(payload)
return Handler
def resolve(self, path: str) -> tuple[bytes | None, int, str]:
parts = path.strip("/").split("/")
if len(parts) == 5 and parts[0] == "feed" and parts[4].endswith(".nupkg"):
feed, package_id, version = parts[1], parts[2], parts[3]
key = (feed, package_id.lower(), version.lower())
if key not in self.nupkgs:
return None, 404, "text/plain"
return self.nupkgs[key][1], self.nupkgs[key][0], "application/octet-stream"
if len(parts) >= 2 and parts[0] == "npm":
# npm paths are /npm/<registry-name>/<version> and /npm/<name>.
name = parts[1]
if len(parts) == 3 and name in self.npm:
payload = self.npm[name]["version_metadata"]
return json.dumps(payload).encode(), 200, "application/json"
if len(parts) == 2 and name in self.npm:
payload = self.npm[name]["packument"]
return json.dumps(payload).encode(), 200, "application/json"
if len(parts) == 3 and parts[0] == "tarballs":
payload = self.tarballs.get(parts[1] + "/" + parts[2])
if payload is None:
return None, 404, "text/plain"
return payload, 200, "application/octet-stream"
return None, 404, "text/plain"
def add_nuget(self, feed: str, package_id: str, version: str, data: bytes, status: int = 200) -> None:
self.nupkgs[(feed, package_id.lower(), version.lower())] = (status, data)
def add_npm(self, name: str, version: str, data: bytes, tags: dict[str, str]) -> None:
tarball_name = name.replace("@", "at-").replace("/", "-")
tarball_path = f"{tarball_name}/{version}.tgz"
self.tarballs[tarball_path] = data
integrity = "sha512-" + base64.b64encode(hashlib.sha512(data).digest()).decode()
self.npm[name] = {
"version_metadata": {
"name": name,
"version": version,
"dist": {"integrity": integrity, "tarball": f"{self.base_url}/tarballs/{tarball_path}"},
},
"packument": {"name": name, "dist-tags": tags},
}
class VerifyPackagesTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.artifacts = self.root / "artifacts"
self.artifacts.mkdir()
self.registry = PackageRegistry()
def tearDown(self) -> None:
self.registry.close()
self.temp.cleanup()
def write_nupkg(self, name: str, data: bytes) -> None:
(self.artifacts / name).write_bytes(data)
def write_npm(self, name: str, data: bytes) -> None:
(self.artifacts / name).write_bytes(data)
def manifest(self, *, fixed: bool = True) -> dict[str, object]:
nuget: list[dict[str, object]] = [
{"id": "Elsa.Core"},
{
"id": "Elsa.SamplePackage",
"version": "1.0.1",
"verify_published": False,
"reason": "sample package is intentionally fixed-version",
},
]
if not fixed:
nuget.pop()
return {
"version": VERSION,
"source_commit": SOURCE_COMMIT,
"nuget": nuget,
"feeds": [
{"name": "feed-405", "base_url": f"{self.registry.base_url}/feed/feed-405"},
{"name": "feed-ok", "base_url": f"{self.registry.base_url}/feed/feed-ok"},
],
"npm": [{"name": "elsa-ui", "dist_tag": "latest", "registry": f"{self.registry.base_url}/npm"}],
"expected_dependencies": {"Elsa.Core": VERSION},
}
def run_verify(self, manifest: dict[str, object]) -> tuple[int, dict[str, object]]:
manifest_path = self.root / "manifest.json"
report_path = self.root / "report.json"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
completed = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--manifest",
str(manifest_path),
"--artifacts",
str(self.artifacts),
"--output",
str(report_path),
"--timeout",
"3",
],
text=True,
capture_output=True,
check=False,
)
self.assertTrue(report_path.exists(), completed.stderr)
return completed.returncode, json.loads(report_path.read_text(encoding="utf-8"))
def seed_success(self) -> None:
core = nupkg("Elsa.Core", VERSION, SOURCE_COMMIT)
sample = nupkg("Elsa.SamplePackage", "1.0.1", SOURCE_COMMIT, signed=True, dependency=VERSION)
self.write_nupkg("Elsa.Core.9.4.0.nupkg", core)
self.write_nupkg("Elsa.SamplePackage.1.0.1.nupkg", sample)
for feed in ("feed-405", "feed-ok"):
self.registry.add_nuget(feed, "Elsa.Core", VERSION, core)
npm = npm_tgz("elsa-ui", VERSION)
self.write_npm("elsa-ui-9.4.0.tgz", npm)
self.registry.add_npm("elsa-ui", VERSION, npm, {"latest": VERSION, "next": "9.5.0"})
self.registry.head_405_feeds.add("feed-405")
def test_success_checks_both_feeds_signed_normalization_and_exact_latest_tag(self) -> None:
self.seed_success()
code, report = self.run_verify(self.manifest())
self.assertEqual(code, 0, report)
self.assertTrue(report["verified"])
self.assertEqual(report["version"], VERSION)
self.assertTrue(all(feed["verified"] for feed in report["nuget"]["feeds"]))
self.assertTrue(report["npm"]["packages"][0]["verified"])
def test_missing_artifact_and_unknown_extra_fail_with_partial_report(self) -> None:
self.seed_success()
(self.artifacts / "Elsa.Core.9.4.0.nupkg").unlink()
self.write_nupkg("Unknown.9.4.0.nupkg", nupkg("Unknown", VERSION, SOURCE_COMMIT))
code, report = self.run_verify(self.manifest())
self.assertNotEqual(code, 0)
self.assertFalse(report["verified"])
self.assertIn("Elsa.Core", report["nuget"]["missing"])
self.assertIn("Unknown", report["nuget"]["extra"])
self.assertTrue(report["errors"])
def test_wrong_commit_and_publish_lag_fail(self) -> None:
self.seed_success()
bad = nupkg("Elsa.Core", VERSION, "deadbeef")
self.write_nupkg("Elsa.Core.9.4.0.nupkg", bad)
self.registry.nupkgs.pop(("feed-ok", "elsa.core", VERSION.lower()))
code, report = self.run_verify(self.manifest())
self.assertNotEqual(code, 0)
self.assertTrue(any("repository commit" in error for error in report["errors"]))
feed_ok = next(feed for feed in report["nuget"]["feeds"] if feed["name"] == "feed-ok")
core = next(package for package in feed_ok["packages"] if package["id"] == "Elsa.Core")
self.assertEqual(core["status"], 404)
def test_wrong_version_is_rejected(self) -> None:
self.seed_success()
wrong = nupkg("Elsa.Core", "9.3.0", SOURCE_COMMIT)
(self.artifacts / "Elsa.Core.9.4.0.nupkg").write_bytes(wrong)
code, report = self.run_verify(self.manifest())
self.assertNotEqual(code, 0)
self.assertTrue(any("nuspec version" in error for error in report["errors"]))
def test_stable_release_rejects_prerelease_internal_dependency(self) -> None:
self.write_nupkg(
"Elsa.Core.9.4.0.nupkg",
nupkg("Elsa.Core", VERSION, SOURCE_COMMIT, dependency="9.4.0-rc1"),
)
manifest = self.manifest(fixed=False)
manifest["feeds"] = []
code, report = self.run_verify(manifest)
self.assertNotEqual(code, 0)
self.assertTrue(any("stable dependency" in error for error in report["errors"]))
def test_rc_and_preview_use_next_and_cannot_take_latest(self) -> None:
for suffix in ("rc1", "preview.1"):
with self.subTest(suffix=suffix):
for artifact in self.artifacts.iterdir():
artifact.unlink()
version = VERSION + "-" + suffix
core = nupkg("Elsa.Core", version, SOURCE_COMMIT)
npm = npm_tgz("elsa-ui", version)
self.write_nupkg("Elsa.Core.nupkg", core)
self.write_npm("elsa-ui.tgz", npm)
for feed in ("feed-405", "feed-ok"):
self.registry.add_nuget(feed, "Elsa.Core", version, core)
self.registry.add_npm("elsa-ui", version, npm, {"next": version, "latest": "9.3.0"})
manifest = self.manifest(fixed=False)
manifest["version"] = version
manifest["expected_dependencies"] = {"Elsa.Core": version}
for item in manifest["nuget"]:
item["version"] = version
manifest["npm"][0]["dist_tag"] = "next"
code, report = self.run_verify(manifest)
self.assertEqual(0, code, report)
self.registry.add_npm("elsa-ui", version, npm, {"next": version, "latest": version})
code, report = self.run_verify(manifest)
self.assertNotEqual(0, code)
self.assertTrue(any("must not occupy" in error for error in report["errors"]))
def test_fixed_skip_requires_reason_and_explicit_version(self) -> None:
manifest = self.manifest(fixed=False)
manifest["nuget"].append({"id": "Elsa.SamplePackage", "verify_published": False})
manifest["feeds"] = []
self.write_nupkg("Elsa.Core.9.4.0.nupkg", nupkg("Elsa.Core", VERSION, SOURCE_COMMIT))
code, report = self.run_verify(manifest)
self.assertNotEqual(code, 0)
self.assertTrue(any("publication skips require" in error for error in report["errors"]))
if __name__ == "__main__":
unittest.main()

View file

@ -15,7 +15,8 @@ on:
- 'release/*'
- 'codex/*'
release:
types: [prereleased, published]
# published covers stable releases and prereleases; subscribe once.
types: [published]
env:
base_version: '3.8.0'