From aeb982dcef3587f889fca787d4b45d0b604473d6 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 5 Sep 2026 07:44:02 -0700 Subject: [PATCH] Extend release train with Elsa templates (#8022) --- .agents/skills/elsa-release/SKILL.md | 10 +- .../elsa-release/references/elsa-profile.json | 74 +++++++++ .../references/post-release-sites.md | 2 + .../skills/elsa-release/references/runbook.md | 34 ++-- .../elsa-release/scripts/package_manifest.py | 100 +++++++++++- .../elsa-release/scripts/release_train.py | 141 +++++++++++++++-- .../elsa-release/scripts/verify_packages.py | 146 +++++++++++++++++- .../elsa-release/tests/test_release_train.py | 87 ++++++++++- .../tests/test_verify_packages.py | 91 +++++++++++ 9 files changed, 646 insertions(+), 39 deletions(-) diff --git a/.agents/skills/elsa-release/SKILL.md b/.agents/skills/elsa-release/SKILL.md index 9a9db7ada..4cc5117b0 100644 --- a/.agents/skills/elsa-release/SKILL.md +++ b/.agents/skills/elsa-release/SKILL.md @@ -1,17 +1,17 @@ --- name: elsa-release -description: Release Elsa Core, Studio, and Extensions in dependency order, including validation, package verification, post-release website/documentation refresh, 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. +description: Release Elsa Core, Studio, Extensions, and Templates in dependency order, including validation, package verification, post-release website/documentation refresh, 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 -“Release Elsa ” means complete the release across Core, Studio, and Extensions, including the configured post-release website/documentation refresh and announcements. Read [the runbook](references/runbook.md) and [post-release site guidance](references/post-release-sites.md) before execution. They contain the commands, repository profile, prerequisites, content audit, validation and recovery procedure. Read [release-note guidance](references/release-notes.md) when preparing notes. +“Release Elsa ” means complete the release across Core, Studio, Extensions, and Templates, including the configured post-release website/documentation refresh and announcements. Read [the runbook](references/runbook.md) and [post-release site guidance](references/post-release-sites.md) before execution. They contain the commands, repository profile, prerequisites, content audit, validation and recovery procedure. Read [release-note guidance](references/release-notes.md) when preparing notes. ## Interpret the request - 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/` 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 all four repositories in the order Core → Studio → Extensions → Templates. 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/` for Core, Studio, and Extensions. Templates follows its documented repository policy: stable releases use freshly fetched `origin/main`, while RC/preview releases use `origin/release/` or an explicit source override. Never use the caller's checkout HEAD as an accidental source or silently publish a preview from Templates `main`. - 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. - The default post-release scope is both the official Elsa Hub website and the Elsa GitBook documentation. A repository subset limits the content claims and receipt scope to the selected repositories; it does not silently publish an unselected package. Use `--no-post-refresh` only when the user explicitly excludes this phase. For an existing completed checkpoint, use explicit `adopt-post-refresh --targets website` for a website-only follow-up. - 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. @@ -24,7 +24,7 @@ Honor overrides such as “Core only”, “without announcements”, “without ## Execution invariant -**Core release → verify its configured feeds → align Studio → validate, release and verify Studio → align Extensions → validate, release and verify Extensions → refresh and live-verify the website and documentation → announce and verify posts.** +**Core release → verify its configured feeds → align Studio → validate, release and verify Studio → align Extensions → validate, release and verify Extensions → align and validate Templates → install and smoke-test the published Templates package → refresh and live-verify the website and documentation → announce and verify posts.** 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. diff --git a/.agents/skills/elsa-release/references/elsa-profile.json b/.agents/skills/elsa-release/references/elsa-profile.json index 50903f101..7e0e41cd7 100644 --- a/.agents/skills/elsa-release/references/elsa-profile.json +++ b/.agents/skills/elsa-release/references/elsa-profile.json @@ -78,6 +78,80 @@ ], "fixed_packages": {}, "npm": [] + }, + { + "name": "templates", + "directory": "elsa-templates", + "github": "elsa-workflows/elsa-templates", + "solution": "Elsa.Templates.slnx", + "workflow": "packages.yml", + "artifact": "elsa-template-packages", + "dependencies": [ + "core", + "studio" + ], + "stage_after": [ + "extensions" + ], + "source_policy": { + "stable": "origin/main", + "prerelease": "origin/release/{base}" + }, + "alignment": [ + { + "file": "src/Elsa.Templates/Elsa.Templates.csproj", + "property": "Version" + }, + { + "file": ".github/workflows/packages.yml", + "yaml_key": "BASE_VERSION", + "base_version": true + }, + { + "glob": "src/Elsa.Templates/templates/**/*.csproj", + "package_prefix": "Elsa" + }, + { + "file": "elsa-templates.csproj", + "package_prefix": "Elsa" + }, + { + "glob": "src/Elsa.Templates/templates/**/StudioBrandingProvider.cs", + "studio_branding": true + }, + { + "file": "test/Elsa.Templates.Tests/TemplateSmokeTests.cs", + "string_constant": "ElsaVersion" + } + ], + "content_expectations": { + "source_root": "src/Elsa.Templates", + "source_globs": [ + "templates/**/*.csproj" + ], + "template_config_globs": [ + "templates/**/.template.config/template.json" + ], + "archive_prefix": "content/", + "archive_source_prefix": "templates/", + "package_prefixes": [ + "Elsa" + ], + "upstream_repositories": [ + "core", + "studio" + ] + }, + "expected_package_ids": [ + "Elsa.Templates" + ], + "required_jobs": [ + "Build packages", + "Publish to feedz.io", + "Publish to nuget.org" + ], + "fixed_packages": {}, + "npm": [] } ], "feeds": [ diff --git a/.agents/skills/elsa-release/references/post-release-sites.md b/.agents/skills/elsa-release/references/post-release-sites.md index 268d460a0..07650ece0 100644 --- a/.agents/skills/elsa-release/references/post-release-sites.md +++ b/.agents/skills/elsa-release/references/post-release-sites.md @@ -15,6 +15,8 @@ Stable releases update the current stable guidance and use `content_label: stabl Keep historical references such as Elsa 3.7 examples separate from current recommendations. A historical example may remain when it is explicitly labelled as historical, but do not present an older Elsa runtime as the new stable release in a current install path. A separately versioned template or container may remain the latest published artifact; label its actual embedded runtime version and give a verified upgrade or source-build path instead of inventing a matching version. Verify Docker images, templates, samples, and other release artifacts independently of NuGet package verification; a green NuGet feed check does not prove those artifacts were published or that the website points to them. +For a release train that selects Templates, record `Elsa.Templates` as a fourth package artifact: its exact source commit, workflow run and `elsa-template-packages` artifact, the single package ID/version, the verified NuGet/Feedz URLs, and the generated-project matrix results. Confirm every embedded Elsa reference matches the released version and belongs to the known package IDs from the configured Core and Studio upstream manifests. Keep a separately published or older template version labelled with its actual embedded runtime version until this evidence exists. + ## Update workflow 1. Read the verified release notes, package manifest, source commit, and selected repository scope. Extract technical claims from the tagged source and release workflow. Do not copy an unverified issue description or invent an API, package, Docker tag, template version, migration, or timeline. diff --git a/.agents/skills/elsa-release/references/runbook.md b/.agents/skills/elsa-release/references/runbook.md index 9471ba5cb..257722d2b 100644 --- a/.agents/skills/elsa-release/references/runbook.md +++ b/.agents/skills/elsa-release/references/runbook.md @@ -6,15 +6,18 @@ Read this once at the start of a release. Codex operates the procedure; the user 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, post-release website/documentation targets, and announcement destinations. Read [post-release site guidance](post-release-sites.md) for the content audit and receipt workflow. 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. +Use [elsa-profile.json](elsa-profile.json). This is the maintained default for repository names, dependency declarations, expected jobs, feeds, fixed-version exceptions, post-release website/documentation targets, and announcement destinations. It includes the fourth package stage, `Elsa.Templates`, whose source and embedded-reference policy is intentionally separate from the Core/Studio/Extensions release branches. Read [post-release site guidance](post-release-sites.md) for the content audit and receipt workflow. 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/`. 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. +Default release branches are `release/` for Core, Studio, and Extensions. Templates stable releases use freshly fetched `origin/main` because its documented `main` policy targets the latest stable Elsa release; Templates RC/preview releases use `origin/release/` unless an explicit source is supplied. Fetch and inspect all required repositories before publishing Core. An explicit source such as `core=3.9.0-rc1` or `templates=origin/3.9.0-preview.2` overrides only that source. An absent branch or conflicting version needs a concrete source decision; the helper must not fall back to `HEAD`, silently publish a preview from Templates `main`, or manufacture a branch from an unrelated release line. + +If the Templates prerelease branch does not exist, record a freshly fetched `main` SHA as the baseline, create `release/` from that reviewed SHA, align the published Core/Studio versions there, and run its branch checks before tagging. This branch preparation is a conscious source decision for Templates only; never manufacture missing Core, Studio, or Extensions release branches. 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. +- All configured package workflow `base_version` values match the new release line before branch CI. Templates stable workflow policy is checked separately: its `BASE_VERSION` and embedded Core/Studio PackageReferences must match the requested stable version, while a prerelease source must be selected explicitly or from its release branch. Version tags drive named release artifacts, but the branch preview version must also be correct. Make routine preparation changes on the intended release branch when needed. +- Before a Templates RC/preview run, inspect its release workflow's tag ancestry guard. It must accept the selected `origin/release/` or explicit prerelease source; if it only accepts `origin/main`, resolve that workflow/source mismatch in the Templates repository before publishing. Never claim a release-line source is runnable while the workflow silently rejects its tag. - 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. @@ -25,10 +28,10 @@ Initialize the plan (local state only): ```bash python3 /scripts/release_train.py --state /state.json init \ - --version 3.9.0 --repos-root + --version 3.9.0 --repos-root ``` -Optional arguments: `--kind stable|rc|preview`, `--repositories core studio`, `--source core=3.9.0-rc1`, repeated `--pr `, `--no-announcements`, `--no-post-refresh`, and `--profile `. The two flags are independent. 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. The post-release receipt scope contains only the selected repositories. +Optional arguments: `--kind stable|rc|preview`, `--repositories core studio extensions templates`, `--source core=3.9.0-rc1`, repeated `--pr `, `--no-announcements`, `--no-post-refresh`, and `--profile `. The two flags are independent. 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. Selecting `templates` includes Core and Studio as verification-only upstreams and does not add Extensions. The post-release receipt scope contains only the selected repositories. Repeating `init` with identical inputs preserves progress. Conflicting inputs fail rather than overwrite an in-flight plan. @@ -54,16 +57,27 @@ python3 /scripts/release_train.py --state /state.json align \ # 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. +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`. Templates updates its package project, workflow `BASE_VERSION`, all embedded Elsa PackageReferences, Studio branding literals, and the test fixture's expected Elsa version. 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. +- Templates: fresh `Elsa.Templates.slnx` restore/build/test, followed by the generated template matrix below. The Templates workflow must produce exactly the `Elsa.Templates` package in its `elsa-template-packages` artifact and must pass `Build packages`, `Publish to feedz.io`, and `Publish to nuget.org` for a named release. Its embedded Elsa references are checked from the source files against the known published package IDs from its configured upstreams (Core and Studio). 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. +For Templates, the required generated matrix covers at least: + +| Template | Required generated variants | Required checks | +| --- | --- | --- | +| `elsa-server` | each supported feature model and persistence option in the source test matrix, with every source `.template.config/template.json` present | restore from the published `Elsa.Templates` version, build, start the host, and verify the health/API endpoint and configured identity login path | +| `elsa-studio` | each supported server hosting mode and authentication provider in the source test matrix | restore from the published package, build, start the host, verify the browser route and all WASM framework/static assets, and complete the configured sign-in smoke check | +| `elsa-combined` | representative feature-model, persistence, hosting, and authentication combinations, including each conditional project-reference branch | restore from the published package, build, start the host, verify the browser route and WASM framework/static assets, and complete the configured sign-in plus workflow API smoke check | + +Run the matrix from an isolated temporary directory after installing the exact published package (`dotnet new install Elsa.Templates:: --nuget-source ` or the equivalent local package source). Do not substitute an unversioned or preview template package. For browser-hosted variants, a server `200` or successful compilation is insufficient: load the actual route in a browser, check framework/ICU/static asset requests for failures, and complete the sign-in route with a configured test identity. Capture the selected template parameters, generated project package references, build/startup result, browser/WASM asset result, authentication result, and package URL in the release record. A package's successful NuGet verification does not replace these generated-project checks. + 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 @@ -80,7 +94,7 @@ python3 /scripts/release_train.py --state /state.json bind \ --manifest /core-manifest.json --notes-file /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. +Repeat for Studio, Extensions, and Templates at their proper turns. The manifest records expected NuGet IDs/versions, source SHA, feeds, upstream dependency versions, and npm IDs/dist-tags. The Templates manifest must contain exactly `Elsa.Templates`, plus source-derived expectations for every embedded project and `.template.config/template.json` in the package's content. Its embedded Elsa PackageReferences are checked against the exact requested version and known published Core/Studio package IDs, preserving each conditional project-reference variant. Binding regenerates these expectations from the checked-out source and already-bound upstream manifests before freezing the source; a hand-edited or incomplete expectation fails. 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. @@ -97,16 +111,16 @@ python3 /scripts/release.py --repo-path \ 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 `/-artifacts/` directory using `gh run download --repo --name --dir `. Download NuGet and Studio's two npm archives in separate subdirectories of that artifact root. Verify: +Download every artifact configured by that source workflow into a dedicated `/-artifacts/` directory using `gh run download --repo --name --dir `. Download NuGet and Studio's two npm archives in separate subdirectories of that artifact root, and download Templates' `elsa-template-packages` artifact separately. Verify: ```bash python3 /scripts/release_train.py --state /state.json verify \ --repo core --artifacts /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. +`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. For Templates it also compares every embedded project file and Elsa PackageReference in the nupkg against the bound source and known published upstream IDs. 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; once Extensions verifies, prepare Templates. -NuGet verification is not a release-wide artifact verdict. Verify Docker images, templates, samples, and other configured release artifacts independently, including the exact source/version or image tag and the public pull/template URL. Record those checks in the site audit or release completion record before using the artifact in current guidance. +NuGet verification is not a release-wide artifact verdict. Verify Docker images, the `Elsa.Templates` package and generated projects, samples, and other configured release artifacts independently, including the exact source/version or image tag and the public pull/template URL. Record those checks in the site audit or release completion record before using the artifact in current guidance. ## 6. Refresh sites, announce, recover, and finish diff --git a/.agents/skills/elsa-release/scripts/package_manifest.py b/.agents/skills/elsa-release/scripts/package_manifest.py index d67d0ec00..d089aea89 100644 --- a/.agents/skills/elsa-release/scripts/package_manifest.py +++ b/.agents/skills/elsa-release/scripts/package_manifest.py @@ -33,6 +33,90 @@ def evaluate_project(path, version, fixed_packages): return package +def _local_name(tag): + return tag.rsplit('}', 1)[-1] + + +def embedded_content(path, cfg, version, upstream_manifests): + """Derive archive-content expectations from the template source itself.""" + + rules = cfg.get('content_expectations') + if not rules: + return None + root = path / rules.get('source_root', '.') + files = [] + seen = set() + for pattern in rules.get('source_globs', []): + for source in sorted(root.glob(pattern)): + if not source.is_file() or source in seen: + continue + seen.add(source) + references = [] + try: + document = ET.parse(source) + except ET.ParseError as exc: + raise ValueError(f'Embedded template project is invalid XML: {source}: {exc}') from exc + for node in document.getroot().iter(): + if _local_name(node.tag) != 'PackageReference': + continue + package_id = (node.attrib.get('Include') or '').strip() + if not package_id or not any(package_id.startswith(prefix) for prefix in rules.get('package_prefixes', [])): + continue + package_version = (node.attrib.get('Version') or '').strip() + if not package_version: + raise ValueError(f'Embedded Elsa PackageReference has no version: {source}: {package_id}') + if package_version != version: + raise ValueError( + f'Embedded package {package_id} in {source} is {package_version}, expected {version}' + ) + references.append({'id': package_id, 'version': package_version}) + source_path = source.relative_to(root).as_posix() + archive_source_prefix = rules.get('archive_source_prefix', '') + archive_path = source_path + if archive_source_prefix and archive_path.startswith(archive_source_prefix): + archive_path = archive_path[len(archive_source_prefix):] + files.append({ + 'path': archive_path, + 'source_path': source_path, + 'references': references, + }) + if not files: + raise ValueError('Content expectations matched no embedded template projects') + + template_configs = [] + for pattern in rules.get('template_config_globs', []): + for source in sorted(root.glob(pattern)): + if source.is_file(): + source_path = source.relative_to(root).as_posix() + archive_source_prefix = rules.get('archive_source_prefix', '') + archive_path = source_path + if archive_source_prefix and archive_path.startswith(archive_source_prefix): + archive_path = archive_path[len(archive_source_prefix):] + template_configs.append(archive_path) + if rules.get('template_config_globs') and not template_configs: + raise ValueError('Content expectations matched no template.json files') + + known_ids = set() + for upstream in rules.get('upstream_repositories', []): + manifest = upstream_manifests.get(upstream) + if not manifest: + raise ValueError(f'Bind upstream {upstream} before evaluating embedded template references') + known_ids.update(item['id'] for item in manifest.get('nuget', [])) + embedded_ids = {ref['id'] for file in files for ref in file['references']} + unknown = sorted(embedded_ids - known_ids, key=str.lower) + if unknown: + raise ValueError(f'Embedded Elsa packages are not published by configured upstreams: {unknown}') + return { + 'package_id': (cfg.get('expected_package_ids') or [None])[0], + 'source_root': rules.get('source_root', '.'), + 'archive_prefix': rules.get('archive_prefix', ''), + 'expected_version': version, + 'known_published_ids': sorted(known_ids, key=str.lower), + 'files': sorted(files, key=lambda item: item['path']), + 'template_configs': sorted(set(template_configs)), + } + + def generate(state, name, path): cfg = config(state, name) item = entry(state, name) @@ -49,19 +133,31 @@ def generate(state, name, path): 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') + expected_ids = cfg.get('expected_package_ids') + if expected_ids is not None and sorted(expected_ids, key=str.lower) != sorted( + (p['id'] for p in packages), key=str.lower + ): + raise ValueError('Evaluated package inventory differs from the release profile') policy = state['profile']['release_kinds'][version.kind] dependencies = {} + upstream_manifests = {} 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 { + upstream_manifest = read(binding['manifest']) + upstream_manifests[upstream] = upstream_manifest + dependencies.update({p['id']:p.get('version',state['version']) for p in upstream_manifest['nuget']}) + result = { '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']], } + content = embedded_content(path, cfg, version.version, upstream_manifests) + if content is not None: + result['content_expectations'] = content + return result def main(): diff --git a/.agents/skills/elsa-release/scripts/release_train.py b/.agents/skills/elsa-release/scripts/release_train.py index cb756f839..e7fdf141f 100644 --- a/.agents/skills/elsa-release/scripts/release_train.py +++ b/.agents/skills/elsa-release/scripts/release_train.py @@ -76,6 +76,34 @@ def entry(state, name): return state['repositories'][name] +def default_source_ref(repository, version): + """Resolve a repository source policy without falling back to checkout HEAD.""" + + policy = repository.get('source_policy', {}) + if not policy: + template = 'origin/release/{base}' + else: + channel = 'stable' if version.kind == 'stable' else 'prerelease' + template = policy.get(channel) + if not template: + raise ValueError(f"{repository['name']}: source policy has no {channel} source") + return template.format(base=version.base, version=version.version, kind=version.kind) + + +def compatible_profile(existing, current): + """Allow additive repository/profile evolution when resuming old checkpoints.""" + + if not isinstance(existing, dict) or not isinstance(current, dict): + return False + old_repositories = {item['name']: item for item in existing.get('repositories', []) if isinstance(item, dict) and item.get('name')} + new_repositories = {item['name']: item for item in current.get('repositories', []) if isinstance(item, dict) and item.get('name')} + if any(new_repositories.get(name) != item for name, item in old_repositories.items()): + return False + old_rest = {key: value for key, value in existing.items() if key != 'repositories'} + new_rest = {key: value for key, value in current.items() if key != 'repositories'} + return old_rest == new_rest + + def post_refresh_configured(state): """Return whether this checkpoint explicitly adopted the post-release gate.""" @@ -262,17 +290,28 @@ def init(args): '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}'), + 'source_ref': sources.get(r['name'], default_source_ref(r, version)), } 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']: + for field in ['version', 'kind', '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') + if existing.get('profile') != state['profile'] and not compatible_profile(existing.get('profile'), state['profile']): + raise ValueError('Existing state has a different profile; use its recorded inputs') + old_scope = { + k: (v['publish'], v['path'], v['source_ref']) for k, v in existing['repositories'].items() + } + new_scope = { + k: (v['publish'], v['path'], v['source_ref']) for k, v in state['repositories'].items() + } + if args.repositories is not None or args.source is not None: + if old_scope != new_scope: + raise ValueError('Existing state has different repository scope or paths') + elif any(new_scope.get(name) != values for name, values in old_scope.items()): + raise ValueError('Existing state has different repository paths or source refs') if post_refresh_configured(existing): existing_refresh = existing['post_refresh'] requested_refresh = state['post_refresh'] @@ -358,7 +397,10 @@ def receipt_valid(item): def require_upstreams(state, name): check_prerequisites(state) - for upstream in config(state, name)['dependencies']: + required = config(state, name)['dependencies'] + config(state, name).get('stage_after', []) + for upstream in required: + if upstream not in state['repositories']: + continue observed = inspect_release(state, upstream) if observed['phase'] != 'verified': raise ValueError(f"{name} waits for {upstream}: {observed['phase']}") @@ -368,15 +410,59 @@ def aligned_text(text, rule, version): if 'property' in rule: tag = re.escape(rule['property']) pattern = rf'(<{tag}>)([^<]*)()' + value, count = re.subn(pattern, lambda m: m[1] + version + m[3], text) + elif 'yaml_key' in rule: + key = re.escape(rule['yaml_key']) + pattern = rf'(^\s*{key}:\s*)([^\s#]+)' + yaml_version = parse_version(version).base if rule.get('base_version') else version + value, count = re.subn(pattern, lambda m: m[1] + yaml_version, text, flags=re.MULTILINE) + elif 'package_prefix' in rule: + prefix = re.escape(rule['package_prefix']) + pattern = rf'(]*\bInclude=["\']{prefix}[^"\']*["\'])[^>]*?\bVersion=["\'])([^"\']*)(["\'])' + value, count = re.subn(pattern, lambda m: m[1] + version + m[3], text) + if count == 0: + raise ValueError(f'Expected at least one package reference for {rule}, found 0; inspect changed repository structure') + return value + elif 'studio_branding' in rule: + pattern = r'(["\']Elsa Studio\s+)\d+\.\d+(["\'])' + major_minor = '.'.join(version.split('.')[:2]) + value, count = re.subn(pattern, rf'\g<1>{major_minor}\g<2>', text) + elif 'string_constant' in rule: + constant = re.escape(rule['string_constant']) + pattern = rf'(\b(?:const\s+)?string\s+{constant}\s*=\s*["\'])[^"\']+(["\'])' + value, count = re.subn(pattern, lambda m: m[1] + version + m[2], text) else: package = re.escape(rule['package']) pattern = rf'(]*\bInclude=[\"\']{package}[\"\'][^>]*\bVersion=[\"\'])([^\"\']*)([\"\'])' - value, count = re.subn(pattern, lambda m: m[1] + version + m[3], text) + 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 alignment_files(path, rule): + if 'glob' in rule: + files = sorted(path.glob(rule['glob'])) + if not files: + raise ValueError(f"Alignment glob matched no files: {rule['glob']}") + return files + if 'file' not in rule: + raise ValueError(f'Alignment rule requires file or glob: {rule}') + file = path / rule['file'] + if not file.is_file(): + raise ValueError(f'Alignment file does not exist: {file}') + return [file] + + +def aligned_files(path, rules, version): + files = {} + for rule in rules: + for file in alignment_files(path, rule): + text = files.get(file, file.read_text()) + files[file] = aligned_text(text, rule, version) + return files + + def align(state, args): item = entry(state, args.repo) if not item['publish']: @@ -392,10 +478,7 @@ def align(state, args): 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']) + files = aligned_files(path, config(state, args.repo)['alignment'], 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(): @@ -415,6 +498,13 @@ def validate_manifest(state, name, manifest, commit): 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') + expected_ids = cfg.get('expected_package_ids') + if expected_ids is not None and sorted(expected_ids, key=str.lower) != sorted( + (x['id'] for x in manifest['nuget']), key=str.lower + ): + raise ValueError('Manifest package inventory differs from the release profile') + if cfg.get('content_expectations') and not manifest.get('content_expectations'): + raise ValueError('Templates manifest is missing source-derived content expectations') 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']) @@ -422,6 +512,25 @@ def validate_manifest(state, name, manifest, commit): raise ValueError('Unconfigured fixed-version/package-verification exception') +def validate_source_content_manifest(state, name, path, manifest): + """Rebuild source-derived archive expectations before freezing a binding.""" + + cfg = config(state, name) + if not cfg.get('content_expectations'): + return + from package_manifest import embedded_content + + upstream_manifests = {} + for upstream in cfg['content_expectations'].get('upstream_repositories', []): + binding = entry(state, upstream).get('binding') + if not binding: + raise ValueError(f'Bind upstream {upstream} before validating embedded content') + upstream_manifests[upstream] = read(binding['manifest']) + expected = embedded_content(path, cfg, state['version'], upstream_manifests) + if manifest.get('content_expectations') != expected: + raise ValueError('Manifest content expectations differ from the checked-out source') + + def bind(state, args): item = entry(state, args.repo) require_upstreams(state, args.repo) @@ -438,9 +547,8 @@ def bind(state, args): 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: + for file, text in aligned_files(path, cfg['alignment'], state['version']).items(): + if text != file.read_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') @@ -448,6 +556,7 @@ def bind(state, args): command(['git', 'merge-base', '--is-ancestor', pr['mergeCommit']['oid'], sha], path) manifest = read(args.manifest) validate_manifest(state, args.repo, manifest, sha) + validate_source_content_manifest(state, args.repo, path, manifest) 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') @@ -496,6 +605,10 @@ def status(state): check_prerequisites(state) results = {} for name in state['repositories']: + stage_after = [stage for stage in config(state, name).get('stage_after', []) if stage in results] + if any(results[stage]['phase'] != 'verified' for stage in stage_after): + results[name] = {'phase': 'wait-for-stage', 'stages': stage_after} + continue upstreams = config(state, name)['dependencies'] if any(results[u]['phase'] != 'verified' for u in upstreams): results[name] = {'phase': 'wait-for-upstream', 'upstreams': upstreams} @@ -600,7 +713,7 @@ def main(): 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('--repositories', nargs='+', help='Repositories to publish; upstream dependencies are verification-only') 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') diff --git a/.agents/skills/elsa-release/scripts/verify_packages.py b/.agents/skills/elsa-release/scripts/verify_packages.py index 26ec28adc..208140d41 100644 --- a/.agents/skills/elsa-release/scripts/verify_packages.py +++ b/.agents/skills/elsa-release/scripts/verify_packages.py @@ -119,7 +119,95 @@ def _archive_payload_hash(data: bytes, max_bytes: int) -> str: return digest.hexdigest() -def _parse_nuspec(data: bytes, max_bytes: int) -> dict[str, Any]: +def _embedded_content_errors(data: bytes, max_bytes: int, expectations: dict[str, Any] | None, package_id: str) -> list[str]: + """Validate embedded template projects without extracting an untrusted archive.""" + + if not expectations or package_id.lower() != str(expectations.get("package_id", "")).lower(): + return [] + expected_files = expectations.get("files", []) + archive_prefix = str(expectations.get("archive_prefix", "")) + expected_version = str(expectations.get("expected_version", "")) + known_ids = {str(value).lower() for value in expectations.get("known_published_ids", [])} + errors: list[str] = [] + 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") + total_bytes = 0 + for info in infos: + if not _safe_archive_name(info.filename): + raise VerificationError(f"unsafe nupkg member path: {info.filename}") + if info.file_size > max_bytes: + raise VerificationError("embedded template exceeds max bytes") + total_bytes += info.file_size + if total_bytes > max_bytes: + raise VerificationError("nupkg uncompressed payload exceeds max bytes") + names = [info.filename for info in infos if not info.is_dir()] + duplicate_names = sorted(name for name, count in Counter(names).items() if count > 1) + if duplicate_names: + errors.extend(f"duplicate nupkg member: {name}" for name in duplicate_names) + name_set = set(names) + template_projects = { + name[len(archive_prefix):] if archive_prefix and name.startswith(archive_prefix) else name + for name in name_set + if name.lower().endswith(".csproj") and (not archive_prefix or name.startswith(archive_prefix)) + } + expected_paths = {str(item.get("path", "")) for item in expected_files} + for missing in sorted(expected_paths - template_projects): + errors.append(f"embedded template project missing from nupkg: {missing}") + for extra in sorted(template_projects - expected_paths): + errors.append(f"unexpected embedded template project in nupkg: {extra}") + expected_configs = {str(value) for value in expectations.get("template_configs", [])} + if expected_configs: + template_configs = { + name[len(archive_prefix):] if archive_prefix and name.startswith(archive_prefix) else name + for name in names + if name.lower().endswith("/.template.config/template.json") + and (not archive_prefix or name.startswith(archive_prefix)) + } + for missing in sorted(expected_configs - template_configs): + errors.append(f"template configuration missing from nupkg: {missing}") + for extra in sorted(template_configs - expected_configs): + errors.append(f"unexpected template configuration in nupkg: {extra}") + for expected in expected_files: + relative = str(expected.get("path", "")) + candidates = [ + name for name in names + if name == archive_prefix + relative + ] + if len(candidates) != 1: + errors.append(f"embedded template project missing or duplicated in nupkg: {relative}") + continue + info = archive.getinfo(candidates[0]) + try: + document = ET.fromstring(archive.read(info)) + except ET.ParseError as exc: + errors.append(f"embedded template project is invalid XML: {relative}: {exc}") + continue + observed = [] + for node in document.iter(): + if _local_name(node.tag) != "PackageReference": + continue + package = (node.attrib.get("Include") or "").strip() + if not any(package.lower().startswith(str(prefix).lower()) for prefix in expectations.get("package_prefixes", ["Elsa"])): + continue + version = (node.attrib.get("Version") or "").strip() + observed.append({"id": package, "version": version}) + if package.lower() not in known_ids: + errors.append(f"{relative}: embedded package {package} is not a published upstream package") + if expected_version and version != expected_version: + errors.append(f"{relative}: embedded package {package} version {version!r} != {expected_version!r}") + if sorted(observed, key=lambda item: (item["id"].lower(), item["version"])) != sorted( + expected.get("references", []), key=lambda item: (item["id"].lower(), item["version"]) + ): + errors.append(f"{relative}: embedded Elsa PackageReferences differ from the bound source") + except (zipfile.BadZipFile, OSError, RuntimeError) as exc: + raise VerificationError(f"invalid nupkg zip: {exc}") from exc + return errors + + +def _parse_nuspec(data: bytes, max_bytes: int, content_expectations: dict[str, Any] | None = None) -> dict[str, Any]: try: with zipfile.ZipFile(io.BytesIO(data)) as archive: infos = archive.infolist() @@ -172,11 +260,12 @@ def _parse_nuspec(data: bytes, max_bytes: int) -> dict[str, Any]: if not values["id"] or not values["version"]: raise VerificationError("nuspec metadata must contain id and version") + values["content_errors"] = _embedded_content_errors(data, max_bytes, content_expectations, values["id"]) values["payload_sha256"] = _archive_payload_hash(data, max_bytes) return values -def _parse_nuget_artifact(path: Path, max_bytes: int) -> dict[str, Any]: +def _parse_nuget_artifact(path: Path, max_bytes: int, content_expectations: dict[str, Any] | None = None) -> dict[str, Any]: try: size = path.stat().st_size except OSError as exc: @@ -187,7 +276,7 @@ def _parse_nuget_artifact(path: Path, max_bytes: int) -> dict[str, Any]: data = path.read_bytes() except OSError as exc: raise VerificationError(f"cannot read artifact: {exc}") from exc - parsed = _parse_nuspec(data, max_bytes) + parsed = _parse_nuspec(data, max_bytes, content_expectations) parsed.update({"path": str(path), "file": path.name}) return parsed @@ -284,7 +373,7 @@ def _http_error_record(exc: BaseException) -> dict[str, Any]: } -def _download_nupkg(url: str, timeout: float, max_bytes: int) -> tuple[dict[str, Any], bytes]: +def _download_nupkg(url: str, timeout: float, max_bytes: int, content_expectations: dict[str, Any] | None = None) -> tuple[dict[str, Any], bytes]: """Download a published nupkg, using HEAD only as a bounded preflight.""" try: @@ -304,7 +393,7 @@ def _download_nupkg(url: str, timeout: float, max_bytes: int) -> tuple[dict[str, ) if status < 200 or status >= 300: raise VerificationError(f"unexpected GET status {status}") - return _parse_nuspec(data, max_bytes), data + return _parse_nuspec(data, max_bytes, content_expectations), data def _download_json(url: str, timeout: float, max_bytes: int) -> dict[str, Any]: @@ -372,6 +461,47 @@ def _validate_manifest(raw: Any) -> dict[str, Any]: raise VerificationError("manifest expected_dependencies values must be non-empty strings") result["expected_dependencies"] = expected_dependencies + content = raw.get("content_expectations") + if content is not None: + if not isinstance(content, dict): + raise VerificationError("manifest content_expectations must be an object") + for field in ("package_id", "expected_version", "archive_prefix"): + if not isinstance(content.get(field), str) or not content[field].strip(): + raise VerificationError(f"manifest content_expectations requires {field}") + if content["expected_version"] != result["version"]: + raise VerificationError("manifest content_expectations version must match the release version") + if not any( + str(item.get("id", "")).lower() == content["package_id"].lower() + for item in raw.get("nuget", []) if isinstance(item, dict) + ): + raise VerificationError("manifest content_expectations package_id is not an expected NuGet package") + for field in ("known_published_ids", "files", "template_configs"): + if not isinstance(content.get(field), list): + raise VerificationError(f"manifest content_expectations {field} must be an array") + if not content[field]: + raise VerificationError(f"manifest content_expectations {field} must not be empty") + for config_path in content["template_configs"]: + if not isinstance(config_path, str) or not config_path.strip(): + raise VerificationError("manifest content_expectations template configs require paths") + for package_id in content["known_published_ids"]: + if not isinstance(package_id, str) or not package_id.strip(): + raise VerificationError("manifest content_expectations known IDs must be non-empty strings") + for file in content["files"]: + if not isinstance(file, dict) or not isinstance(file.get("path"), str) or not file["path"].strip(): + raise VerificationError("manifest content_expectations files require paths") + if not isinstance(file.get("references"), list): + raise VerificationError("manifest content_expectations file references must be arrays") + for reference in file["references"]: + if ( + not isinstance(reference, dict) + or not isinstance(reference.get("id"), str) + or not isinstance(reference.get("version"), str) + or not reference["id"].strip() + or not reference["version"].strip() + ): + raise VerificationError("manifest embedded references require id and version") + result["content_expectations"] = content + 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") @@ -443,6 +573,7 @@ def _check_nuget_metadata( artifact_label: str, ) -> list[str]: errors: list[str] = [] + errors.extend(str(error) for error in metadata.get("content_errors", [])) 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: @@ -502,7 +633,7 @@ def _verify_nuget( 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 = _parse_nuget_artifact(path, max_bytes, manifest.get("content_expectations")) artifact_errors = _check_nuget_metadata( artifact, str(artifact.get("id")), @@ -570,7 +701,8 @@ def _verify_nuget( local = records[0] try: published, _ = _download_nupkg( - package_result["url"], timeout=timeout, max_bytes=max_bytes + package_result["url"], timeout=timeout, max_bytes=max_bytes, + content_expectations=manifest.get("content_expectations") ) package_result.update( { diff --git a/.agents/skills/elsa-release/tests/test_release_train.py b/.agents/skills/elsa-release/tests/test_release_train.py index 0dee5713a..629c2394c 100644 --- a/.agents/skills/elsa-release/tests/test_release_train.py +++ b/.agents/skills/elsa-release/tests/test_release_train.py @@ -95,6 +95,26 @@ class TrainTests(unittest.TestCase): with self.assertRaisesRegex(ValueError,'different announce'): train.init(self.args) + def test_init_preserves_a_legacy_three_repository_checkpoint_after_templates_is_added(self): + legacy = copy.deepcopy(self.state) + legacy['profile']['repositories'] = [ + repository for repository in legacy['profile']['repositories'] if repository['name'] != 'templates' + ] + legacy['repositories'].pop('templates') + train.save(self.args.state, legacy) + resumed = train.init(self.args) + self.assertNotIn('templates', resumed['repositories']) + self.assertEqual(legacy['profile'], resumed['profile']) + + def test_init_rejects_changed_implicit_paths_or_explicit_sources(self): + changed_root = self.root / 'other-root' + changed = SimpleNamespace(**{**vars(self.args), 'repos_root': str(changed_root)}) + with self.assertRaisesRegex(ValueError, 'repository paths'): + train.init(changed) + changed_source = SimpleNamespace(**{**vars(self.args), 'source': ['core=origin/main']}) + with self.assertRaisesRegex(ValueError, 'repository scope'): + train.init(changed_source) + 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'): @@ -116,6 +136,38 @@ class TrainTests(unittest.TestCase): self.assertFalse(state['repositories']['studio']['publish']) self.assertTrue(state['repositories']['extensions']['publish']) + def test_templates_are_fourth_stage_with_core_and_studio_upstreams(self): + self.args.state = self.root / 'templates.json' + self.args.repositories = ['templates'] + state = train.init(self.args) + self.assertEqual(['core', 'studio', 'templates'], list(state['repositories'])) + self.assertFalse(state['repositories']['core']['publish']) + self.assertFalse(state['repositories']['studio']['publish']) + self.assertTrue(state['repositories']['templates']['publish']) + self.assertEqual('origin/main', state['repositories']['templates']['source_ref']) + + self.args.state = self.root / 'templates-preview.json' + self.args.version = '3.9.0-preview.1' + self.args.kind = 'preview' + self.args.repositories = ['templates'] + preview = train.init(self.args) + self.assertEqual('origin/release/3.9.0', preview['repositories']['templates']['source_ref']) + + def test_templates_can_select_an_explicit_preview_source(self): + self.args.state = self.root / 'templates-source.json' + self.args.repositories = ['templates'] + self.args.source = ['templates=origin/3.9.0-preview.2'] + state = train.init(self.args) + self.assertEqual('origin/3.9.0-preview.2', state['repositories']['templates']['source_ref']) + + def test_full_train_keeps_templates_after_extensions_even_without_an_extension_reference(self): + self.bind_fixture('core') + self.bind_fixture('studio') + with patch.object(train, 'gh', side_effect=self.github): + observed = train.status(self.state) + self.assertEqual('wait-for-stage', observed['repositories']['templates']['phase']) + self.assertEqual(['extensions'], observed['repositories']['templates']['stages']) + def test_dependency_order_and_tampered_receipt_blocks_progress(self): with patch.object(train,'gh',side_effect=self.github): first=train.status(self.state) @@ -124,7 +176,7 @@ class TrainTests(unittest.TestCase): 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.bind_fixture('studio');self.bind_fixture('extensions');self.bind_fixture('templates') self.site_fixture('website');self.site_fixture('documentation') self.assertEqual('announcements',train.status(self.state)['next']) binding=self.state['repositories']['core']['binding'] @@ -167,6 +219,28 @@ class TrainTests(unittest.TestCase): with self.assertRaisesRegex(ValueError,'found 2'): train.aligned_text(source+source,{'package':'Elsa.Api.Client'},'3.9.0') + def test_template_alignment_updates_embedded_refs_branding_workflow_and_test_version(self): + self.assertEqual( + '', + train.aligned_text('', {'package_prefix': 'Elsa'}, '3.9.0'), + ) + self.assertEqual( + 'BASE_VERSION: 3.9.0', + train.aligned_text('BASE_VERSION: 3.8.0', {'yaml_key': 'BASE_VERSION'}, '3.9.0'), + ) + self.assertEqual( + 'BASE_VERSION: 3.9.0', + train.aligned_text('BASE_VERSION: 3.8.0', {'yaml_key': 'BASE_VERSION', 'base_version': True}, '3.9.0-rc1'), + ) + self.assertIn( + 'Elsa Studio 3.9', + train.aligned_text('AppNameWithVersion => "Elsa Studio 3.8"', {'studio_branding': True}, '3.9.0'), + ) + self.assertIn( + 'ElsaVersion = "3.9.0"', + train.aligned_text('const string ElsaVersion = "3.8.0"', {'string_constant': 'ElsaVersion'}, '3.9.0'), + ) + def test_announcements_cannot_complete_from_queued_or_changed_receipt(self): for name in self.state['repositories']:self.bind_fixture(name) self.site_fixture('website');self.site_fixture('documentation') @@ -304,6 +378,17 @@ class TrainTests(unittest.TestCase): with self.assertRaisesRegex(ValueError,'feed policy'): train.validate_manifest(self.state,'core',manifest,'a'*40) + def test_templates_manifest_requires_source_derived_content_expectations(self): + manifest = { + 'version': '3.9.0', + 'source_commit': 'a' * 40, + 'nuget': [{'id': 'Elsa.Templates', 'version': '3.9.0'}], + 'feeds': copy.deepcopy(self.state['profile']['feeds']), + 'npm': [], + } + with self.assertRaisesRegex(ValueError, 'content expectations'): + train.validate_manifest(self.state, 'templates', manifest, 'a' * 40) + def test_solution_inventory_evaluates_packability_and_fixed_source_version(self): project=self.root/'Sample.csproj';project.write_text('1.0.1') fixed={'Sample':{'version':'1.0.1','verify_published':False,'reason':'fixed sample'}} diff --git a/.agents/skills/elsa-release/tests/test_verify_packages.py b/.agents/skills/elsa-release/tests/test_verify_packages.py index 92b95cab5..e28afc12b 100644 --- a/.agents/skills/elsa-release/tests/test_verify_packages.py +++ b/.agents/skills/elsa-release/tests/test_verify_packages.py @@ -40,6 +40,28 @@ def nupkg(package_id: str, version: str, commit: str, *, signed: bool = False, d return output.getvalue() +def nupkg_with_template( + package_id: str, + version: str, + commit: str, + *, + project_path: str = "content/templates/Server.csproj", + embedded_version: str = VERSION, + include_project: bool = True, +) -> bytes: + data = nupkg(package_id, version, commit) + if not include_project: + return data + output = io.BytesIO(data) + with zipfile.ZipFile(output, "a", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + project_path, + f'', + ) + archive.writestr("content/elsa-server/.template.config/template.json", "{}") + return output.getvalue() + + def npm_tgz(name: str, version: str) -> bytes: output = io.BytesIO() with tarfile.open(fileobj=output, mode="w:gz") as archive: @@ -309,6 +331,75 @@ class VerifyPackagesTests(unittest.TestCase): self.assertNotEqual(code, 0) self.assertTrue(any("publication skips require" in error for error in report["errors"])) + def template_manifest(self) -> dict[str, object]: + return { + "version": VERSION, + "source_commit": SOURCE_COMMIT, + "nuget": [{"id": "Elsa.Templates"}], + "feeds": [ + {"name": "feed-ok", "base_url": f"{self.registry.base_url}/feed/feed-ok"}, + ], + "npm": [], + "expected_dependencies": {}, + "content_expectations": { + "package_id": "Elsa.Templates", + "archive_prefix": "content/", + "expected_version": VERSION, + "known_published_ids": ["Elsa.Core"], + "package_prefixes": ["Elsa"], + "files": [ + { + "path": "templates/Server.csproj", + "references": [{"id": "Elsa.Core", "version": VERSION}], + } + ], + "template_configs": ["elsa-server/.template.config/template.json"], + }, + } + + def test_template_content_matches_source_and_published_payload(self) -> None: + manifest = self.template_manifest() + package = nupkg_with_template("Elsa.Templates", VERSION, SOURCE_COMMIT) + self.write_nupkg("Elsa.Templates.nupkg", package) + self.registry.add_nuget("feed-ok", "Elsa.Templates", VERSION, package) + code, report = self.run_verify(manifest) + self.assertEqual(0, code, report) + self.assertTrue(report["verified"]) + + def test_stale_embedded_reference_fails_even_when_nuspec_is_current(self) -> None: + manifest = self.template_manifest() + package = nupkg_with_template("Elsa.Templates", VERSION, SOURCE_COMMIT, embedded_version="3.7.1") + self.write_nupkg("Elsa.Templates.nupkg", package) + self.registry.add_nuget("feed-ok", "Elsa.Templates", VERSION, package) + code, report = self.run_verify(manifest) + self.assertNotEqual(0, code) + self.assertTrue(any("embedded package Elsa.Core version" in error for error in report["errors"])) + + def test_missing_embedded_template_fails_closed(self) -> None: + manifest = self.template_manifest() + package = nupkg_with_template("Elsa.Templates", VERSION, SOURCE_COMMIT, include_project=False) + self.write_nupkg("Elsa.Templates.nupkg", package) + self.registry.add_nuget("feed-ok", "Elsa.Templates", VERSION, package) + code, report = self.run_verify(manifest) + self.assertNotEqual(0, code) + self.assertTrue(any("embedded template project missing" in error for error in report["errors"])) + + def test_duplicate_embedded_template_member_fails_closed(self) -> None: + manifest = self.template_manifest() + package = nupkg_with_template("Elsa.Templates", VERSION, SOURCE_COMMIT) + output = io.BytesIO(package) + with zipfile.ZipFile(output, "a", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "content/templates/Server.csproj", + '', + ) + package = output.getvalue() + self.write_nupkg("Elsa.Templates.nupkg", package) + self.registry.add_nuget("feed-ok", "Elsa.Templates", VERSION, package) + code, report = self.run_verify(manifest) + self.assertNotEqual(0, code) + self.assertTrue(any("duplicate nupkg member" in error for error in report["errors"])) + if __name__ == "__main__": unittest.main()