diff --git a/.github/workflows/container-images.yml b/.github/workflows/container-images.yml index df98657..592ba89 100644 --- a/.github/workflows/container-images.yml +++ b/.github/workflows/container-images.yml @@ -40,6 +40,11 @@ on: type: string required: false default: '' + supersede_run_id: + description: Prior successful full publication run to explicitly correct (advanced recovery only) + type: string + required: false + default: '' release: types: - published @@ -63,6 +68,7 @@ jobs: core_version: ${{ steps.release.outputs.core_version }} studio_version: ${{ steps.release.outputs.studio_version }} extensions_version: ${{ steps.release.outputs.extensions_version }} + supersede_run_id: ${{ steps.release.outputs.supersede_run_id }} images: ${{ steps.release.outputs.images }} matrix: ${{ steps.release.outputs.matrix }} steps: @@ -85,6 +91,7 @@ jobs: STUDIO_VERSION: ${{ inputs.studio_version || '' }} EXTENSIONS_VERSION: ${{ inputs.extensions_version || '' }} EXPECTED_COMMIT: ${{ inputs.expected_commit || '' }} + SUPERSEDE_RUN_ID: ${{ inputs.supersede_run_id || '' }} run: | python3 scripts/container_release.py validate-run \ --event "$GITHUB_EVENT_NAME" \ @@ -95,10 +102,20 @@ jobs: --studio-version "$STUDIO_VERSION" \ --extensions-version "$EXTENSIONS_VERSION" \ --expected-commit "$EXPECTED_COMMIT" \ + --supersede-run-id "$SUPERSEDE_RUN_ID" \ --release-tag "$RELEASE_EVENT_TAG" \ --ref "$GITHUB_REF" \ --commit "$GITHUB_SHA" + - name: Require correction source to be in main history + if: inputs.supersede_run_id != '' + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main || { + echo "A correction must use a source commit already included in main." >&2 + exit 1 + } + - name: Require published tag to be present in main history if: steps.release.outputs.publish == 'true' && startsWith(github.ref, 'refs/tags/') run: | @@ -148,7 +165,12 @@ jobs: CORE_VERSION: ${{ needs.prepare.outputs.core_version }} STUDIO_VERSION: ${{ needs.prepare.outputs.studio_version }} EXTENSIONS_VERSION: ${{ needs.prepare.outputs.extensions_version }} + SUPERSEDE_RUN_ID: ${{ needs.prepare.outputs.supersede_run_id }} run: | + PLAN_OPTIONS=() + if [[ -n "$SUPERSEDE_RUN_ID" ]]; then + PLAN_OPTIONS+=(--skip-version-tag) + fi python3 scripts/container_release.py plan-image \ --repository "$IMAGE_REPOSITORY" \ --version "$RELEASE_VERSION" \ @@ -156,7 +178,8 @@ jobs: --source-ref "$SOURCE_REF" \ --core-version "$CORE_VERSION" \ --studio-version "$STUDIO_VERSION" \ - --extensions-version "$EXTENSIONS_VERSION" + --extensions-version "$EXTENSIONS_VERSION" \ + "${PLAN_OPTIONS[@]}" - name: Build and push immutable source tag if: needs.prepare.outputs.publish == 'true' && steps.plan.outputs.reuse != 'true' @@ -270,6 +293,9 @@ jobs: finalize: name: Publish version tags and write receipt + permissions: + actions: read + contents: read needs: - prepare - build-and-smoke @@ -279,6 +305,11 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.source_commit }} + fetch-depth: 0 + + - name: Fetch main for correction ancestry validation + if: needs.prepare.outputs.supersede_run_id != '' + run: git fetch --no-tags origin main:refs/remotes/origin/main - name: Download image evidence uses: actions/download-artifact@v4 @@ -315,10 +346,12 @@ jobs: INPUT_CORE_VERSION: ${{ inputs.core_version || '' }} INPUT_STUDIO_VERSION: ${{ inputs.studio_version || '' }} INPUT_EXTENSIONS_VERSION: ${{ inputs.extensions_version || '' }} + SUPERSEDE_RUN_ID: ${{ needs.prepare.outputs.supersede_run_id }} EVENT_NAME: ${{ github.event_name }} GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} + GH_TOKEN: ${{ github.token }} run: | python3 scripts/container_release.py finalize \ --publication "$PUBLICATION" \ @@ -338,6 +371,7 @@ jobs: --input-core-version "$INPUT_CORE_VERSION" \ --input-studio-version "$INPUT_STUDIO_VERSION" \ --input-extensions-version "$INPUT_EXTENSIONS_VERSION" \ + --supersede-run-id "$SUPERSEDE_RUN_ID" \ --run-id "$GITHUB_RUN_ID" \ --run-attempt "$GITHUB_RUN_ATTEMPT" \ --artifact-dir artifacts/image-evidence \ diff --git a/Directory.Packages.props b/Directory.Packages.props index b1ab4b2..fd14c62 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/README.md b/README.md index 85f3214..9593e7b 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Each Elsa Apps release publishes an exact version tag and a source-specific tag The shared [container image workflow](.github/workflows/container-images.yml) builds all six images for pull-request validation without pushing. A manual run defaults to build-only. Publishing requires `publish: true`, a reviewed `main` or matching release-tag ref, the pinned full `expected_commit`, exact package versions for every selected image family, and either `all` or a comma-separated subset of these profile names: `server`, `studio-server`, `studio-wasm`, `studio-wasm-standalone`, `server-studio-server`, and `server-studio-wasm`. A published GitHub release tag builds and publishes the full image set automatically. The workflow validates every selected image's platform manifests and restored package assets, starts the image on each platform, verifies Blazor browser assets, and tests the server login and bearer-authenticated API on server hosts before creating version tags and the machine-readable receipt artifact. +For a correction to a completed publication, first merge the fix to `main`, then manually dispatch this workflow from `main` with the exact version and all three package versions, `images: all`, `publish: true`, the full reviewed `expected_commit`, and `supersede_run_id` set to the prior successful publication's run ID. The workflow verifies that run and its receipt artifact, builds and smokes all six profiles, and checks all eight existing version tags against the prior receipt before promoting. It allows only tags matching the prior receipt or the already-verified correction candidate, so an interrupted correction can be retried; a missing or unexpected tag blocks the correction. The new receipt links the superseded run, artifact digest, source commit, and prior image digests. The prior receipt remains intact, and this path never updates Elsa 4 or `latest` tags. Leave `supersede_run_id` blank for normal immutable publication, where conflicting version tags continue to be rejected. + Studio deployments should set the backend URL using `Backend__Url`, which is injected into the hosted client configuration as `window.elsaConfig.backendUrl`. ## License diff --git a/scripts/container_release.py b/scripts/container_release.py index c13f47b..8038990 100644 --- a/scripts/container_release.py +++ b/scripts/container_release.py @@ -5,6 +5,8 @@ import argparse import datetime as dt +import hashlib +import io import json import os import re @@ -15,6 +17,7 @@ import urllib.error import urllib.request import xml.etree.ElementTree as ET +import zipfile from pathlib import Path from typing import Any, Iterable @@ -25,6 +28,7 @@ REQUIRED_PLATFORMS = ("linux/amd64", "linux/arm64") APPS_REPOSITORY = "elsa-workflows/elsa-apps" BEARER_API_ENDPOINT = "/elsa/api/workflow-definitions?page=0&pageSize=1" +DASHBOARD_API_ENDPOINT = "/elsa/api/dashboard/overview?range=24h&includeSystem=false" EXTENSION_PACKAGES = ( "Elsa.Agents.", "Elsa.Logging.", @@ -61,7 +65,11 @@ "repository": "elsaworkflows/elsa-studio-blazor-wasm-app", "dockerfile": "src/Elsa.Studio.BlazorWasm/Dockerfile", "port": 8080, - "smokeAssets": ["/_framework/dotnet.js", "/_framework/blazor.webassembly.js"], + "smokeAssets": [ + "/_framework/dotnet.js", + "/_framework/blazor.webassembly.js", + "/Elsa.Studio.BlazorWasm.Client.styles.css", + ], "assetsPath": "/app/elsa-project.assets.json", "packages": ["core", "studio", "extensions"], "aliases": [{"name": "studio-wasm-alias", "repository": "elsaworkflows/elsa-studio"}], @@ -72,7 +80,11 @@ "repository": "elsaworkflows/elsa-studio-blazor-wasm-standalone-app", "dockerfile": "src/Elsa.Studio.BlazorWasm.Client/Dockerfile", "port": 80, - "smokeAssets": ["/_framework/dotnet.js", "/_framework/blazor.webassembly.js"], + "smokeAssets": [ + "/_framework/dotnet.js", + "/_framework/blazor.webassembly.js", + "/Elsa.Studio.BlazorWasm.Client.styles.css", + ], "assetsPath": "/usr/share/nginx/html/_framework/elsa-project.assets.json", "packages": ["core", "studio", "extensions"], }, @@ -203,6 +215,27 @@ def validate_publication(event: str, version: str, publish: bool, ref: str, rele raise ReleaseError(f"Unsupported workflow event: {event!r}") +def validate_supersede_request( + run_id: str, + event: str, + version: str, + publish: bool, + ref: str, + selected_images: list[dict[str, Any]], + package_versions: dict[str, str], +) -> None: + if not run_id: + return + if not re.fullmatch(r"[1-9][0-9]*", run_id): + raise ReleaseError("supersede_run_id must be a positive GitHub Actions run id") + if event != "workflow_dispatch" or not publish or ref != "refs/heads/main": + raise ReleaseError("A correction requires a publishing workflow_dispatch from main") + if selected_images != list(IMAGES): + raise ReleaseError("A correction requires all six application image profiles") + if any(package_versions[family] != version for family in ("core", "studio", "extensions")): + raise ReleaseError("A correction requires all package versions to equal the release version") + + def resolve_run(args: argparse.Namespace) -> dict[str, Any]: packages = read_package_versions(Path(args.packages)) event = args.event @@ -251,16 +284,29 @@ def resolve_run(args: argparse.Namespace) -> dict[str, Any]: if not is_elsa_version(package_version): raise ReleaseError(f"Unsupported {family} package version: {package_version!r}") + package_versions = { + "core": packages["ElsaVersion"], + "studio": packages["ElsaStudioVersion"], + "extensions": packages["ElsaExtensionsVersion"], + } + supersede_run_id = str(getattr(args, "supersede_run_id", "") or "").strip() + validate_supersede_request( + supersede_run_id, + event, + version, + publish, + args.ref, + selected_images, + package_versions, + ) + return { "version": version, "publish": publish, "sourceRef": args.ref, "sourceCommit": args.commit, - "packageVersions": { - "core": packages["ElsaVersion"], - "studio": packages["ElsaStudioVersion"], - "extensions": packages["ElsaExtensionsVersion"], - }, + "packageVersions": package_versions, + "supersedeRunId": supersede_run_id, "images": selected_images, "requiredFamilies": sorted(required_families), "matrix": {"include": selected_images}, @@ -418,11 +464,17 @@ def verify_labels(reference: str, expected: dict[str, str], platforms: Iterable[ def get_image_plan_with_packages( - repository: str, version: str, commit: str, source_ref: str, package_versions: dict[str, str] + repository: str, + version: str, + commit: str, + source_ref: str, + package_versions: dict[str, str], + *, + skip_version_tag: bool = False, ) -> dict[str, Any]: expected_labels = labels_for(version, commit, source_ref, package_versions) release_reference = image_reference(repository, version) - release_manifest = inspect_manifest(release_reference) + release_manifest = None if skip_version_tag else inspect_manifest(release_reference) if release_manifest: verified = verify_manifest(release_reference) verify_labels(release_reference, expected_labels, REQUIRED_PLATFORMS) @@ -488,41 +540,70 @@ def login_and_probe_api(url: str, username: str, password: str) -> dict[str, Any if login_status != 200 or not access_token: raise ReleaseError("Synthetic admin login did not return an access token") - endpoint = BEARER_API_ENDPOINT - probe = urllib.request.Request( - f"{url.rstrip('/')}{endpoint}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - try: - with urllib.request.urlopen(probe, timeout=15) as response: - api_status = response.status - content_type = response.headers.get("Content-Type", "").lower() - result = json.loads(response.read()) - except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as error: - raise ReleaseError( - f"Bearer-authenticated workflow definitions API request to {endpoint} failed: {error}" - ) from error - except json.JSONDecodeError as error: - raise ReleaseError( - f"Bearer-authenticated workflow definitions API request to {endpoint} returned invalid JSON" - ) from error + workflow_api = probe_bearer_json_api(url, BEARER_API_ENDPOINT, access_token) + result = workflow_api["body"] items = result.get("items", result.get("Items")) if isinstance(result, dict) else None total_count = result.get("totalCount", result.get("TotalCount")) if isinstance(result, dict) else None - if ( - api_status != 200 - or not content_type.startswith("application/json") - or not isinstance(items, list) - or not isinstance(total_count, int) - ): + if not isinstance(items, list) or not isinstance(total_count, int): + raise ReleaseError( + f"Bearer-authenticated workflow definitions API request to {BEARER_API_ENDPOINT} did not return a paged JSON response" + ) + + dashboard_api = probe_bearer_json_api(url, DASHBOARD_API_ENDPOINT, access_token) + dashboard = dashboard_api["body"] + if not isinstance(dashboard.get("runtime"), dict) or not isinstance(dashboard.get("workflowInstances"), dict): raise ReleaseError( - f"Bearer-authenticated workflow definitions API request to {endpoint} did not return a paged JSON response" + f"Bearer-authenticated dashboard API request to {DASHBOARD_API_ENDPOINT} returned an unexpected JSON object" ) + return { "identityLogin": {"status": login_status, "endpoint": "/elsa/api/identity/login"}, - "bearerApi": {"status": api_status, "endpoint": endpoint}, + "bearerApi": { + "status": workflow_api["status"], + "endpoint": BEARER_API_ENDPOINT, + "contentType": workflow_api["contentType"], + }, + "dashboardApi": { + "status": dashboard_api["status"], + "endpoint": DASHBOARD_API_ENDPOINT, + "contentType": dashboard_api["contentType"], + }, } +def probe_bearer_json_api(url: str, endpoint: str, access_token: str) -> dict[str, Any]: + request = urllib.request.Request( + f"{url.rstrip('/')}{endpoint}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + status = response.status + content_type = response.headers.get("Content-Type", "").lower() + body = json.loads(response.read()) + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as error: + raise ReleaseError(f"Bearer-authenticated API request to {endpoint} failed: {error}") from error + except json.JSONDecodeError as error: + raise ReleaseError(f"Bearer-authenticated API request to {endpoint} returned invalid JSON") from error + if status != 200 or not content_type.startswith("application/json") or not isinstance(body, dict): + raise ReleaseError(f"Bearer-authenticated API request to {endpoint} did not return a JSON object") + return {"status": status, "contentType": content_type, "body": body} + + +def validate_smoke_asset_evidence(path: str, status: int, byte_count: int, content_type: str) -> None: + if status != 200 or byte_count < 100: + raise ReleaseError(f"Browser asset {path} returned HTTP {status} or only {byte_count} bytes") + is_css = path.lower().endswith(".css") + content_type = content_type.lower() + valid_content_type = ( + content_type.startswith("text/css") + if is_css + else "javascript" in content_type or "ecmascript" in content_type + ) + if not valid_content_type: + raise ReleaseError(f"Browser asset {path} has unexpected content type {content_type!r}") + + def verify_smoke_assets(url: str, asset_paths: Iterable[str]) -> list[dict[str, Any]]: verified = [] for path in asset_paths: @@ -532,13 +613,10 @@ def verify_smoke_assets(url: str, asset_paths: Iterable[str]) -> list[dict[str, status = response.status content_type = response.headers.get("Content-Type", "").lower() except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as error: - raise ReleaseError(f"Browser framework asset request for {path} failed: {error}") from error - if status != 200 or len(body) < 100: - raise ReleaseError(f"Browser framework asset {path} returned HTTP {status} or an empty body") - if "javascript" not in content_type and "ecmascript" not in content_type: - raise ReleaseError(f"Browser framework asset {path} has unexpected content type {content_type!r}") + raise ReleaseError(f"Browser asset request for {path} failed: {error}") from error + validate_smoke_asset_evidence(path, status, len(body), content_type) if body.lstrip().lower().startswith((b" list[dict[str, Any]]: by_id = {fragment["id"]: fragment for fragment in fragments} + correction = prior_by_reference is not None + if correction: + planned_targets = { + image_reference(image["repository"], version) + for image in selected_images + } + planned_targets.update( + image_reference(alias["repository"], version) + for image in selected_images + for alias in image.get("aliases", []) + ) + if set(prior_by_reference) != planned_targets or not prior_commit or not prior_source_ref or not prior_package_versions: + raise ReleaseError("Correction receipt and selected version tags do not match") + + def inspect_target(reference: str, digest: str, platforms: dict[str, str]) -> str: + existing = inspect_manifest(reference) + if existing is None: + if correction: + raise ReleaseError(f"Correction target is missing: {reference}") + return "missing" + manifest = verify_manifest(reference) + if correction: + prior = prior_by_reference[reference] + if manifest["digest"] == digest and manifest["platforms"] == platforms: + verify_labels(reference, labels_for(version, commit, source_ref, package_versions), REQUIRED_PLATFORMS) + return "candidate" + if manifest["digest"] == prior["digest"] and manifest["platforms"] == prior["platforms"]: + verify_labels( + reference, + labels_for(version, prior_commit, prior_source_ref, prior_package_versions), + REQUIRED_PLATFORMS, + ) + return "prior" + raise ReleaseError(f"Correction target {reference} differs from both verified receipts") + verify_labels(reference, labels_for(version, commit, source_ref, package_versions), REQUIRED_PLATFORMS) + if manifest["digest"] != digest: + raise ReleaseError(f"Refusing to overwrite conflicting version tag {reference}") + if manifest["platforms"] != platforms: + raise ReleaseError(f"Refusing to reuse version tag with conflicting platforms {reference}") + return "candidate" + planned: list[tuple[str, str, str, dict[str, Any]]] = [] # Check every destination before creating any version tags. @@ -842,18 +975,9 @@ def run_docker_promotion( fragment_platforms = {item["platform"]: item["digest"] for item in source["platforms"]} if source_manifest["platforms"] != fragment_platforms: raise ReleaseError(f"Source manifest platforms do not match verified smoke evidence for {image['name']}") - existing = inspect_manifest(version_ref) - if existing is not None: - existing_manifest = verify_manifest(version_ref) - verify_labels( - version_ref, - labels_for(version, commit, source_ref, package_versions), - REQUIRED_PLATFORMS, - ) - if existing_manifest["digest"] != source_manifest["digest"]: - raise ReleaseError(f"Refusing to overwrite conflicting version tag {version_ref}") - if existing_manifest["platforms"] != source_manifest["platforms"]: - raise ReleaseError(f"Refusing to reuse version tag with conflicting platforms {version_ref}") + if correction: + verify_labels(immutable_source_ref, labels_for(version, commit, source_ref, package_versions), REQUIRED_PLATFORMS) + inspect_target(version_ref, source_manifest["digest"], source_manifest["platforms"]) planned.append((version_ref, immutable_source_ref, image["id"], source_manifest)) for image in selected_images: @@ -861,20 +985,11 @@ def run_docker_promotion( source_platforms = {item["platform"]: item["digest"] for item in source["platforms"]} for alias in image.get("aliases", []): alias_ref = image_reference(alias["repository"], version) - existing = inspect_manifest(alias_ref) - if existing is None: - continue - existing_manifest = verify_manifest(alias_ref) - verify_labels( - alias_ref, - labels_for(version, commit, source_ref, package_versions), - REQUIRED_PLATFORMS, - ) - if existing_manifest["digest"] != source["digest"] or existing_manifest["platforms"] != source_platforms: - raise ReleaseError(f"Refusing to overwrite conflicting alias tag {alias_ref}") + inspect_target(alias_ref, source["digest"], source_platforms) for version_ref, source_ref_for_image, image_id, source_manifest in planned: - if inspect_manifest(version_ref) is None: + state = inspect_target(version_ref, source_manifest["digest"], source_manifest["platforms"]) + if state in ("missing", "prior"): run_command(["docker", "buildx", "imagetools", "create", "--tag", version_ref, source_ref_for_image]) promoted = verify_manifest(version_ref, expected_digest=source_manifest["digest"]) if promoted["platforms"] != source_manifest["platforms"]: @@ -919,8 +1034,9 @@ def run_docker_promotion( canonical = canonical_by_id[image["id"]] for alias in image.get("aliases", []): alias_ref = image_reference(alias["repository"], version) - existing = inspect_manifest(alias_ref) - if existing is None: + source_platforms = {item["platform"]: item["digest"] for item in canonical["platforms"]} + state = inspect_target(alias_ref, canonical["digest"], source_platforms) + if state in ("missing", "prior"): run_command( [ "docker", @@ -954,6 +1070,350 @@ def run_docker_promotion( return output +def validate_receipt_platforms(value: Any, context: str) -> dict[str, str]: + if not isinstance(value, list): + raise ReleaseError(f"Prior receipt has invalid platform evidence for {context}") + platforms: dict[str, str] = {} + for item in value: + if not isinstance(item, dict): + raise ReleaseError(f"Prior receipt has invalid platform evidence for {context}") + platform = item.get("platform") + digest = item.get("digest") + if platform not in REQUIRED_PLATFORMS or platform in platforms: + raise ReleaseError(f"Prior receipt has duplicate or unsupported platforms for {context}") + if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise ReleaseError(f"Prior receipt has an invalid platform digest for {context}") + platforms[platform] = digest + if set(platforms) != set(REQUIRED_PLATFORMS): + raise ReleaseError(f"Prior receipt is missing a required platform for {context}") + return {platform: platforms[platform] for platform in REQUIRED_PLATFORMS} + + +def prior_publication_source_ref(run: dict[str, Any], version: str) -> str: + event = run.get("event") + head_branch = run.get("head_branch") + if event == "release": + if head_branch != version: + raise ReleaseError("Prior release event must target the exact version tag") + source_ref = f"refs/tags/{version}" + elif event == "workflow_dispatch": + if head_branch == "main": + source_ref = "refs/heads/main" + elif head_branch == version: + source_ref = f"refs/tags/{version}" + else: + raise ReleaseError("Prior dispatch must come from main or the matching version tag") + else: + raise ReleaseError("Prior publication must be a release or workflow_dispatch event") + + # head_ref/ref are not included by every Actions API response, but reject them if present + # and inconsistent with the accepted branch/tag provenance. + if run.get("head_ref") not in (None, "", head_branch, source_ref): + raise ReleaseError("Prior run ref metadata does not match its accepted publication source") + if run.get("ref") not in (None, source_ref): + raise ReleaseError("Prior run ref metadata does not match its accepted publication source") + return source_ref + + +def prior_workflow_inputs_match( + inputs: Any, *, event: str, version: str, old_commit: str +) -> bool: + if not isinstance(inputs, dict): + return False + if event == "release": + return ( + inputs.get("version") == "" + and inputs.get("publish") is False + and inputs.get("images") == "" + and all(inputs.get(f"{family}_version") == "" for family in ("core", "studio", "extensions")) + and inputs.get("expected_commit") == old_commit + ) + image_selection = inputs.get("images") + if not isinstance(image_selection, str): + return False + try: + selects_all_images = parse_image_selection(image_selection) == list(IMAGES) + except ReleaseError: + return False + return ( + inputs.get("version") == version + and inputs.get("publish") is True + and selects_all_images + and inputs.get("expected_commit") == old_commit + and all(inputs.get(f"{family}_version") == version for family in ("core", "studio", "extensions")) + ) + + +def validate_superseded_receipt( + receipt: Any, + *, + version: str, + package_versions: dict[str, str], + repository: str, + run: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]: + if not isinstance(receipt, dict): + raise ReleaseError("Prior receipt is not a JSON object") + old_commit = str(run.get("head_sha", "")) + run_id = str(run.get("id", "")) + run_attempt = run.get("run_attempt") + expected_workflow = ".github/workflows/container-images.yml" + source = receipt.get("appsSource") + prior_run = receipt.get("workflowRun") + inputs = receipt.get("workflowInputs") + expected_source_ref = prior_publication_source_ref(run, version) + prior_event = run.get("event") + if ( + not re.fullmatch(r"[0-9a-f]{40}", old_commit) + or receipt.get("schemaVersion") != 1 + or receipt.get("releaseVersion") != version + or receipt.get("publication") != "published" + or receipt.get("appsRepository") != repository + or receipt.get("appsSourceCommit") != old_commit + or not isinstance(source, dict) + or source.get("commit") != old_commit + or source.get("ref") != expected_source_ref + or receipt.get("packageVersions") != package_versions + or not isinstance(prior_run, dict) + or prior_run.get("repository") != repository + or str(prior_run.get("id", "")) != run_id + or prior_run.get("runAttempt") != run_attempt + or prior_run.get("workflow") != expected_workflow + or prior_run.get("url") != run.get("html_url") + or prior_run.get("event") != prior_event + or prior_run.get("ref") != expected_source_ref + or prior_run.get("headSha") != old_commit + or prior_run.get("conclusion") != "success" + or not prior_workflow_inputs_match( + inputs, event=str(prior_event), version=version, old_commit=old_commit + ) + ): + raise ReleaseError("Prior receipt provenance does not match the successful full-version publication") + expected_targets: dict[str, tuple[dict[str, Any], str | None]] = {} + for image in IMAGES: + expected_targets[image_reference(image["repository"], version)] = (image, None) + for alias in image.get("aliases", []): + expected_targets[image_reference(alias["repository"], version)] = (image, alias["name"]) + + receipt_images = receipt.get("images") + if not isinstance(receipt_images, list) or len(receipt_images) != len(expected_targets): + raise ReleaseError("Prior receipt must contain exactly the six profiles and two aliases") + by_reference: dict[str, dict[str, Any]] = {} + for entry in receipt_images: + if not isinstance(entry, dict): + raise ReleaseError("Prior receipt contains an invalid image entry") + reference = image_reference(str(entry.get("repository", "")), str(entry.get("tag", ""))) + if reference not in expected_targets or reference in by_reference: + raise ReleaseError("Prior receipt contains an unexpected or duplicate image reference") + image, alias_name = expected_targets[reference] + expected_name = alias_name or image["name"] + if ( + entry.get("name") != expected_name + or entry.get("tag") != version + or entry.get("packageVersions") != {family: version for family in image["packages"]} + or (alias_name is not None and entry.get("alias_of") != image["name"]) + or (alias_name is None and "alias_of" in entry) + ): + raise ReleaseError(f"Prior receipt image identity or package versions are invalid for {reference}") + digest = entry.get("digest") + if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise ReleaseError(f"Prior receipt has an invalid image digest for {reference}") + image_platforms = validate_receipt_platforms(entry.get("platforms"), reference) + by_reference[reference] = { + "digest": digest, + "platforms": image_platforms, + } + + for image in IMAGES: + canonical = by_reference[image_reference(image["repository"], version)] + for alias in image.get("aliases", []): + alias_entry = by_reference[image_reference(alias["repository"], version)] + if alias_entry["digest"] != canonical["digest"] or alias_entry["platforms"] != canonical["platforms"]: + raise ReleaseError(f"Prior receipt alias does not match {image['name']}") + + references = [ + {"reference": reference, "digest": entry["digest"], + "platforms": [{"platform": platform, "digest": entry["platforms"][platform]} + for platform in REQUIRED_PLATFORMS]} + for reference, entry in by_reference.items() + ] + supersedes = { + "workflowRun": { + "repository": repository, + "id": run_id, + "runAttempt": run_attempt, + "workflow": expected_workflow, + "url": run.get("html_url"), + "event": prior_event, + "ref": expected_source_ref, + "headSha": old_commit, + }, + "appsSourceCommit": old_commit, + "references": references, + } + return by_reference, supersedes + + +def github_api_json(endpoint: str) -> dict[str, Any]: + result = subprocess.run( + ["gh", "api", endpoint], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=os.environ.copy() + ) + if result.returncode: + raise ReleaseError(f"GitHub API request failed while verifying prior publication (exit {result.returncode})") + try: + value = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise ReleaseError("GitHub API returned invalid JSON while verifying prior publication") from error + if not isinstance(value, dict): + raise ReleaseError("GitHub API returned an unexpected response while verifying prior publication") + return value + + +def github_api_artifact_zip(endpoint: str) -> bytes: + result = subprocess.run( + ["gh", "api", endpoint], stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=os.environ.copy() + ) + if result.returncode: + raise ReleaseError(f"Could not download prior receipt artifact (exit {result.returncode})") + return result.stdout + + +def validate_superseded_archive( + archive: bytes, + artifact: dict[str, Any], + *, + expected_name: str, + run: dict[str, Any], + version: str, + package_versions: dict[str, str], + repository: str, +) -> tuple[dict[str, Any], dict[str, dict[str, Any]], dict[str, Any]]: + if artifact.get("name") != expected_name or artifact.get("expired") is not False: + raise ReleaseError("Prior run does not have the exact unexpired receipt artifact") + artifact_run = artifact.get("workflow_run") + if not isinstance(artifact_run, dict) or ( + str(artifact_run.get("id", "")) != str(run.get("id", "")) + or artifact_run.get("head_sha") != run.get("head_sha") + or artifact_run.get("head_branch") != run.get("head_branch") + or artifact_run.get("event") not in (None, run.get("event")) + or artifact_run.get("run_attempt") not in (None, run.get("run_attempt")) + ): + raise ReleaseError("Prior receipt artifact metadata does not match its workflow run") + size = artifact.get("size_in_bytes") + if type(size) is not int or size < 1 or size > 5 * 1024 * 1024 or len(archive) > 5 * 1024 * 1024: + raise ReleaseError("Prior receipt artifact is unexpectedly large or has invalid size metadata") + api_digest = artifact.get("digest") + if not isinstance(api_digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", api_digest): + raise ReleaseError("GitHub did not provide a valid prior receipt archive digest") + actual_digest = "sha256:" + hashlib.sha256(archive).hexdigest() + if actual_digest != api_digest: + raise ReleaseError("Downloaded prior receipt archive does not match GitHub artifact metadata") + + try: + with zipfile.ZipFile(io.BytesIO(archive)) as zipped: + members = zipped.infolist() + if len(members) != 1 or members[0].filename != "container-release-receipt.json": + raise ReleaseError("Prior receipt archive must contain only container-release-receipt.json") + if members[0].file_size > 2 * 1024 * 1024 or members[0].is_dir(): + raise ReleaseError("Prior receipt artifact has an invalid or oversized JSON member") + receipt_text = zipped.read(members[0]).decode("utf-8") + receipt = json.loads(receipt_text) + except (zipfile.BadZipFile, UnicodeDecodeError, json.JSONDecodeError, OSError) as error: + raise ReleaseError("Prior receipt artifact is not a valid single-file JSON archive") from error + + by_reference, supersedes = validate_superseded_receipt( + receipt, + version=version, + package_versions=package_versions, + repository=repository, + run=run, + ) + artifact_id = artifact.get("id") + if type(artifact_id) is not int or artifact_id < 1: + raise ReleaseError("Prior receipt artifact has an invalid GitHub artifact id") + supersedes["receiptArtifact"] = { + "id": artifact_id, + "name": expected_name, + "archiveDigest": actual_digest, + } + return receipt, by_reference, supersedes + + +def load_superseded_publication( + run_id: str, + *, + version: str, + package_versions: dict[str, str], + repository: str, + current_run_id: str, + current_commit: str, +) -> tuple[dict[str, Any], dict[str, dict[str, Any]], dict[str, Any]]: + if not re.fullmatch(r"[1-9][0-9]*", run_id) or run_id == current_run_id: + raise ReleaseError("supersede_run_id must identify a different completed publication run") + if repository != APPS_REPOSITORY: + raise ReleaseError("Corrections are supported only in the canonical Apps repository") + run = github_api_json(f"repos/{repository}/actions/runs/{run_id}") + workflow_path = str(run.get("path", "")).split("@", 1)[0] + source_repository = (run.get("head_repository") or {}).get("full_name") + run_repository = (run.get("repository") or {}).get("full_name") + old_commit = str(run.get("head_sha", "")) + attempt = run.get("run_attempt") + prior_source_ref = prior_publication_source_ref(run, version) + if ( + str(run.get("id", "")) != run_id + or run.get("status") != "completed" + or run.get("conclusion") != "success" + or run.get("event") not in ("release", "workflow_dispatch") + or workflow_path != ".github/workflows/container-images.yml" + or run_repository != repository + or source_repository != repository + or type(attempt) is not int + or attempt < 1 + or not re.fullmatch(r"[0-9a-f]{40}", old_commit) + ): + raise ReleaseError("supersede_run_id is not a completed successful canonical full-version publication") + if old_commit == current_commit: + raise ReleaseError("A correction requires a new source commit containing the fix") + + # The prior source must be in the corrected source's history; the candidate itself must be in main. + for ancestor, descendant, message in ( + (old_commit, current_commit, "Prior publication source is not an ancestor of the correction source"), + (current_commit, "refs/remotes/origin/main", "Correction source is not in main history"), + ): + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", ancestor, descendant], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode: + raise ReleaseError(message) + + artifacts_response = github_api_json(f"repos/{repository}/actions/runs/{run_id}/artifacts?per_page=100") + artifacts = artifacts_response.get("artifacts") + if not isinstance(artifacts, list) or artifacts_response.get("total_count") != len(artifacts): + raise ReleaseError("Could not enumerate every artifact from the prior publication run") + artifact_name = f"container-release-receipt-{version}-{run_id}-{attempt}" + matches = [artifact for artifact in artifacts if isinstance(artifact, dict) and artifact.get("name") == artifact_name] + if len(matches) != 1: + raise ReleaseError("Prior successful run must have exactly one receipt artifact with the expected name") + artifact = matches[0] + artifact_id = artifact.get("id") + if type(artifact_id) is not int or artifact_id < 1: + raise ReleaseError("Prior receipt artifact has an invalid GitHub artifact id") + archive = github_api_artifact_zip(f"repos/{repository}/actions/artifacts/{artifact_id}/zip") + receipt, _by_reference, supersedes = validate_superseded_archive( + archive, + artifact, + expected_name=artifact_name, + run=run, + version=version, + package_versions=package_versions, + repository=repository, + ) + return receipt, _by_reference, supersedes + + def receipt_metadata(args: argparse.Namespace) -> dict[str, Any]: run_url = f"https://github.com/{args.repository}/actions/runs/{args.run_id}" return { @@ -985,6 +1445,7 @@ def receipt_metadata(args: argparse.Namespace) -> dict[str, Any]: "core_version": getattr(args, "input_core_version", ""), "studio_version": getattr(args, "input_studio_version", ""), "extensions_version": getattr(args, "input_extensions_version", ""), + "supersede_run_id": getattr(args, "supersede_run_id", ""), "expected_commit": ( getattr(args, "expected_commit", "") or args.commit if args.event == "release" @@ -1048,6 +1509,7 @@ def create_parser() -> argparse.ArgumentParser: validate.add_argument("--studio-version", default="") validate.add_argument("--extensions-version", default="") validate.add_argument("--expected-commit", default="") + validate.add_argument("--supersede-run-id", default="") validate.add_argument("--ref", required=True) validate.add_argument("--commit", required=True) validate.add_argument("--release-tag", default="") @@ -1065,6 +1527,7 @@ def create_parser() -> argparse.ArgumentParser: plan.add_argument("--core-version", required=True) plan.add_argument("--studio-version", required=True) plan.add_argument("--extensions-version", required=True) + plan.add_argument("--skip-version-tag", action="store_true") plan.add_argument("--output", default=os.environ.get("GITHUB_OUTPUT")) verify = subparsers.add_parser("verify-image") @@ -1109,6 +1572,7 @@ def create_parser() -> argparse.ArgumentParser: finalize.add_argument("--input-core-version", default="") finalize.add_argument("--input-studio-version", default="") finalize.add_argument("--input-extensions-version", default="") + finalize.add_argument("--supersede-run-id", default="") finalize.add_argument("--event", required=True) finalize.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", APPS_REPOSITORY)) finalize.add_argument("--run-id", default=os.environ.get("GITHUB_RUN_ID", "local")) @@ -1135,6 +1599,7 @@ def main(argv: list[str] | None = None) -> int: "core_version": run["packageVersions"]["core"], "studio_version": run["packageVersions"]["studio"], "extensions_version": run["packageVersions"]["extensions"], + "supersede_run_id": run["supersedeRunId"], "images": ",".join(image["name"] for image in run["images"]), "matrix": run["matrix"], }, @@ -1153,7 +1618,12 @@ def main(argv: list[str] | None = None) -> int: "extensions": args.extensions_version, } plan = get_image_plan_with_packages( - args.repository, args.version, args.commit, args.source_ref, package_versions + args.repository, + args.version, + args.commit, + args.source_ref, + package_versions, + skip_version_tag=args.skip_version_tag, ) github_output( { @@ -1256,20 +1726,60 @@ def main(argv: list[str] | None = None) -> int: package_versions, args.publication, ) + supersede_run_id = str(getattr(args, "supersede_run_id", "") or "").strip() + superseded_receipt = None + supersedes = None if args.publication == "published": - images = run_docker_promotion( - fragments, - selected_images, - args.version, - args.commit, - args.source_ref, - package_versions, - ) + if supersede_run_id: + validate_supersede_request( + supersede_run_id, + args.event, + args.version, + True, + args.source_ref, + selected_images, + package_versions, + ) + if args.expected_commit != args.commit: + raise ReleaseError("Correction expected_commit must equal the checked-out source commit") + if str(args.run_id) == supersede_run_id: + raise ReleaseError("A correction cannot supersede its own workflow run") + superseded_receipt, prior_by_reference, supersedes = load_superseded_publication( + supersede_run_id, + version=args.version, + package_versions=package_versions, + repository=args.repository, + current_run_id=str(args.run_id), + current_commit=args.commit, + ) + images = run_docker_promotion( + fragments, + selected_images, + args.version, + args.commit, + args.source_ref, + package_versions, + prior_by_reference=prior_by_reference, + prior_commit=superseded_receipt["appsSourceCommit"], + prior_source_ref=superseded_receipt["appsSource"]["ref"], + prior_package_versions=superseded_receipt["packageVersions"], + ) + else: + images = run_docker_promotion( + fragments, + selected_images, + args.version, + args.commit, + args.source_ref, + package_versions, + ) registry_verified_at = dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z") else: images = build_only_images(fragments, selected_images, args.version, package_versions) registry_verified_at = None receipt = receipt_metadata(args) + if supersedes is not None: + receipt["supersedes"] = supersedes receipt["images"] = images receipt["resolvedPackages"] = aggregate_resolved_packages(images) receipt["imageEvidence"] = [ diff --git a/scripts/tests/test_container_release.py b/scripts/tests/test_container_release.py index 936fea3..009f0b4 100644 --- a/scripts/tests/test_container_release.py +++ b/scripts/tests/test_container_release.py @@ -1,7 +1,10 @@ import argparse +import hashlib +import io import json import tempfile import unittest +import zipfile from pathlib import Path from unittest.mock import Mock, patch @@ -35,6 +38,198 @@ def args(**overrides): return argparse.Namespace(**values) +def archive_receipt(receipt, artifact=None): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zipped: + zipped.writestr("container-release-receipt.json", json.dumps(receipt)) + archive = buffer.getvalue() + if artifact is not None: + artifact["size_in_bytes"] = len(archive) + artifact["digest"] = "sha256:" + hashlib.sha256(archive).hexdigest() + return archive + + +def correction_fixture(): + version = "3.9.0" + prior_commit = "2" * 40 + run_id = "424242" + attempt = 2 + package_versions = {family: version for family in ("core", "studio", "extensions")} + platforms = { + "linux/amd64": "sha256:" + "a" * 64, + "linux/arm64": "sha256:" + "b" * 64, + } + receipt_images = [] + for index, image in enumerate(release.IMAGES): + digest = "sha256:" + format(index + 4, "x") * 64 + entry = { + "name": image["name"], + "repository": image["repository"], + "tag": version, + "sourceRef": f"{image['repository']}:{version}-sha-{prior_commit}", + "digest": digest, + "platforms": [ + {"platform": platform, "digest": platform_digest} + for platform, platform_digest in platforms.items() + ], + "registryVerified": True, + "smoke": {"success": True}, + "packages": image["packages"], + "packageVersions": {family: version for family in image["packages"]}, + "resolvedPackages": {}, + } + receipt_images.append(entry) + for image in release.IMAGES: + canonical = next(entry for entry in receipt_images if entry["name"] == image["name"]) + for alias in image.get("aliases", []): + receipt_images.append({ + **canonical, + "name": alias["name"], + "repository": alias["repository"], + "alias_of": image["name"], + }) + + receipt = { + "schemaVersion": 1, + "releaseVersion": version, + "appsRepository": release.APPS_REPOSITORY, + "appsSource": {"ref": "refs/heads/main", "commit": prior_commit}, + "appsSourceCommit": prior_commit, + "workflowRun": { + "repository": release.APPS_REPOSITORY, + "id": run_id, + "runAttempt": attempt, + "workflow": ".github/workflows/container-images.yml", + "url": f"https://github.com/{release.APPS_REPOSITORY}/actions/runs/{run_id}", + "event": "workflow_dispatch", + "ref": "refs/heads/main", + "headSha": prior_commit, + "conclusion": "success", + }, + "packageVersions": package_versions, + "workflowInputs": { + "version": version, + "publish": True, + "images": "all", + "core_version": version, + "studio_version": version, + "extensions_version": version, + "expected_commit": prior_commit, + }, + "publication": "published", + "images": receipt_images, + } + archive = archive_receipt(receipt) + run = { + "id": int(run_id), + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "path": ".github/workflows/container-images.yml@refs/heads/main", + "html_url": f"https://github.com/{release.APPS_REPOSITORY}/actions/runs/{run_id}", + "head_branch": "main", + "head_repository": {"full_name": release.APPS_REPOSITORY}, + "repository": {"full_name": release.APPS_REPOSITORY}, + "head_sha": prior_commit, + "run_attempt": attempt, + } + artifact = { + "id": 812345, + "name": f"container-release-receipt-{version}-{run_id}-{attempt}", + "expired": False, + "size_in_bytes": len(archive), + "digest": "sha256:" + hashlib.sha256(archive).hexdigest(), + "workflow_run": {"id": int(run_id), "head_sha": prior_commit, "head_branch": "main"}, + } + return receipt, run, artifact, archive, package_versions + + +def set_prior_publication_provenance(receipt, run, artifact, *, event, source_ref, images="all"): + version = receipt["releaseVersion"] + old_commit = receipt["appsSourceCommit"] + head_branch = source_ref.removeprefix("refs/heads/").removeprefix("refs/tags/") + receipt["appsSource"]["ref"] = source_ref + prior_run = receipt["workflowRun"] + prior_run.update(event=event, ref=source_ref) + if event == "release": + receipt["workflowInputs"].update( + version="", + publish=False, + images="", + core_version="", + studio_version="", + extensions_version="", + expected_commit=old_commit, + ) + else: + receipt["workflowInputs"].update( + version=version, + publish=True, + images=images, + core_version=version, + studio_version=version, + extensions_version=version, + expected_commit=old_commit, + ) + + run.update( + event=event, + head_branch=head_branch, + head_ref=source_ref, + path=f".github/workflows/container-images.yml@{source_ref}", + ) + artifact["workflow_run"].update( + head_branch=head_branch, + ) + + return archive_receipt(receipt, artifact) + + +def correction_fragments(): + version = "3.9.0" + commit = "3" * 40 + platforms = { + "linux/amd64": "sha256:" + "c" * 64, + "linux/arm64": "sha256:" + "d" * 64, + } + fragments = [] + for index, image in enumerate(release.IMAGES): + fragments.append({ + "id": image["id"], + "name": image["name"], + "repository": image["repository"], + "sourceRef": f"{image['repository']}:{version}-sha-{commit}", + "digest": "sha256:" + format(index + 10, "x") * 64, + "platforms": [ + {"platform": platform, "digest": digest} + for platform, digest in platforms.items() + ], + "smoke": {"success": True}, + "resolvedPackages": {}, + }) + return fragments + + +def correction_registry(prior_by_reference, fragments, already_corrected=()): + version = "3.9.0" + registry = {} + new_refs = {} + for image, fragment in zip(release.IMAGES, fragments): + manifest = { + "digest": fragment["digest"], + "platforms": {item["platform"]: item["digest"] for item in fragment["platforms"]}, + } + registry[f"{image['repository']}@{fragment['digest']}"] = manifest + new_refs[f"{image['repository']}:{version}"] = manifest + for alias in image.get("aliases", []): + new_refs[f"{alias['repository']}:{version}"] = manifest + for reference, prior in prior_by_reference.items(): + registry[reference] = {"digest": prior["digest"], "platforms": prior["platforms"]} + for reference in already_corrected: + registry[reference] = new_refs[reference] + return registry + + class VersionAndSelectionTests(unittest.TestCase): def test_accepts_future_3x_semver_and_prereleases(self): for version in ("3.8.4", "3.9.0", "3.10.0", "3.9.1-rc.2", "3.10.0-preview.1"): @@ -58,9 +253,12 @@ def test_wasm_profiles_use_dotnet_10_bootstrap_assets(self): for profile in ("studio-wasm", "studio-wasm-standalone", "server-studio-wasm"): with self.subTest(profile=profile): image = release.parse_image_selection(profile)[0] + expected = ["/_framework/dotnet.js", "/_framework/blazor.webassembly.js"] + if profile in ("studio-wasm", "studio-wasm-standalone"): + expected.append("/Elsa.Studio.BlazorWasm.Client.styles.css") self.assertEqual( image["smokeAssets"], - ["/_framework/dotnet.js", "/_framework/blazor.webassembly.js"], + expected, ) def test_publication_source_and_expected_commit_are_pinned(self): @@ -83,6 +281,25 @@ def test_dispatch_does_not_allow_pr_publication(self): with self.assertRaisesRegex(release.ReleaseError, "Pull requests cannot publish"): release.validate_publication("pull_request", "3.9.0", True, "refs/pull/42/merge") + def test_supersede_is_full_version_published_main_dispatch_only(self): + packages = {family: "3.9.0" for family in ("core", "studio", "extensions")} + release.validate_supersede_request( + "424242", "workflow_dispatch", "3.9.0", True, "refs/heads/main", list(release.IMAGES), packages + ) + cases = ( + ("pull_request", True, "refs/heads/main", list(release.IMAGES), packages), + ("workflow_dispatch", False, "refs/heads/main", list(release.IMAGES), packages), + ("workflow_dispatch", True, "refs/heads/release", list(release.IMAGES), packages), + ("workflow_dispatch", True, "refs/heads/main", list(release.IMAGES[:1]), packages), + ("workflow_dispatch", True, "refs/heads/main", list(release.IMAGES), {**packages, "studio": "3.8.4"}), + ) + for event, publish, ref, images, package_versions in cases: + with self.subTest(event=event, publish=publish, ref=ref): + with self.assertRaises(release.ReleaseError): + release.validate_supersede_request( + "424242", event, "3.9.0", publish, ref, images, package_versions + ) + class ManifestPromotionTests(unittest.TestCase): def test_same_commit_published_from_main_is_reusable_from_matching_release_tag(self): @@ -209,6 +426,277 @@ def docker_command(command, **kwargs): any(command[:4] == ["docker", "buildx", "imagetools", "create"] for command in commands) ) + def test_correction_plan_ignores_the_existing_version_tag(self): + image = release.parse_image_selection("server")[0] + candidate_ref = f"{image['repository']}:3.9.0-sha-{COMMIT}" + manifest = {"digest": ROOT_DIGEST, "platforms": PLATFORMS} + with ( + patch.object(release, "inspect_manifest", return_value=manifest) as inspect, + patch.object(release, "verify_labels"), + ): + plan = release.get_image_plan_with_packages( + image["repository"], + "3.9.0", + COMMIT, + "refs/heads/main", + {family: "3.9.0" for family in ("core", "studio", "extensions")}, + skip_version_tag=True, + ) + self.assertEqual(plan["source_ref"], candidate_ref) + self.assertTrue(plan["reuse"]) + self.assertEqual([call.args[0] for call in inspect.call_args_list], [candidate_ref, candidate_ref]) + + +class SupersededPublicationTests(unittest.TestCase): + def assert_archive_rejected(self, run, artifact, archive, packages, message): + with self.assertRaisesRegex(release.ReleaseError, message): + release.validate_superseded_archive( + archive, + artifact, + expected_name=artifact["name"], + run=run, + version="3.9.0", + package_versions=packages, + repository=release.APPS_REPOSITORY, + ) + + def promote(self, registry, prior, receipt, packages, fragments, run_command): + with ( + patch.object(release, "inspect_manifest", side_effect=registry.get), + patch.object(release, "verify_labels"), + patch.object(release, "run_command", side_effect=run_command), + ): + return release.run_docker_promotion( + fragments, + list(release.IMAGES), + "3.9.0", + "3" * 40, + "refs/heads/main", + packages, + prior_by_reference=prior, + prior_commit=receipt["appsSourceCommit"], + prior_source_ref=receipt["appsSource"]["ref"], + prior_package_versions=receipt["packageVersions"], + ) + + def prior_publication(self): + receipt, run, artifact, archive, package_versions = correction_fixture() + _receipt, references, supersedes = release.validate_superseded_archive( + archive, + artifact, + expected_name=artifact["name"], + run=run, + version="3.9.0", + package_versions=package_versions, + repository=release.APPS_REPOSITORY, + ) + return receipt, run, artifact, archive, package_versions, references, supersedes + + def test_verified_artifact_has_exact_receipt_member_and_all_eight_refs(self): + _receipt, _run, artifact, _archive, _packages, references, supersedes = self.prior_publication() + self.assertEqual(len(references), 8) + self.assertEqual(len(supersedes["references"]), 8) + self.assertEqual(supersedes["receiptArtifact"]["id"], artifact["id"]) + self.assertEqual(supersedes["receiptArtifact"]["archiveDigest"], artifact["digest"]) + + def test_loader_accepts_release_and_matching_tag_dispatch_prior_publications(self): + for event, source_ref in ( + ("release", "refs/tags/3.9.0"), + ("workflow_dispatch", "refs/tags/3.9.0"), + ): + with self.subTest(event=event, source_ref=source_ref): + receipt, run, artifact, _archive, packages = correction_fixture() + archive = set_prior_publication_provenance( + receipt, + run, + artifact, + event=event, + source_ref=source_ref, + images=( + "server,studio-server,studio-wasm,studio-wasm-standalone,server-studio-server,server-studio-wasm" + if event == "workflow_dispatch" + else "all" + ), + ) + with ( + patch.object( + release, + "github_api_json", + side_effect=[run, {"total_count": 1, "artifacts": [artifact]}], + ), + patch.object(release, "github_api_artifact_zip", return_value=archive), + patch.object(release.subprocess, "run", return_value=Mock(returncode=0)), + ): + _loaded, references, supersedes = release.load_superseded_publication( + "424242", + version="3.9.0", + package_versions=packages, + repository=release.APPS_REPOSITORY, + current_run_id="777777", + current_commit="3" * 40, + ) + self.assertEqual(len(references), 8) + self.assertEqual(supersedes["workflowRun"]["event"], event) + self.assertEqual(supersedes["workflowRun"]["ref"], source_ref) + + def test_rejects_prior_run_from_a_different_tag(self): + receipt, run, artifact, _archive, packages = correction_fixture() + archive = set_prior_publication_provenance( + receipt, run, artifact, event="workflow_dispatch", source_ref="refs/tags/3.9.0" + ) + run["head_branch"] = "3.8.4" + artifact["workflow_run"]["head_branch"] = "3.8.4" + self.assert_archive_rejected(run, artifact, archive, packages, "matching version tag") + + def test_rejects_receipt_event_or_ref_that_disagrees_with_prior_run(self): + receipt, run, artifact, _archive, packages = correction_fixture() + set_prior_publication_provenance( + receipt, run, artifact, event="release", source_ref="refs/tags/3.9.0" + ) + receipt["workflowRun"]["event"] = "workflow_dispatch" + archive = archive_receipt(receipt, artifact) + self.assert_archive_rejected(run, artifact, archive, packages, "provenance") + + receipt["workflowRun"]["event"] = "release" + receipt["appsSource"]["ref"] = "refs/heads/main" + archive = archive_receipt(receipt, artifact) + self.assert_archive_rejected(run, artifact, archive, packages, "provenance") + + def test_rejects_archive_run_metadata_that_disagrees_with_github_run(self): + _receipt, run, artifact, archive, packages = correction_fixture() + artifact["workflow_run"]["event"] = "release" + self.assert_archive_rejected(run, artifact, archive, packages, "artifact metadata") + + def test_rejects_tampered_digest_and_extra_archive_members(self): + _receipt, run, artifact, archive, packages = correction_fixture() + with self.assertRaisesRegex(release.ReleaseError, "does not match GitHub artifact metadata"): + release.validate_superseded_archive( + archive + b"tampered", + artifact, + expected_name=artifact["name"], + run=run, + version="3.9.0", + package_versions=packages, + repository=release.APPS_REPOSITORY, + ) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zipped: + zipped.writestr("container-release-receipt.json", json.dumps(_receipt)) + zipped.writestr("unexpected.json", "{}") + extra_archive = buffer.getvalue() + artifact["digest"] = "sha256:" + hashlib.sha256(extra_archive).hexdigest() + with self.assertRaisesRegex(release.ReleaseError, "only container-release-receipt.json"): + release.validate_superseded_archive( + extra_archive, + artifact, + expected_name=artifact["name"], + run=run, + version="3.9.0", + package_versions=packages, + repository=release.APPS_REPOSITORY, + ) + + def test_loader_checks_canonical_successful_run_and_downloads_exact_artifact(self): + _receipt, run, artifact, archive, packages = correction_fixture() + current_commit = "3" * 40 + with ( + patch.object(release, "github_api_json", side_effect=[run, {"total_count": 1, "artifacts": [artifact]}]) as api, + patch.object(release, "github_api_artifact_zip", return_value=archive) as download, + patch.object(release.subprocess, "run", return_value=Mock(returncode=0)), + ): + _loaded, references, supersedes = release.load_superseded_publication( + "424242", + version="3.9.0", + package_versions=packages, + repository=release.APPS_REPOSITORY, + current_run_id="777777", + current_commit=current_commit, + ) + self.assertEqual(len(references), 8) + self.assertEqual(supersedes["appsSourceCommit"], run["head_sha"]) + self.assertEqual(api.call_args_list[0].args[0], "repos/elsa-workflows/elsa-apps/actions/runs/424242") + self.assertEqual( + api.call_args_list[1].args[0], + "repos/elsa-workflows/elsa-apps/actions/runs/424242/artifacts?per_page=100", + ) + download.assert_called_once_with("repos/elsa-workflows/elsa-apps/actions/artifacts/812345/zip") + + def test_loader_rejects_same_source_before_downloading_or_promoting(self): + _receipt, run, _artifact, _archive, packages = correction_fixture() + with ( + patch.object(release, "github_api_json", return_value=run) as api, + patch.object(release, "github_api_artifact_zip") as download, + patch.object(release.subprocess, "run") as command, + ): + with self.assertRaisesRegex(release.ReleaseError, "requires a new source commit"): + release.load_superseded_publication( + "424242", version="3.9.0", package_versions=packages, + repository=release.APPS_REPOSITORY, current_run_id="777777", + current_commit=run["head_sha"], + ) + api.assert_called_once() + download.assert_not_called() + command.assert_not_called() + + def test_correction_retry_promotes_prior_refs_and_leaves_latest_tags_out(self): + receipt, _run, _artifact, _archive, packages, prior, _supersedes = self.prior_publication() + fragments = correction_fragments() + corrected_ref = "elsaworkflows/elsa-server:3.9.0" + registry = correction_registry(prior, fragments, already_corrected=(corrected_ref,)) + created = [] + copied_sources = [] + + def create_tag(command, **_kwargs): + if command[:4] == ["docker", "buildx", "imagetools", "create"]: + target, source = command[5], command[6] + created.append(target) + copied_sources.append(source) + registry[target] = registry[source] + return None + + promoted = self.promote(registry, prior, receipt, packages, fragments, create_tag) + + self.assertEqual(len(promoted), 8) + self.assertEqual(len(created), 7) + self.assertNotIn(corrected_ref, created) + self.assertEqual(set(created), set(prior) - {corrected_ref}) + self.assertTrue(all("@sha256:" in source for source in copied_sources)) + + created.clear() + retry = self.promote(registry, prior, receipt, packages, fragments, create_tag) + self.assertEqual(len(retry), 8) + self.assertEqual(created, []) + + def test_drift_or_missing_tag_blocks_all_version_writes(self): + receipt, _run, _artifact, _archive, packages, prior, _supersedes = self.prior_publication() + fragments = correction_fragments() + affected_ref = "elsaworkflows/elsa-studio:3.9.0" + + for state in ("drift", "missing"): + with self.subTest(state=state): + registry = correction_registry(prior, fragments) + if state == "drift": + registry[affected_ref] = { + "digest": "sha256:" + "f" * 64, + "platforms": {"linux/amd64": "sha256:" + "e" * 64, "linux/arm64": "sha256:" + "d" * 64}, + } + else: + del registry[affected_ref] + create_tag = Mock() + with self.assertRaises(release.ReleaseError): + self.promote( + registry, + prior, + receipt, + packages, + fragments, + create_tag, + ) + self.assertFalse( + any(call.args[0][:4] == ["docker", "buildx", "imagetools", "create"] for call in create_tag.call_args_list) + ) + def server_fragment(): image = release.parse_image_selection("server")[0] @@ -224,7 +712,16 @@ def server_fragment(): "httpStatus": 200, "browserAssets": [], "identityLogin": {"status": 200, "endpoint": "/elsa/api/identity/login"}, - "bearerApi": {"status": 200, "endpoint": "/elsa/api/workflow-definitions?page=0&pageSize=1"}, + "bearerApi": { + "status": 200, + "endpoint": release.BEARER_API_ENDPOINT, + "contentType": "application/json; charset=utf-8", + }, + "dashboardApi": { + "status": 200, + "endpoint": release.DASHBOARD_API_ENDPOINT, + "contentType": "application/json; charset=utf-8", + }, } for platform, digest in PLATFORMS.items() ] @@ -243,6 +740,47 @@ def server_fragment(): } +def standalone_fragment(): + image = release.parse_image_selection("studio-wasm-standalone")[0] + assets = [ + { + "path": path, + "status": 200, + "bytes": 775 if path.endswith(".css") else 500, + "contentType": "text/css; charset=utf-8" if path.endswith(".css") else "application/javascript", + } + for path in image["smokeAssets"] + ] + platforms = [ + {"platform": platform, "digest": digest} + for platform, digest in PLATFORMS.items() + ] + smoke_platforms = [ + { + "platform": platform, + "imageDigest": digest, + "status": "success", + "httpStatus": 200, + "browserAssets": assets, + } + for platform, digest in PLATFORMS.items() + ] + return { + "id": image["id"], + "name": image["name"], + "repository": image["repository"], + "sourceRef": f"{image['repository']}:3.9.0-sha-{COMMIT}", + "digest": ROOT_DIGEST, + "platforms": platforms, + "smoke": {"success": True, "imageDigest": ROOT_DIGEST, "platforms": smoke_platforms}, + "resolvedPackages": { + "core": [{"id": "Elsa", "version": "3.9.0"}], + "studio": [{"id": "Elsa.Studio", "version": "3.9.0"}], + "extensions": [{"id": "Elsa.Logging", "version": "3.9.0"}], + }, + } + + class ImageArtifactSelectionTests(unittest.TestCase): def test_newest_attempt_wins_independent_of_download_order_and_retains_earlier_success(self): with tempfile.TemporaryDirectory() as temporary: @@ -294,16 +832,16 @@ def test_fragment_profile_must_match_artifact_profile(self): class FinalizeEvidenceRejectionTests(unittest.TestCase): - def run_finalize_with_fragment(self, fragment): + def run_finalize_with_fragment(self, fragment, profile="server"): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) root = Path(temporary.name) - artifact_dir = root / f"container-image-server-12345-1" + artifact_dir = root / f"container-image-{profile}-12345-1" artifact_dir.mkdir() - (artifact_dir / "server.json").write_text(json.dumps(fragment), encoding="utf-8") + (artifact_dir / f"{profile}.json").write_text(json.dumps(fragment), encoding="utf-8") command = [ "finalize", "--publication", "published", "--version", "3.9.0", - "--commit", COMMIT, "--source-ref", "refs/heads/main", "--images", "server", + "--commit", COMMIT, "--source-ref", "refs/heads/main", "--images", profile, "--core-version", "3.9.0", "--studio-version", "3.9.0", "--extensions-version", "3.9.0", "--event", "workflow_dispatch", "--repository", "elsa-workflows/elsa-apps", "--run-id", "12345", "--run-attempt", "2", "--expected-commit", COMMIT, @@ -328,6 +866,23 @@ def test_mismatched_resolved_package_is_rejected_before_any_tag_creation(self): fragment["resolvedPackages"]["core"][0]["version"] = "3.8.4" self.run_finalize_with_fragment(fragment) + def test_dashboard_html_fallback_is_rejected_before_any_tag_creation(self): + fragment = server_fragment() + fragment["smoke"]["platforms"][0]["dashboardApi"]["contentType"] = "text/html" + self.run_finalize_with_fragment(fragment) + + def test_missing_standalone_stylesheet_is_rejected_before_any_tag_creation(self): + fragment = standalone_fragment() + for row in fragment["smoke"]["platforms"]: + row["browserAssets"] = [asset for asset in row["browserAssets"] if not asset["path"].endswith(".css")] + self.run_finalize_with_fragment(fragment, "studio-wasm-standalone") + + def test_empty_standalone_stylesheet_is_rejected_before_any_tag_creation(self): + fragment = standalone_fragment() + for row in fragment["smoke"]["platforms"]: + next(asset for asset in row["browserAssets"] if asset["path"].endswith(".css"))["bytes"] = 99 + self.run_finalize_with_fragment(fragment, "studio-wasm-standalone") + def test_receipt_records_selected_earlier_evidence_attempt(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -389,6 +944,22 @@ def test_rejects_html_fallback_for_missing_javascript_asset(self): with self.assertRaisesRegex(release.ReleaseError, "HTML fallback page"): release.verify_smoke_assets("http://127.0.0.1:1234", ["/_framework/dotnet.js"]) + def test_verifies_standalone_stylesheet_content_type(self): + css = self.Response(b".studio { color: #fff; }".ljust(100, b" "), "text/css; charset=utf-8") + with patch.object(release.urllib.request, "urlopen", return_value=css): + verified = release.verify_smoke_assets( + "http://127.0.0.1:1234", ["/Elsa.Studio.BlazorWasm.Client.styles.css"] + ) + self.assertEqual(verified[0]["contentType"], "text/css; charset=utf-8") + + def test_rejects_empty_stylesheet(self): + css = self.Response(b"", "text/css; charset=utf-8") + with patch.object(release.urllib.request, "urlopen", return_value=css): + with self.assertRaisesRegex(release.ReleaseError, "only 0 bytes"): + release.verify_smoke_assets( + "http://127.0.0.1:1234", ["/Elsa.Studio.BlazorWasm.Client.styles.css"] + ) + class AuthenticatedApiSmokeTests(unittest.TestCase): class Response: @@ -410,11 +981,12 @@ def read(self): def test_uses_bearer_protected_workflow_definitions_api_and_validates_paged_json(self): login = self.Response(b'{"accessToken":"synthetic-token"}') definitions = self.Response(b'{"items":[],"totalCount":0}') + dashboard = self.Response(b'{"runtime":{},"workflowInstances":{}}') requests = [] def open_url(request, timeout): requests.append(request) - return (login, definitions)[len(requests) - 1] + return (login, definitions, dashboard)[len(requests) - 1] with patch.object(release.urllib.request, "urlopen", side_effect=open_url): result = release.login_and_probe_api("http://127.0.0.1:1234/", "smoke", "secret") @@ -427,7 +999,13 @@ def open_url(request, timeout): ) self.assertEqual(requests[1].get_method(), "GET") self.assertEqual(requests[1].get_header("Authorization"), "Bearer synthetic-token") + self.assertEqual( + requests[2].full_url, + "http://127.0.0.1:1234/elsa/api/dashboard/overview?range=24h&includeSystem=false", + ) + self.assertEqual(requests[2].get_header("Authorization"), "Bearer synthetic-token") self.assertEqual(result["bearerApi"]["status"], 200) + self.assertEqual(result["dashboardApi"]["status"], 200) def test_rejects_html_fallback_even_when_it_returns_http_200(self): login = self.Response(b'{"accessToken":"synthetic-token"}') @@ -443,6 +1021,22 @@ def test_rejects_non_paged_json_response(self): with self.assertRaisesRegex(release.ReleaseError, "paged JSON response"): release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") + def test_rejects_dashboard_html_fallback_even_when_it_returns_http_200(self): + login = self.Response(b'{"accessToken":"synthetic-token"}') + definitions = self.Response(b'{"items":[],"totalCount":0}') + fallback = self.Response(b'Studio', "text/html; charset=utf-8") + with patch.object(release.urllib.request, "urlopen", side_effect=[login, definitions, fallback]): + with self.assertRaisesRegex(release.ReleaseError, "dashboard/overview.*invalid JSON"): + release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") + + def test_rejects_dashboard_json_without_dashboard_overview_shape(self): + login = self.Response(b'{"accessToken":"synthetic-token"}') + definitions = self.Response(b'{"items":[],"totalCount":0}') + invalid = self.Response(b'{"message":"not the dashboard overview"}') + with patch.object(release.urllib.request, "urlopen", side_effect=[login, definitions, invalid]): + with self.assertRaisesRegex(release.ReleaseError, "dashboard/overview.*unexpected JSON object"): + release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") + def test_login_and_api_timeouts_name_the_failed_endpoint(self): with patch.object(release.urllib.request, "urlopen", side_effect=TimeoutError("timed out")): with self.assertRaisesRegex(release.ReleaseError, "identity/login failed"): @@ -450,7 +1044,7 @@ def test_login_and_api_timeouts_name_the_failed_endpoint(self): login = self.Response(b'{"accessToken":"synthetic-token"}') with patch.object(release.urllib.request, "urlopen", side_effect=[login, TimeoutError("timed out")]): - with self.assertRaisesRegex(release.ReleaseError, "workflow definitions API request.*failed"): + with self.assertRaisesRegex(release.ReleaseError, "workflow-definitions.*failed"): release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") def test_asset_timeout_names_the_failed_asset(self): diff --git a/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj b/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj index a6b87ee..76b24c6 100644 --- a/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj +++ b/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Elsa.Server.Studio.BlazorServer/Program.cs b/src/Elsa.Server.Studio.BlazorServer/Program.cs index ea22795..e98f11e 100644 --- a/src/Elsa.Server.Studio.BlazorServer/Program.cs +++ b/src/Elsa.Server.Studio.BlazorServer/Program.cs @@ -46,6 +46,7 @@ .UseLiquid() .UseHttp(http => http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options)) .UseWorkflowsApi() + .UseDashboardApi() .UseLoggingFramework() .UseAgentActivities() .UseAgentPersistence(persistence => persistence.UseEntityFrameworkCore(ef => ConfigureEntityFrameworkCoreForAgents(ef, configuration))) @@ -85,7 +86,7 @@ services.AddShell(); services.AddRemoteBackend(backendApiConfig); services.AddLoginModule().UseElsaIdentity(); -services.AddDashboardModule(); +services.AddDashboardModule(backendApiConfig); services.AddWorkflowsModule(); services.AddWebhooksModule(); services.AddAgentsModule(backendApiConfig); diff --git a/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs b/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs index 7704f63..b42db46 100644 --- a/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs +++ b/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs @@ -40,7 +40,7 @@ builder.Services.AddShell(); builder.Services.AddRemoteBackend(backendApiConfig); builder.Services.AddLoginModule().UseElsaIdentity(); -builder.Services.AddDashboardModule(); +builder.Services.AddDashboardModule(backendApiConfig); builder.Services.AddWorkflowsModule(); builder.Services.AddWorkflowContextsModule(); builder.Services.AddScoped(); @@ -48,4 +48,4 @@ var app = builder.Build(); var startupTaskRunner = app.Services.GetRequiredService(); await startupTaskRunner.RunStartupTasksAsync(); -await app.RunAsync(); \ No newline at end of file +await app.RunAsync(); diff --git a/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj b/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj index fc6d485..21b6bab 100644 --- a/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj +++ b/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Elsa.Server.Studio.BlazorWasm/Program.cs b/src/Elsa.Server.Studio.BlazorWasm/Program.cs index 1e77135..131f19c 100644 --- a/src/Elsa.Server.Studio.BlazorWasm/Program.cs +++ b/src/Elsa.Server.Studio.BlazorWasm/Program.cs @@ -26,6 +26,7 @@ .UseLiquid() .UseHttp(http => http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options)) .UseWorkflowsApi() + .UseDashboardApi() .UseAgentActivities() .UseAgentPersistence(persistence => persistence.UseEntityFrameworkCore(ef => ConfigureEntityFrameworkCoreForAgents(ef, configuration))) .UseAgentsApi() diff --git a/src/Elsa.Server/Elsa.Server.csproj b/src/Elsa.Server/Elsa.Server.csproj index 507a7c0..61eede5 100644 --- a/src/Elsa.Server/Elsa.Server.csproj +++ b/src/Elsa.Server/Elsa.Server.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Elsa.Server/Program.cs b/src/Elsa.Server/Program.cs index a4c6c1c..858c48e 100644 --- a/src/Elsa.Server/Program.cs +++ b/src/Elsa.Server/Program.cs @@ -42,6 +42,7 @@ }) .UseDefaultAuthentication(auth => auth.UseAdminApiKey()) .UseWorkflowsApi() + .UseDashboardApi() .UseCSharp() .UseJavaScript(options => options.AllowClrAccess = true) .UseHttp(options => options.ConfigureHttpOptions = httpOptions => configuration.Bind("Http", httpOptions)) diff --git a/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs b/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs index 1b5f82e..d2a8880 100644 --- a/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs +++ b/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs @@ -19,7 +19,7 @@ public static IServiceCollection AddModules(this IServiceCollection services, IC services.AddAgentsModule(backendApiConfig); if(modulesOptions.Features.Dashboard) - services.AddDashboardModule(); + services.AddDashboardModule(backendApiConfig); if (modulesOptions.Features.Webhooks) services.AddWebhooksModule(); diff --git a/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs b/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs index 2244c7c..3b5b319 100644 --- a/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs +++ b/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs @@ -19,7 +19,7 @@ public static IServiceCollection AddModules(this IServiceCollection services, IC services.AddAgentsModule(backendApiConfig); if(modulesOptions.Features.Dashboard) - services.AddDashboardModule(); + services.AddDashboardModule(backendApiConfig); if (modulesOptions.Features.Webhooks) services.AddWebhooksModule(); diff --git a/src/Elsa.Studio.BlazorWasm.Client/wwwroot/index.html b/src/Elsa.Studio.BlazorWasm.Client/wwwroot/index.html index a9112ce..bc38c2e 100644 --- a/src/Elsa.Studio.BlazorWasm.Client/wwwroot/index.html +++ b/src/Elsa.Studio.BlazorWasm.Client/wwwroot/index.html @@ -20,7 +20,7 @@ - + @@ -44,4 +44,4 @@
Loading... - \ No newline at end of file + diff --git a/src/Elsa.Studio.BlazorWasm/Pages/_Host.cshtml b/src/Elsa.Studio.BlazorWasm/Pages/_Host.cshtml index f10ed82..5782379 100644 --- a/src/Elsa.Studio.BlazorWasm/Pages/_Host.cshtml +++ b/src/Elsa.Studio.BlazorWasm/Pages/_Host.cshtml @@ -29,7 +29,7 @@ - + @@ -58,4 +58,4 @@ - \ No newline at end of file +