From 5358d30774a8a8fe80f81c25df41e2ffb98be823 Mon Sep 17 00:00:00 2001 From: mrpicodevsec Date: Tue, 11 Aug 2026 17:01:33 -0700 Subject: [PATCH 1/4] fix(release): resume drafts and normalize resolve-release outputs (FDE-714) The public distribution repository was running a stale export of the release workflow, and release artifacts were never attached to the v0.1.0 draft. Two defects were responsible. First, the resolve-release job derived its job outputs through `steps.passthrough.outputs.* || steps.derive.outputs.*`. On the public repository the passthrough step is skipped, so `release_created` never propagated, and the build-candidate and promote-release jobs were skipped on every dispatch. A dedicated normalization step now selects the derived or passthrough identity in shell based on the repository, so the job outputs are reliable. Second, the release lookup used the by-tag endpoint, which excludes draft releases, so each dispatch created a new empty draft instead of resuming the existing one. The new scripts/release_resolution.py lists all releases, including drafts, and selects a single safe action: create the tag and draft, create the draft on an existing tag, resume the one unpublished draft, or treat a published release as nothing to do. The release policy adds uploads.github.com to the fetch allowlist so the promotion job can upload artifacts. This ports the internal fix from FDE-714 (PR #106) verbatim for the release workflow, resolver, and its test. The release-policy change is scoped to the single allowlist entry so it does not touch unrelated operator-app assets. --- .github/workflows/documentation.yml | 2 +- .github/workflows/release.yml | 327 +++++++++++++++++-------- .github/workflows/verify.yml | 4 +- scripts/release-policy.json | 1 + scripts/release_resolution.py | 109 +++++++++ tests/release/test_release_workflow.py | 148 +++++++++++ 6 files changed, 485 insertions(+), 106 deletions(-) create mode 100644 scripts/release_resolution.py create mode 100644 tests/release/test_release_workflow.py diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index d7f690e..9eeb06b 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -16,7 +16,7 @@ concurrency: jobs: site: name: Documentation links, language, and browser accessibility - runs-on: ${{ vars.HEAVY_RUNNER || 'ubuntu-latest' }} + runs-on: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER || 'ubuntu-latest' }} timeout-minutes: 20 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c938e6f..9aeece9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,9 +53,9 @@ jobs: # unchanged. In the public distribution repository, where Release Please is # deliberately disabled, it derives the same identity from the merged and # equivalence-verified tree: the version is read from pyproject.toml, the tag is - # that version prefixed with "v", and a draft release is created only when the - # tag does not exist yet. A merge that does not change the version resolves to an - # existing tag and the pipeline stops without output. + # that version prefixed with "v", and release state is resolved by immutable tag + # and numeric release ID. A merged tree that resolves to an already published + # release stops without rebuilding or publishing. resolve-release: name: Resolve the release candidate identity if: >- @@ -68,18 +68,17 @@ jobs: permissions: contents: write outputs: - body: ${{ steps.passthrough.outputs.body || steps.derive.outputs.body }} - html_url: ${{ steps.passthrough.outputs.html_url || steps.derive.outputs.html_url }} - release_created: ${{ steps.passthrough.outputs.release_created || steps.derive.outputs.release_created }} - sha: ${{ steps.passthrough.outputs.sha || steps.derive.outputs.sha }} - tag_name: ${{ steps.passthrough.outputs.tag_name || steps.derive.outputs.tag_name }} - version: ${{ steps.passthrough.outputs.version || steps.derive.outputs.version }} + html_url: ${{ steps.resolved.outputs.html_url }} + release_created: ${{ steps.resolved.outputs.release_created }} + release_id: ${{ steps.resolved.outputs.release_id }} + sha: ${{ steps.resolved.outputs.sha }} + tag_name: ${{ steps.resolved.outputs.tag_name }} + version: ${{ steps.resolved.outputs.version }} steps: - name: Pass through the private Release Please outputs id: passthrough if: github.repository != 'picogrid/ecn-sdk-python' env: - RELEASE_BODY: ${{ needs.release-please.outputs.body }} RELEASE_HTML_URL: ${{ needs.release-please.outputs.html_url }} RELEASE_CREATED: ${{ needs.release-please.outputs.release_created }} RELEASE_SHA: ${{ needs.release-please.outputs.sha }} @@ -87,9 +86,6 @@ jobs: RELEASE_VERSION: ${{ needs.release-please.outputs.version }} run: | { - echo "body< tag-check.err)"; then tag_sha="$(printf '%s' "$ref_json" | jq -r '.object.sha')" - # An annotated tag ref points at a tag object; dereference it to - # the commit before comparing against the merged commit. if [ "$(printf '%s' "$ref_json" | jq -r '.object.type')" = "tag" ]; then tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_sha}" --jq '.object.sha')" fi - # The tag exists. A missing release means an earlier run created - # the ref but failed before the draft; recover by creating the - # draft on this exact ref. An unpublished draft means an earlier - # run failed before handing off its identity; resume it. Either - # recovery requires the tag to be bound to this exact merged - # commit. A published release means there is nothing to do for - # this merge, and any other lookup failure is an unknown state - # that fails the job. - if release_response="$( - gh api --include \ - "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" \ - --jq '.draft' \ - 2> release-check.err - )"; then - is_draft="$(printf '%s\n' "$release_response" | sed -n '$p')" - if [ "$is_draft" = "true" ]; then - test "$tag_sha" = "$GITHUB_SHA" || { - echo "draft tag ${tag} targets ${tag_sha}, not ${GITHUB_SHA}; refusing to resume" - exit 1 - } - echo "tag ${tag} carries an unpublished draft; resuming it" - html_url="$(gh release view "$tag" --json url --jq '.url')" - release_created=true - elif [ "$is_draft" = "false" ]; then - echo "tag ${tag} already exists; nothing to release for this merge" - echo "release_created=false" >> "$GITHUB_OUTPUT" - exit 0 - else - echo "release lookup returned an invalid draft state" - exit 1 - fi - else - release_http_status="$( - printf '%s\n' "$release_response" | - sed -n 's/^HTTP\/[^ ]* \([0-9][0-9][0-9]\).*/\1/p' | - sed -n '$p' - )" - test "$release_http_status" = "404" || { - cat release-check.err - exit 1 - } - test "$tag_sha" = "$GITHUB_SHA" || { - echo "tag ${tag} targets ${tag_sha}, not ${GITHUB_SHA}; refusing to create a release for it" + + # A recovery workflow may be newer than the immutable release tag, + # but it may never release an unrelated or future commit. + comparison="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/compare/${tag_sha}...${GITHUB_SHA}" \ + --jq '.status' + )" + case "$comparison" in + ahead|identical) ;; + *) + echo "tag ${tag} at ${tag_sha} is not an ancestor of ${GITHUB_SHA}" exit 1 - } - echo "tag ${tag} exists without a release; creating the draft on it" - html_url="$(gh release create "$tag" \ - --verify-tag \ - --draft \ - --title "$tag" \ - --notes "See CHANGELOG.md at this tag for the release history.")" - release_created=true - fi + ;; + esac else grep -q "HTTP 404" tag-check.err || { cat tag-check.err; exit 1; } - # Create the tag ref explicitly at the merged commit before the - # release. A concurrent writer claiming the tag makes this call - # fail instead of silently retargeting the release, and - # --verify-tag pins the release to the ref just created. - gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="refs/tags/${tag}" \ - -f sha="$GITHUB_SHA" > /dev/null - html_url="$(gh release create "$tag" \ - --verify-tag \ - --draft \ - --title "$tag" \ - --notes "See CHANGELOG.md at this tag for the release history.")" - release_created=true fi + + # The release-by-tag endpoint excludes drafts. List all releases so a + # retry resumes exactly one draft instead of creating another one. + gh api \ + --paginate \ + --slurp \ + "repos/${GITHUB_REPOSITORY}/releases?per_page=100" | + jq 'add' > releases.json + python3 scripts/release_resolution.py \ + --tag "$tag" \ + --current-sha "$GITHUB_SHA" \ + --tag-sha "$tag_sha" \ + --releases-json releases.json > resolution.json + + action="$(jq -r '.action' resolution.json)" + html_url="$(jq -r '.html_url' resolution.json)" + release_created="$(jq -r '.release_created' resolution.json)" + release_id="$(jq -r '.release_id // empty' resolution.json)" + release_sha="$(jq -r '.release_sha' resolution.json)" + case "$action" in + create-tag-and-draft|create-draft) + if [ "$action" = "create-tag-and-draft" ]; then + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${tag}" \ + -f sha="$release_sha" > /dev/null + fi + release_json="$( + gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/releases" \ + -f tag_name="$tag" \ + -f target_commitish="$release_sha" \ + -f name="$tag" \ + -f body="See CHANGELOG.md at this tag for the release history." \ + -F draft=true + )" + release_id="$(printf '%s' "$release_json" | jq -r '.id')" + html_url="$(printf '%s' "$release_json" | jq -r '.html_url')" + ;; + resume) + echo "tag ${tag} carries one unpublished draft; resuming release ${release_id}" + ;; + published) + echo "tag ${tag} is already published; nothing to release" + ;; + *) + echo "release resolver returned unsupported action: ${action}" + exit 1 + ;; + esac + test -n "$html_url" + echo "$release_id" | grep -Eq '^[1-9][0-9]*$' { - echo "body=" echo "html_url=$html_url" echo "release_created=$release_created" - echo "sha=$GITHUB_SHA" + echo "release_id=$release_id" + echo "sha=$release_sha" echo "tag_name=$tag" echo "version=$version" } >> "$GITHUB_OUTPUT" + - name: Normalize the release candidate outputs + id: resolved + env: + DERIVED_HTML_URL: ${{ steps.derive.outputs.html_url }} + DERIVED_RELEASE_CREATED: ${{ steps.derive.outputs.release_created }} + DERIVED_RELEASE_ID: ${{ steps.derive.outputs.release_id }} + DERIVED_SHA: ${{ steps.derive.outputs.sha }} + DERIVED_TAG_NAME: ${{ steps.derive.outputs.tag_name }} + DERIVED_VERSION: ${{ steps.derive.outputs.version }} + PASSTHROUGH_HTML_URL: ${{ steps.passthrough.outputs.html_url }} + PASSTHROUGH_RELEASE_CREATED: ${{ steps.passthrough.outputs.release_created }} + PASSTHROUGH_SHA: ${{ steps.passthrough.outputs.sha }} + PASSTHROUGH_TAG_NAME: ${{ steps.passthrough.outputs.tag_name }} + PASSTHROUGH_VERSION: ${{ steps.passthrough.outputs.version }} + run: | + if [ "$GITHUB_REPOSITORY" = "picogrid/ecn-sdk-python" ]; then + html_url="$DERIVED_HTML_URL" + release_created="$DERIVED_RELEASE_CREATED" + release_id="$DERIVED_RELEASE_ID" + sha="$DERIVED_SHA" + tag_name="$DERIVED_TAG_NAME" + version="$DERIVED_VERSION" + else + html_url="$PASSTHROUGH_HTML_URL" + release_created="$PASSTHROUGH_RELEASE_CREATED" + release_id= + sha="$PASSTHROUGH_SHA" + tag_name="$PASSTHROUGH_TAG_NAME" + version="$PASSTHROUGH_VERSION" + fi + case "$release_created" in + true) + test -n "$html_url" + test -n "$sha" + test -n "$tag_name" + test -n "$version" + if [ "$GITHUB_REPOSITORY" = "picogrid/ecn-sdk-python" ]; then + echo "$release_id" | grep -Eq '^[1-9][0-9]*$' + fi + ;; + false|'') ;; + *) + echo "invalid release_created output: ${release_created}" + exit 1 + ;; + esac + { + echo "html_url=$html_url" + echo "release_created=${release_created:-false}" + echo "release_id=$release_id" + echo "sha=$sha" + echo "tag_name=$tag_name" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + build-candidate: name: Build and verify the release candidate once - if: needs.resolve-release.outputs.release_created == 'true' + if: fromJSON(needs.resolve-release.outputs.release_created || 'false') needs: resolve-release - runs-on: ${{ vars.HEAVY_RUNNER || 'ubuntu-latest' }} + runs-on: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER || 'ubuntu-latest' }} timeout-minutes: 90 permissions: contents: read env: - PLAYWRIGHT_WORKERS: "4" - VERIFY_RELEASE_JOBS: ${{ vars.HEAVY_RUNNER && '4' || '1' }} + PLAYWRIGHT_WORKERS: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER && '4' || '1' }} + VERIFY_RELEASE_JOBS: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER && '4' || '1' }} LEGION_DOCS_URL: ${{ vars.LEGION_DOCS_URL }} LEGION_DOCS_VERSION: ${{ vars.LEGION_DOCS_VERSION }} RELEASE_TAG: ${{ needs.resolve-release.outputs.tag_name }} @@ -289,7 +341,7 @@ jobs: publication-reachability: name: Require anonymous publication reachability if: >- - needs.resolve-release.outputs.release_created == 'true' && + fromJSON(needs.resolve-release.outputs.release_created || 'false') && vars.RELEASE_PROMOTION_ENABLED == 'true' && github.repository == 'picogrid/ecn-sdk-python' needs: @@ -336,7 +388,7 @@ jobs: promote-release: name: Attest, sign, and publish the exact candidate if: >- - needs.resolve-release.outputs.release_created == 'true' && + fromJSON(needs.resolve-release.outputs.release_created || 'false') && vars.RELEASE_PROMOTION_ENABLED == 'true' && github.repository == 'picogrid/ecn-sdk-python' needs: @@ -423,41 +475,110 @@ jobs: candidate/dist/picogrid_ecn_client-*.tar.gz release-signing-artifacts: false - - name: Require the unpublished draft created by this workflow + - name: Require the resolved unpublished draft env: GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ needs.resolve-release.outputs.release_id }} RELEASE_TAG: ${{ needs.resolve-release.outputs.tag_name }} run: | test "$(find candidate/dist -maxdepth 1 -type f -name '*.sigstore.json' | wc -l)" -eq 3 test -f candidate/dist/picogrid_ecn_client-*.whl.sigstore.json test -f candidate/dist/picogrid_ecn_operator_app-*.whl.sigstore.json test -f candidate/dist/picogrid_ecn_client-*.tar.gz.sigstore.json - test "$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" = "true" + echo "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' + release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}")" + test "$(printf '%s' "$release_json" | jq -r '.draft')" = "true" + test "$(printf '%s' "$release_json" | jq -r '.tag_name')" = "$RELEASE_TAG" - - name: Attach exact artifacts, signatures, and sanitized evidence + - name: Replace exact draft assets without retaining stale files env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.resolve-release.outputs.tag_name }} - run: >- - gh release upload "$RELEASE_TAG" - candidate/dist/picogrid_ecn_client-*.whl - candidate/dist/picogrid_ecn_operator_app-*.whl - candidate/dist/picogrid_ecn_client-*.tar.gz - candidate/dist/picogrid_ecn_client-*.whl.sigstore.json - candidate/dist/picogrid_ecn_operator_app-*.whl.sigstore.json - candidate/dist/picogrid_ecn_client-*.tar.gz.sigstore.json - candidate/reports/generated/* + RELEASE_ID: ${{ needs.resolve-release.outputs.release_id }} + run: | + mapfile -d '' -t upload_files < <( + find candidate/dist candidate/reports/generated \ + -mindepth 1 -maxdepth 1 -type f -print0 | + sort -z + ) + test "${#upload_files[@]}" -gt 6 + test "$( + find candidate/dist candidate/reports/generated \ + -mindepth 1 -maxdepth 1 -type l | + wc -l + )" -eq 0 + + declare -A expected_assets=() + for path in "${upload_files[@]}"; do + name="${path##*/}" + if [[ -n "${expected_assets[$name]+present}" ]]; then + echo "duplicate candidate asset name: ${name}" + exit 1 + fi + expected_assets["$name"]="$path" + done + + gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" > draft.json + asset_count="$(jq '.assets | length' draft.json)" + for ((index = 0; index < asset_count; index++)); do + asset_id="$(jq -r --argjson index "$index" '.assets[$index].id' draft.json)" + asset_name="$(jq -r --argjson index "$index" '.assets[$index].name' draft.json)" + if [[ -z "${expected_assets[$asset_name]+present}" ]]; then + echo "unexpected existing draft asset: ${asset_name}" + exit 1 + fi + gh api \ + --method DELETE \ + "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" + done + + for name in "${!expected_assets[@]}"; do + path="${expected_assets[$name]}" + encoded_name="$( + python3 -c \ + 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' \ + "$name" + )" + curl \ + --fail-with-body \ + --silent \ + --show-error \ + --request POST \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --header "Content-Type: application/octet-stream" \ + --data-binary "@${path}" \ + --output uploaded.json \ + "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${encoded_name}" + test "$(jq -r '.name' uploaded.json)" = "$name" + done + + printf '%s\n' "${!expected_assets[@]}" | sort > expected-assets.txt + gh api \ + "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + --jq '.assets[].name' | + sort > uploaded-assets.txt + diff -u expected-assets.txt uploaded-assets.txt - name: Publish the fully populated GitHub release env: GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ needs.resolve-release.outputs.release_id }} RELEASE_TAG: ${{ needs.resolve-release.outputs.tag_name }} - run: gh release edit "$RELEASE_TAG" --draft=false + run: | + published="$( + gh api \ + --method PATCH \ + "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -F draft=false + )" + test "$(printf '%s' "$published" | jq -r '.draft')" = "false" + test "$(printf '%s' "$published" | jq -r '.tag_name')" = "$RELEASE_TAG" publish-pypi: name: Publish the exact client artifacts to PyPI with OIDC if: >- - needs.resolve-release.outputs.release_created == 'true' && + fromJSON(needs.resolve-release.outputs.release_created || 'false') && vars.RELEASE_PROMOTION_ENABLED == 'true' && vars.PYPI_PUBLISH_ENABLED == 'true' && github.repository == 'picogrid/ecn-sdk-python' diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 6c0ff15..673f739 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -133,13 +133,13 @@ jobs: exact-release-gate: name: Exact release artifact needs: python-tests - runs-on: ${{ vars.HEAVY_RUNNER || 'ubuntu-latest' }} + runs-on: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER || 'ubuntu-latest' }} timeout-minutes: 90 permissions: contents: read env: PLAYWRIGHT_WORKERS: "4" - VERIFY_RELEASE_JOBS: ${{ vars.HEAVY_RUNNER && '4' || '1' }} + VERIFY_RELEASE_JOBS: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER && '4' || '1' }} LEGION_DOCS_URL: ${{ vars.LEGION_DOCS_URL }} LEGION_DOCS_VERSION: ${{ vars.LEGION_DOCS_VERSION }} steps: diff --git a/scripts/release-policy.json b/scripts/release-policy.json index ed870d1..7d1ac23 100644 --- a/scripts/release-policy.json +++ b/scripts/release-policy.json @@ -40,6 +40,7 @@ "thetechbasket.com", "tidelift.com", "tiles.example.invalid", + "uploads.github.com", "www.google.com", "www.i18next.com", "www.locize.com", diff --git a/scripts/release_resolution.py b/scripts/release_resolution.py new file mode 100644 index 0000000..59e5111 --- /dev/null +++ b/scripts/release_resolution.py @@ -0,0 +1,109 @@ +# Copyright (c) Picogrid, Inc. +# SPDX-License-Identifier: MPL-2.0 + +"""Resolve a public release from immutable tag and GitHub release state.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Literal + +_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") + + +class ResolutionError(ValueError): + """Release state is ambiguous or malformed.""" + + +@dataclass(frozen=True) +class Resolution: + action: Literal["create-draft", "create-tag-and-draft", "published", "resume"] + html_url: str + release_id: int | None + release_created: bool + release_sha: str + + +def _sha(value: str, label: str, *, optional: bool = False) -> str: + normalized = value.strip().lower() + if optional and not normalized: + return "" + if not _SHA_PATTERN.fullmatch(normalized): + raise ResolutionError(f"{label} must be a full lowercase Git commit SHA") + return normalized + + +def resolve_release( + *, + tag: str, + current_sha: str, + tag_sha: str, + releases: list[dict[str, Any]], +) -> Resolution: + """Select the only safe action for one version-bearing release tag.""" + + current_sha = _sha(current_sha, "current SHA") + tag_sha = _sha(tag_sha, "tag SHA", optional=True) + matching = [release for release in releases if release.get("tag_name") == tag] + if len(matching) > 1: + raise ResolutionError(f"multiple releases claim {tag}; remove duplicates before retrying") + + if matching: + release = matching[0] + draft = release.get("draft") + html_url = release.get("html_url") + release_id = release.get("id") + if ( + not isinstance(draft, bool) + or not isinstance(html_url, str) + or not html_url + or type(release_id) is not int + or release_id <= 0 + ): + raise ResolutionError(f"release record for {tag} is malformed") + if not tag_sha: + raise ResolutionError(f"release {tag} exists without its immutable tag") + if draft: + return Resolution("resume", html_url, release_id, True, tag_sha) + return Resolution("published", html_url, release_id, False, tag_sha) + + if tag_sha: + return Resolution("create-draft", "", None, True, tag_sha) + return Resolution("create-tag-and-draft", "", None, True, current_sha) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--current-sha", required=True) + parser.add_argument("--tag-sha", default="") + parser.add_argument("--releases-json", type=Path, required=True) + return parser.parse_args() + + +def main() -> int: + arguments = _parse_args() + try: + raw = json.loads(arguments.releases_json.read_text(encoding="utf-8")) + if not isinstance(raw, list) or not all(isinstance(item, dict) for item in raw): + raise ResolutionError("GitHub releases response must be a JSON array of objects") + resolution = resolve_release( + tag=arguments.tag, + current_sha=arguments.current_sha, + tag_sha=arguments.tag_sha, + releases=raw, + ) + except (OSError, json.JSONDecodeError, ResolutionError) as error: + print(f"release resolution failed: {error}", file=sys.stderr) + return 1 + print(json.dumps(asdict(resolution), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release/test_release_workflow.py b/tests/release/test_release_workflow.py new file mode 100644 index 0000000..6b899f2 --- /dev/null +++ b/tests/release/test_release_workflow.py @@ -0,0 +1,148 @@ +# Copyright (c) Picogrid, Inc. +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +REPOSITORY = Path(__file__).parents[2] +RESOLVER = REPOSITORY / "scripts" / "release_resolution.py" + + +def _resolve( + tmp_path: Path, releases: list[dict[str, object]], tag_sha: str = "a" * 40 +) -> subprocess.CompletedProcess[str]: + releases_path = tmp_path / "releases.json" + releases_path.write_text(json.dumps(releases), encoding="utf-8") + command = [ + sys.executable, + str(RESOLVER), + "--tag", + "v0.1.0", + "--current-sha", + "b" * 40, + "--releases-json", + str(releases_path), + ] + if tag_sha: + command.extend(("--tag-sha", tag_sha)) + return subprocess.run(command, capture_output=True, text=True, check=False) + + +def test_release_resolution_resumes_one_draft_from_immutable_tag(tmp_path: Path) -> None: + draft = { + "id": 101, + "draft": True, + "html_url": "https://github.com/picogrid/ecn-sdk-python/releases/tag/untagged-one", + "tag_name": "v0.1.0", + } + + result = _resolve(tmp_path, [draft]) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "action": "resume", + "html_url": draft["html_url"], + "release_id": 101, + "release_created": True, + "release_sha": "a" * 40, + } + + +def test_release_resolution_refuses_duplicate_drafts(tmp_path: Path) -> None: + drafts = [ + { + "id": number, + "draft": True, + "html_url": f"https://github.com/picogrid/ecn-sdk-python/releases/tag/untagged-{number}", + "tag_name": "v0.1.0", + } + for number in (1, 2) + ] + + result = _resolve(tmp_path, drafts) + + assert result.returncode != 0 + assert "multiple releases claim v0.1.0" in result.stderr + + +def test_release_resolution_uses_current_commit_only_before_tag_creation(tmp_path: Path) -> None: + result = _resolve(tmp_path, [], tag_sha="") + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "action": "create-tag-and-draft", + "html_url": "", + "release_id": None, + "release_created": True, + "release_sha": "b" * 40, + } + + +def test_release_resolution_stops_after_published_release(tmp_path: Path) -> None: + published = { + "id": 202, + "draft": False, + "html_url": "https://github.com/picogrid/ecn-sdk-python/releases/tag/v0.1.0", + "tag_name": "v0.1.0", + } + + result = _resolve(tmp_path, [published]) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "action": "published", + "release_id": 202, + "html_url": published["html_url"], + "release_created": False, + "release_sha": "a" * 40, + } + + +def test_release_workflow_normalizes_boolean_output_before_job_conditions() -> None: + workflow = (REPOSITORY / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + assert "steps.resolved.outputs.release_created" in workflow + assert workflow.count("fromJSON(needs.resolve-release.outputs.release_created || 'false')") == 4 + assert "steps.resolved.outputs.body" not in workflow + assert "PASSTHROUGH_EOF" not in workflow + assert "RESOLVED_BODY_EOF" not in workflow + + +def test_release_workflow_promotes_and_replaces_draft_assets_by_release_id() -> None: + workflow = (REPOSITORY / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + assert "release_id: ${{ steps.resolved.outputs.release_id }}" in workflow + assert "RELEASE_ID: ${{ needs.resolve-release.outputs.release_id }}" in workflow + assert 'gh release view "$RELEASE_TAG"' not in workflow + assert 'gh release upload "$RELEASE_TAG"' not in workflow + assert 'gh release edit "$RELEASE_TAG"' not in workflow + assert '"repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}"' in workflow + assert '"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"' in workflow + assert "unexpected existing draft asset" in workflow + assert "uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" in workflow + policy = json.loads( + (REPOSITORY / "scripts" / "release-policy.json").read_text(encoding="utf-8") + ) + assert "uploads.github.com" in policy["approved_public_hostnames"] + + +def test_heavy_runner_is_limited_to_trusted_main_jobs() -> None: + expected = "${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER || 'ubuntu-latest' }}" + + for name in ("documentation.yml", "release.yml", "verify.yml"): + workflow = (REPOSITORY / ".github" / "workflows" / name).read_text(encoding="utf-8") + assert expected in workflow + assert "runs-on: ${{ vars.HEAVY_RUNNER || 'ubuntu-latest' }}" not in workflow + + release_workflow = (REPOSITORY / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + worker_count = "${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER && '4' || '1' }}" + assert f"PLAYWRIGHT_WORKERS: {worker_count}" in release_workflow + + releasing = (REPOSITORY / "RELEASING.md").read_text(encoding="utf-8") + assert "An existing tag with no matching release creates a new draft" in releasing From d259def33adaa0b53a93ad384a444e9d6dc58c6f Mon Sep 17 00:00:00 2001 From: mrpicodevsec Date: Tue, 11 Aug 2026 17:42:47 -0700 Subject: [PATCH 2/4] fix(release): keep the workflow test independent of the internal runbook Guard the RELEASING.md assertion behind an existence check so the exported test passes on the public repository, where RELEASING.md is intentionally absent. Ports the follow-up from internal PR #110 on top of the FDE-714 release workflow fix. --- tests/release/test_release_workflow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/release/test_release_workflow.py b/tests/release/test_release_workflow.py index 6b899f2..39cc714 100644 --- a/tests/release/test_release_workflow.py +++ b/tests/release/test_release_workflow.py @@ -144,5 +144,9 @@ def test_heavy_runner_is_limited_to_trusted_main_jobs() -> None: worker_count = "${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER && '4' || '1' }}" assert f"PLAYWRIGHT_WORKERS: {worker_count}" in release_workflow - releasing = (REPOSITORY / "RELEASING.md").read_text(encoding="utf-8") - assert "An existing tag with no matching release creates a new draft" in releasing + # RELEASING.md is the internal release custody runbook and is excluded from + # the public export, so only assert its contents when the file is present. + releasing_md = REPOSITORY / "RELEASING.md" + if releasing_md.exists(): + releasing = releasing_md.read_text(encoding="utf-8") + assert "An existing tag with no matching release creates a new draft" in releasing From f6cb8bff3a8233c7d47401b25b3082bec775034b Mon Sep 17 00:00:00 2001 From: mrpicodevsec Date: Tue, 11 Aug 2026 17:49:39 -0700 Subject: [PATCH 3/4] test(release): assert approved hostname via explicit set membership Clears the CodeQL incomplete-url-substring-sanitization alert on the hostname allowlist check. Carries the follow-up from internal PR #110. --- tests/release/test_release_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/release/test_release_workflow.py b/tests/release/test_release_workflow.py index 39cc714..99c9598 100644 --- a/tests/release/test_release_workflow.py +++ b/tests/release/test_release_workflow.py @@ -127,7 +127,7 @@ def test_release_workflow_promotes_and_replaces_draft_assets_by_release_id() -> policy = json.loads( (REPOSITORY / "scripts" / "release-policy.json").read_text(encoding="utf-8") ) - assert "uploads.github.com" in policy["approved_public_hostnames"] + assert "uploads.github.com" in set(policy["approved_public_hostnames"]) def test_heavy_runner_is_limited_to_trusted_main_jobs() -> None: From 3f2b266aa08cab84ee91a385114271c2abb946d1 Mon Sep 17 00:00:00 2001 From: Peter Kazazes Date: Wed, 12 Aug 2026 11:39:10 -0400 Subject: [PATCH 4/4] chore: promote current ECN SDK candidate --- .github/workflows/release.yml | 19 +- operator-app/frontend/src/main.ts | 211 ++++++++++-- operator-app/tests/operator.spec.ts | 436 +++++++++++++++++++++++++ scripts/release-policy.json | 2 +- tests/release/test_release_workflow.py | 44 ++- 5 files changed, 678 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9aeece9..9c588f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -263,7 +263,13 @@ jobs: build-candidate: name: Build and verify the release candidate once - if: fromJSON(needs.resolve-release.outputs.release_created || 'false') + # release-please is always skipped on the public distribution repository, + # and that skip propagates transitively down the needs graph. !cancelled() + # overrides the propagated skip so this job is evaluated on its own merits. + if: >- + !cancelled() && + needs.resolve-release.result == 'success' && + fromJSON(needs.resolve-release.outputs.release_created || 'false') needs: resolve-release runs-on: ${{ github.ref == 'refs/heads/main' && vars.HEAVY_RUNNER || 'ubuntu-latest' }} timeout-minutes: 90 @@ -341,6 +347,9 @@ jobs: publication-reachability: name: Require anonymous publication reachability if: >- + !cancelled() && + needs.resolve-release.result == 'success' && + needs.build-candidate.result == 'success' && fromJSON(needs.resolve-release.outputs.release_created || 'false') && vars.RELEASE_PROMOTION_ENABLED == 'true' && github.repository == 'picogrid/ecn-sdk-python' @@ -388,6 +397,10 @@ jobs: promote-release: name: Attest, sign, and publish the exact candidate if: >- + !cancelled() && + needs.resolve-release.result == 'success' && + needs.build-candidate.result == 'success' && + needs.publication-reachability.result == 'success' && fromJSON(needs.resolve-release.outputs.release_created || 'false') && vars.RELEASE_PROMOTION_ENABLED == 'true' && github.repository == 'picogrid/ecn-sdk-python' @@ -578,6 +591,10 @@ jobs: publish-pypi: name: Publish the exact client artifacts to PyPI with OIDC if: >- + !cancelled() && + needs.resolve-release.result == 'success' && + needs.build-candidate.result == 'success' && + needs.promote-release.result == 'success' && fromJSON(needs.resolve-release.outputs.release_created || 'false') && vars.RELEASE_PROMOTION_ENABLED == 'true' && vars.PYPI_PUBLISH_ENABLED == 'true' && diff --git a/operator-app/frontend/src/main.ts b/operator-app/frontend/src/main.ts index 095f066..0a9aaf5 100644 --- a/operator-app/frontend/src/main.ts +++ b/operator-app/frontend/src/main.ts @@ -34,6 +34,8 @@ type Theme = 'light' | 'dark'; const themeStorageKey = 'picogrid-ecn-operator-theme'; const viewIdentityStorageKey = 'picogrid-ecn-operator-view-id'; +const viewGenerationStorageKey = 'picogrid-ecn-operator-view-generation'; +const viewRetirementStorageKey = 'picogrid-ecn-operator-view-retirement'; const duplicateViewCloseCode = 1013; const duplicateViewCloseReason = 'operator view identity is already in use'; const duplicateViewRetryLimit = 3; @@ -58,23 +60,61 @@ const canonicalUuidPattern = interface InitialViewIdentity { id: string; persistent: boolean; + generation: string | null; + retirementPending: boolean; } function initialViewIdentity(): InitialViewIdentity { const generated = window.crypto.randomUUID(); try { - const stored = window.sessionStorage.getItem(viewIdentityStorageKey); - if (stored === null) { + const storedId = window.sessionStorage.getItem(viewIdentityStorageKey); + if (storedId === null || !canonicalUuidPattern.test(storedId)) { window.sessionStorage.setItem(viewIdentityStorageKey, generated); - return { id: generated, persistent: true }; + window.sessionStorage.removeItem(viewGenerationStorageKey); + window.sessionStorage.removeItem(viewRetirementStorageKey); + return { + id: generated, + generation: null, + retirementPending: false, + persistent: true, + }; } - if (!canonicalUuidPattern.test(stored)) { - window.sessionStorage.setItem(viewIdentityStorageKey, generated); - return { id: generated, persistent: true }; + const storedGeneration = window.sessionStorage.getItem(viewGenerationStorageKey); + const retirementGeneration = window.sessionStorage.getItem(viewRetirementStorageKey); + if ( + storedGeneration !== null && + canonicalUuidPattern.test(storedGeneration) && + retirementGeneration !== null && + canonicalUuidPattern.test(retirementGeneration) && + retirementGeneration.toLowerCase() === storedGeneration.toLowerCase() + ) { + window.sessionStorage.setItem( + viewRetirementStorageKey, + storedGeneration.toLowerCase(), + ); + return { + id: storedId.toLowerCase(), + generation: storedGeneration.toLowerCase(), + retirementPending: true, + persistent: true, + }; } - return { id: stored.toLowerCase(), persistent: true }; + window.sessionStorage.setItem(viewIdentityStorageKey, generated); + window.sessionStorage.removeItem(viewGenerationStorageKey); + window.sessionStorage.removeItem(viewRetirementStorageKey); + return { + id: generated, + generation: null, + retirementPending: false, + persistent: true, + }; } catch { - return { id: generated, persistent: false }; + return { + id: generated, + generation: null, + retirementPending: false, + persistent: false, + }; } } @@ -170,7 +210,12 @@ let deferredStrandedPreparation: { let socket: WebSocket | null = null; let recoverySocket: WebSocket | null = null; let activeViewId = initialBrowserView.id; -let activeViewGeneration = window.crypto.randomUUID(); +let activeViewGeneration = + initialBrowserView.generation ?? window.crypto.randomUUID(); +let activeViewAcceptedByDocument = false; +let retirementRequired = initialBrowserView.retirementPending; +let postRetirementConflict = false; +let acknowledgedRetirementGeneration: string | null = null; let viewIdentityPersistent = initialBrowserView.persistent; let viewGeneration = 0; let preparationGeneration = 0; @@ -411,10 +456,10 @@ function setReviewState(status: PreparationStatus): void { if (review) review.status = status; confirmDialog.dataset.state = status; const reviewIsActive = status === 'review'; - reconnectViewButton.disabled = status !== 'review'; + reconnectViewButton.disabled = status !== 'review' || postRetirementConflict; cancelConfirmButton.disabled = !reviewIsActive; confirmButton.disabled = !reviewIsActive || !confirmCheck.checked || !preparedTaskIsEligible(); - recoverViewButton.disabled = status !== 'stranded'; + recoverViewButton.disabled = status !== 'stranded' || postRetirementConflict; confirmInvalidation.textContent = status === 'invalidating' ? 'Task confirmation or prepared-task invalidation is still in progress…' @@ -1057,7 +1102,63 @@ function render(): void { ); } +function forgetPersistedViewIdentity(): void { + viewIdentityPersistent = false; + for (const key of [ + viewIdentityStorageKey, + viewGenerationStorageKey, + viewRetirementStorageKey, + ]) { + try { + window.sessionStorage.removeItem(key); + } catch { + // The current document remains fail-closed even when storage cannot be cleared. + } + } +} + +function acceptViewGeneration(generation: string): void { + activeViewGeneration = generation; + activeViewAcceptedByDocument = true; + retirementRequired = false; + acknowledgedRetirementGeneration = null; + postRetirementConflict = false; + if (!viewIdentityPersistent) return; + try { + window.sessionStorage.setItem(viewGenerationStorageKey, generation); + window.sessionStorage.removeItem(viewRetirementStorageKey); + } catch { + forgetPersistedViewIdentity(); + } +} + +function preserveAcceptedViewForRetirement(): void { + if (!activeViewAcceptedByDocument || !viewIdentityPersistent) return; + retirementRequired = true; + try { + window.sessionStorage.setItem(viewRetirementStorageKey, activeViewGeneration); + } catch { + forgetPersistedViewIdentity(); + } +} + +function acknowledgeViewRetirement(generation: string): void { + if (activeViewGeneration !== generation) return; + acknowledgedRetirementGeneration = generation; + activeViewAcceptedByDocument = false; + retirementRequired = false; + if (!viewIdentityPersistent) return; + try { + if (window.sessionStorage.getItem(viewRetirementStorageKey) === generation) { + window.sessionStorage.removeItem(viewRetirementStorageKey); + } + } catch { + viewIdentityPersistent = false; + } +} + async function connectState(retireCurrentView = false): Promise { + if (postRetirementConflict) return; if (connectionTransition) return connectionTransition; const transition = connectStateOnce(retireCurrentView); connectionTransition = transition; @@ -1153,12 +1254,33 @@ function restoreStrandedRecovery(): void { } function reportDuplicateViewConflict(retirementAcknowledged: boolean): void { - armTasking.checked = false; + postRetirementConflict = retirementAcknowledged; browserConnection = 'duplicate'; taskOutcome.textContent = retirementAcknowledged ? 'A successor view was refused after backend retirement was acknowledged. No further connection was attempted; reload before tasking.' : 'This operator view identity remains active after bounded retries. Close the other tab or reload before reconnecting; tasking remains disabled.'; render(); + if (postRetirementConflict) { + reconnectViewButton.disabled = true; + recoverViewButton.disabled = true; + } +} + +function rotateContestedViewIdentity(): void { + activeViewId = window.crypto.randomUUID(); + activeViewGeneration = window.crypto.randomUUID(); + activeViewAcceptedByDocument = false; + retirementRequired = false; + acknowledgedRetirementGeneration = null; + postRetirementConflict = false; + if (!viewIdentityPersistent) return; + try { + window.sessionStorage.setItem(viewIdentityStorageKey, activeViewId); + window.sessionStorage.removeItem(viewGenerationStorageKey); + window.sessionStorage.removeItem(viewRetirementStorageKey); + } catch { + forgetPersistedViewIdentity(); + } } async function connectStateOnce(retireCurrentView: boolean): Promise { @@ -1200,6 +1322,7 @@ async function connectStateOnce(retireCurrentView: boolean): Promise { render(); try { await retireBrowserView(activeViewId, retiringGeneration); + acknowledgeViewRetirement(retiringGeneration); } catch { if (!pageActive) return; const previous = socket; @@ -1232,12 +1355,16 @@ async function connectStateOnce(retireCurrentView: boolean): Promise { ); browserConnection = 'connecting'; render(); - const retryLimit = retireCurrentView ? 0 : duplicateViewRetryLimit; + const retirementAcknowledged = + retireCurrentView || acknowledgedRetirementGeneration !== null; + const retryLimit = retirementAcknowledged ? 0 : duplicateViewRetryLimit; for (let duplicateRetries = 0; duplicateRetries <= retryLimit; duplicateRetries += 1) { - activeViewGeneration = window.crypto.randomUUID(); + const candidateGeneration = window.crypto.randomUUID(); let outcome: StateSocketOutcome; try { - outcome = await bindStateSocket(stateWebSocket(activeViewId, activeViewGeneration)); + outcome = await bindStateSocket(stateWebSocket(activeViewId, candidateGeneration), { + onAccepted: () => acceptViewGeneration(candidateGeneration), + }); } catch { browserConnection = 'disconnected'; render(); @@ -1255,7 +1382,7 @@ async function connectStateOnce(retireCurrentView: boolean): Promise { return; } if (duplicateRetries === retryLimit) { - reportDuplicateViewConflict(retireCurrentView); + reportDuplicateViewConflict(retirementAcknowledged); return; } browserConnection = 'connecting'; @@ -1278,12 +1405,15 @@ async function recoverStrandedViewOnce( return; } const retiringGeneration = activeViewGeneration; - try { - await retireBrowserView(retirementViewId, retiringGeneration); - } catch { - if (!pageActive) return; - restoreStrandedRecovery(); - return; + if (acknowledgedRetirementGeneration !== retiringGeneration) { + try { + await retireBrowserView(retirementViewId, retiringGeneration); + acknowledgeViewRetirement(retiringGeneration); + } catch { + if (!pageActive) return; + restoreStrandedRecovery(); + return; + } } if (!pageActive) return; if ( @@ -1295,13 +1425,14 @@ async function recoverStrandedViewOnce( restoreStrandedRecovery(); return; } - activeViewGeneration = window.crypto.randomUUID(); + const candidateGeneration = window.crypto.randomUUID(); try { - const candidate = stateWebSocket(activeViewId, activeViewGeneration); + const candidate = stateWebSocket(activeViewId, candidateGeneration); recoverySocket = candidate; const outcome = await bindStateSocket(candidate, { preserveStrandedBeforeAcceptance: true, onAccepted: () => { + acceptViewGeneration(candidateGeneration); if (review === strandedReview) review = null; if ( deferredStrandedPreparation?.viewId === retirementViewId && @@ -1323,10 +1454,14 @@ async function recoverStrandedViewOnce( outcome.code === duplicateViewCloseCode && outcome.reason === duplicateViewCloseReason ) { + postRetirementConflict = true; + browserConnection = 'duplicate'; taskOutcome.textContent = strandedOutcomeMessage( - 'A successor view was refused after backend retirement was acknowledged.', + 'A successor view was refused after backend retirement was acknowledged. Reload before attempting another recovery.', ); render(); + reconnectViewButton.disabled = true; + recoverViewButton.disabled = true; } } catch { recoverySocket = null; @@ -1336,7 +1471,14 @@ async function recoverStrandedViewOnce( function recoverStrandedView(): void { const strandedReview = review; - if (!strandedReview || strandedReview.status !== 'stranded' || connectionTransition) return; + if ( + !strandedReview || + strandedReview.status !== 'stranded' || + connectionTransition || + postRetirementConflict + ) { + return; + } setReviewState('invalidating'); armTasking.checked = false; browserConnection = 'connecting'; @@ -1545,7 +1687,15 @@ confirmDialog.addEventListener('cancel', (event) => { void discardPreparationFromBrowser(); }); reconnectViewButton.addEventListener('click', () => { - void connectState(true); + if ( + browserConnection === 'duplicate' && + !activeViewAcceptedByDocument && + !retirementRequired && + !postRetirementConflict + ) { + rotateContestedViewIdentity(); + } + void connectState(activeViewAcceptedByDocument || retirementRequired); }); recoverViewButton.addEventListener('click', () => { recoverStrandedView(); @@ -1583,6 +1733,7 @@ window.addEventListener('pagehide', (event) => { browserConnection = 'reconnect'; closeStateSocketForBrowserTransition(); abandonPreparationWithView(null); + preserveAcceptedViewForRetirement(); if (!event.persisted) { basemap?.removeFrom(map); basemap = null; @@ -1594,8 +1745,7 @@ window.addEventListener('pageshow', (event) => { pageActive = true; viewGeneration += 1; browserConnection = navigator.onLine ? 'reconnect' : 'offline'; - render(); - if (navigator.onLine) void connectState(true); + if (navigator.onLine) void connectState(activeViewAcceptedByDocument || retirementRequired); }); setInterval(render, 1_000); @@ -1606,8 +1756,7 @@ async function start(): Promise { modeLabel.textContent = `${configuration.mode.toUpperCase()} · ${configuration.integrations.join(', ')} · max ${configuration.maximum_entities}`; configureBasemap(configuration); configureFilters(configuration); - render(); - void connectState(); + void connectState(retirementRequired); } catch (error) { setConnection('startup failed', false); const item = document.createElement('li'); diff --git a/operator-app/tests/operator.spec.ts b/operator-app/tests/operator.spec.ts index 7519b4c..5e549fb 100644 --- a/operator-app/tests/operator.spec.ts +++ b/operator-app/tests/operator.spec.ts @@ -431,6 +431,56 @@ test('fails closed on an unclassified preparation 5xx response', async ({ page } }); test('offers reconnect recovery only after confirmation is stranded', async ({ page }) => { + const [configurationResponse, stateResponse] = await Promise.all([ + page.request.get('http://127.0.0.1:8080/api/config'), + page.request.get('http://127.0.0.1:8080/api/state'), + ]); + const configuration = await configurationResponse.json(); + const state = await stateResponse.json(); + await page.route('**/api/config', async (route) => { + await route.fulfill({ json: configuration }); + }); + await page.route('**/api/state', async (route) => { + await route.fulfill({ json: state }); + }); + let socketAttempts = 0; + await page.routeWebSocket('**/ws/state?view_id=*', (webSocket) => { + socketAttempts += 1; + if (socketAttempts === 1) { + setTimeout(() => webSocket.send(JSON.stringify(state)), 50); + return; + } + if (socketAttempts === 2) { + void webSocket.close({ + code: 1011, + reason: 'synthetic successor failure', + }); + return; + } + void webSocket.close({ + code: 1013, + reason: 'operator view identity is already in use', + }); + }); + await page.route('**/api/tasks/prepare', async (route) => { + await route.fulfill({ + json: { + preparation_token: 'synthetic-stranded-preparation', + expires_at: new Date(Date.now() + 60_000).toISOString(), + target_key: 'mock-target/00000000-0000-4000-8000-000000000201', + target_label: 'Synthetic task target', + command: 'echo', + mode: 'complete', + payload: { message: 'stranded confirmation' }, + warning: 'Review before sending.', + }, + }); + }); + let retirementAttempts = 0; + await page.route('**/api/view/retire', async (route) => { + retirementAttempts += 1; + await route.fulfill({ json: { retired: true } }); + }); await page.goto('/'); await expect(page.getByTestId('connection-state')).toContainText('ready', { timeout: 10_000, @@ -457,6 +507,134 @@ test('offers reconnect recovery only after confirmation is stranded', async ({ p ); await expect(dialog).toBeVisible(); await expect(recover).toBeEnabled(); + + await recover.click(); + await expect.poll(() => socketAttempts).toBe(2); + await expect(recover).toBeEnabled(); + expect(retirementAttempts).toBe(1); + + await recover.click(); + await expect.poll(() => socketAttempts).toBe(3); + await expect(page.getByTestId('connection-state')).toContainText( + 'view identity conflict', + ); + await expect(recover).toBeDisabled(); + expect(retirementAttempts).toBe(1); + await recover.dispatchEvent('click'); + await expect.poll(() => socketAttempts).toBe(3); +}); + +test('canonicalizes and clears a restored retirement marker after acknowledgement', async ({ + page, +}) => { + const viewId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const generation = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + await page.addInitScript( + ({ storedViewId, storedGeneration }) => { + window.sessionStorage.setItem('picogrid-ecn-operator-view-id', storedViewId); + window.sessionStorage.setItem( + 'picogrid-ecn-operator-view-generation', + storedGeneration.toUpperCase(), + ); + window.sessionStorage.setItem( + 'picogrid-ecn-operator-view-retirement', + storedGeneration.toUpperCase(), + ); + }, + { storedViewId: viewId, storedGeneration: generation }, + ); + let markRetirementStarted!: () => void; + let releaseRetirement!: () => void; + const retirementStarted = new Promise((resolve) => { + markRetirementStarted = resolve; + }); + const retirementRelease = new Promise((resolve) => { + releaseRetirement = resolve; + }); + await page.route('**/api/view/retire', async (route) => { + markRetirementStarted(); + await retirementRelease; + await route.fulfill({ json: { retired: true } }); + }); + + await page.goto('/'); + await retirementStarted; + expect( + await page.evaluate(() => + window.sessionStorage.getItem('picogrid-ecn-operator-view-retirement'), + ), + ).toBe(generation); + releaseRetirement(); + await expect(page.getByTestId('connection-state')).toContainText('ready'); + expect( + await page.evaluate(() => + window.sessionStorage.getItem('picogrid-ecn-operator-view-retirement'), + ), + ).toBeNull(); +}); + +test('removes a persisted identity when retirement intent cannot be stored', async ({ + page, +}) => { + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + const storedBefore = await page.evaluate(() => ({ + id: window.sessionStorage.getItem('picogrid-ecn-operator-view-id'), + generation: window.sessionStorage.getItem('picogrid-ecn-operator-view-generation'), + })); + expect(storedBefore.id).not.toBeNull(); + expect(storedBefore.generation).not.toBeNull(); + + const storedAfter = await page.evaluate(() => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key: string, value: string): void { + if (key === 'picogrid-ecn-operator-view-retirement') { + throw new DOMException('synthetic quota exceeded', 'QuotaExceededError'); + } + originalSetItem.call(this, key, value); + }; + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })); + return { + id: window.sessionStorage.getItem('picogrid-ecn-operator-view-id'), + generation: window.sessionStorage.getItem( + 'picogrid-ecn-operator-view-generation', + ), + }; + }); + + expect(storedAfter).toEqual({ id: null, generation: null }); +}); + +test('removes a persisted identity when an accepted generation cannot be stored', async ({ + page, +}) => { + await page.addInitScript(() => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key: string, value: string): void { + if (key === 'picogrid-ecn-operator-view-generation') { + throw new DOMException('synthetic quota exceeded', 'QuotaExceededError'); + } + originalSetItem.call(this, key, value); + }; + }); + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + expect( + await page.evaluate(() => ({ + id: window.sessionStorage.getItem('picogrid-ecn-operator-view-id'), + generation: window.sessionStorage.getItem( + 'picogrid-ecn-operator-view-generation', + ), + retirement: window.sessionStorage.getItem( + 'picogrid-ecn-operator-view-retirement', + ), + })), + ).toEqual({ id: null, generation: null, retirement: null }); + await expect(page.locator('#arm-tasking')).toBeDisabled(); }); test('repairs a malformed stored view identity without disabling tasking', async ({ page }) => { @@ -781,10 +959,20 @@ test('retires the exact prior view before a synthetic persisted-page restoration await expect(page.locator('.leaflet-container')).toHaveCount(1); await expect.poll(() => socketUrls.length).toBe(1); const priorSocket = new URL(socketUrls[0]!); + expect( + await page.evaluate(() => + window.sessionStorage.getItem('picogrid-ecn-operator-view-retirement'), + ), + ).toBeNull(); await page.evaluate(() => { window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); }); + expect( + await page.evaluate(() => + window.sessionStorage.getItem('picogrid-ecn-operator-view-retirement'), + ), + ).toBe(priorSocket.searchParams.get('view_generation')); await expect(page.getByTestId('connection-state')).not.toContainText('ready'); observeRestore = true; await page.evaluate(() => { @@ -803,6 +991,254 @@ test('retires the exact prior view before a synthetic persisted-page restoration await expect(page.getByTestId('connection-state')).toContainText('ready', { timeout: 10_000, }); + expect( + await page.evaluate(() => + window.sessionStorage.getItem('picogrid-ecn-operator-view-retirement'), + ), + ).toBeNull(); +}); + +test('retires the persisted accepted generation before a full-page reload successor', async ({ + page, +}) => { + const socketUrls: string[] = []; + const reloadOrder: string[] = []; + let observeReload = false; + let retirementHeaders: Record | null = null; + page.on('websocket', (webSocket) => { + socketUrls.push(webSocket.url()); + if (observeReload) reloadOrder.push('successor socket'); + }); + await page.route('**/api/view/retire', async (route) => { + retirementHeaders = route.request().headers(); + reloadOrder.push('prior view retirement'); + await route.continue(); + }); + + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + await expect.poll(() => socketUrls.length).toBe(1); + const priorSocket = new URL(socketUrls[0]!); + + observeReload = true; + await page.reload(); + + await expect.poll(() => reloadOrder.slice(0, 2)).toEqual([ + 'prior view retirement', + 'successor socket', + ]); + expect(retirementHeaders).toMatchObject({ + 'x-operator-view': priorSocket.searchParams.get('view_id'), + 'x-operator-view-generation': priorSocket.searchParams.get('view_generation'), + }); + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); +}); + +test('retries from the last accepted generation after a successor socket fails', async ({ + page, +}) => { + const snapshot = await (await page.request.get('/api/state')).json(); + const socketUrls: string[] = []; + const retirementGenerations: string[] = []; + let initialAcceptedGeneration: string | null = null; + let socketAttempts = 0; + await page.routeWebSocket('**/ws/state?view_id=*', (webSocket) => { + socketUrls.push(webSocket.url()); + socketAttempts += 1; + if (socketAttempts === 2) { + void webSocket.close({ code: 1011, reason: 'synthetic successor failure' }); + return; + } + webSocket.send(JSON.stringify(snapshot)); + }); + await page.route('**/api/view/retire', async (route) => { + const generation = route.request().headers()['x-operator-view-generation']; + if (generation) retirementGenerations.push(generation); + if (generation !== initialAcceptedGeneration) { + await route.fulfill({ + status: 409, + json: { detail: 'operator browser view generation is not active' }, + }); + return; + } + await route.fulfill({ json: { retired: true } }); + }); + + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + await expect.poll(() => socketUrls.length).toBe(1); + initialAcceptedGeneration = new URL(socketUrls[0]!).searchParams.get('view_generation'); + + await page.getByRole('button', { name: 'Reconnect view' }).click(); + await expect.poll(() => socketAttempts).toBe(2); + await expect(page.getByRole('button', { name: 'Reconnect view' })).toBeEnabled(); + await page.getByRole('button', { name: 'Reconnect view' }).click(); + + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + expect(retirementGenerations).toEqual([initialAcceptedGeneration]); + expect(socketAttempts).toBe(3); +}); + +test('keeps retirement acknowledgement terminal across later reconnect attempts', async ({ + page, +}) => { + const snapshot = await (await page.request.get('/api/state')).json(); + let socketAttempts = 0; + let retirementAttempts = 0; + await page.routeWebSocket('**/ws/state?view_id=*', (webSocket) => { + socketAttempts += 1; + if (socketAttempts === 1) { + webSocket.send(JSON.stringify(snapshot)); + return; + } + if (socketAttempts === 2) { + void webSocket.close({ code: 1011, reason: 'synthetic successor failure' }); + return; + } + void webSocket.close({ + code: 1013, + reason: 'operator view identity is already in use', + }); + }); + await page.route('**/api/view/retire', async (route) => { + retirementAttempts += 1; + await route.fulfill({ json: { retired: true } }); + }); + + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('ready'); + const reconnect = page.getByRole('button', { name: 'Reconnect view' }); + await reconnect.click(); + await expect.poll(() => socketAttempts).toBe(2); + await expect(reconnect).toBeEnabled(); + await reconnect.click(); + await expect(page.getByTestId('connection-state')).toContainText( + 'view identity conflict', + ); + await expect(reconnect).toBeDisabled(); + expect(retirementAttempts).toBe(1); + expect(socketAttempts).toBe(3); +}); + +test('keeps restored retirement mandatory after a failed acknowledgement', async ({ + page, +}) => { + const viewId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const generation = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + await page.addInitScript( + ({ storedViewId, storedGeneration }) => { + window.sessionStorage.setItem('picogrid-ecn-operator-view-id', storedViewId); + window.sessionStorage.setItem( + 'picogrid-ecn-operator-view-generation', + storedGeneration, + ); + window.sessionStorage.setItem( + 'picogrid-ecn-operator-view-retirement', + storedGeneration, + ); + }, + { storedViewId: viewId, storedGeneration: generation }, + ); + let retirementAttempts = 0; + const socketUrls: string[] = []; + page.on('websocket', (webSocket) => socketUrls.push(webSocket.url())); + await page.route('**/api/view/retire', async (route) => { + retirementAttempts += 1; + if (retirementAttempts === 1) { + await route.fulfill({ + status: 409, + json: { detail: 'synthetic retirement still pending' }, + }); + return; + } + await route.fulfill({ json: { retired: true } }); + }); + + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('reconnect'); + expect(retirementAttempts).toBe(1); + expect(socketUrls).toEqual([]); + + await page.getByRole('button', { name: 'Reconnect view' }).click(); + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + expect(retirementAttempts).toBe(2); + expect(socketUrls).toHaveLength(1); +}); + +test('blocks retries after a post-retirement duplicate refusal', async ({ page }) => { + const snapshot = await (await page.request.get('/api/state')).json(); + let socketAttempts = 0; + await page.routeWebSocket('**/ws/state?view_id=*', (webSocket) => { + socketAttempts += 1; + if (socketAttempts === 1) { + webSocket.send(JSON.stringify(snapshot)); + return; + } + void webSocket.close({ + code: 1013, + reason: 'operator view identity is already in use', + }); + }); + let retirementAttempts = 0; + await page.route('**/api/view/retire', async (route) => { + retirementAttempts += 1; + await route.fulfill({ json: { retired: true } }); + }); + + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('ready'); + const reconnect = page.getByRole('button', { name: 'Reconnect view' }); + await reconnect.click(); + await expect(page.getByTestId('connection-state')).toContainText( + 'view identity conflict', + ); + await expect(reconnect).toBeDisabled(); + expect(retirementAttempts).toBe(1); + expect(socketAttempts).toBe(2); +}); + +test('rotates a cloned identity before its initial socket opens', async ({ + context, + page, +}) => { + const retirementGenerations: string[] = []; + await context.route('**/api/view/retire', async (route) => { + const generation = route.request().headers()['x-operator-view-generation']; + if (generation) retirementGenerations.push(generation); + await route.fulfill({ json: { retired: true } }); + }); + + await page.goto('/'); + await expect(page.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + const originalViewId = await page.evaluate(() => + window.sessionStorage.getItem('picogrid-ecn-operator-view-id'), + ); + const popup = page.waitForEvent('popup'); + await page.evaluate(() => { + void window.open(window.location.href, '_blank'); + }); + const clone = await popup; + await expect(clone.getByTestId('connection-state')).toContainText('ready', { + timeout: 10_000, + }); + expect( + await clone.evaluate(() => + window.sessionStorage.getItem('picogrid-ecn-operator-view-id'), + ), + ).not.toBe(originalViewId); + expect(retirementGenerations).toEqual([]); }); diff --git a/scripts/release-policy.json b/scripts/release-policy.json index 7d1ac23..e7031f6 100644 --- a/scripts/release-policy.json +++ b/scripts/release-policy.json @@ -204,7 +204,7 @@ "operator_app/runtime.py", "operator_app/settings.py", "operator_app/state.py", - "operator_app/static/assets/index-BHiyEqwP.js", + "operator_app/static/assets/index-B7E8cfjm.js", "operator_app/static/assets/index-VXC6R6nV.css", "operator_app/static/brand/ecn-client-og.png", "operator_app/static/brand/picogrid-app-icon-192.png", diff --git a/tests/release/test_release_workflow.py b/tests/release/test_release_workflow.py index 99c9598..86ec172 100644 --- a/tests/release/test_release_workflow.py +++ b/tests/release/test_release_workflow.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import re import subprocess import sys from pathlib import Path @@ -127,7 +128,48 @@ def test_release_workflow_promotes_and_replaces_draft_assets_by_release_id() -> policy = json.loads( (REPOSITORY / "scripts" / "release-policy.json").read_text(encoding="utf-8") ) - assert "uploads.github.com" in set(policy["approved_public_hostnames"]) + assert any(host == "uploads.github.com" for host in policy["approved_public_hostnames"]) + + +def _job_block(workflow: str, job: str) -> str: + # Slice a single job's YAML block, from its two-space-indented header to the + # next top-level job header, so conditions are checked against that job alone + # rather than the whole file. + marker = f"\n {job}:\n" + start = workflow.index(marker) + len(marker) + rest = workflow[start:] + nxt = re.search(r"\n \S", rest) + return rest[: nxt.start()] if nxt else rest + + +def test_downstream_jobs_survive_the_skipped_release_please() -> None: + # release-please is always skipped on the public distribution repository, + # and GitHub Actions propagates that skip transitively through the needs + # graph. Every job downstream of resolve-release must override the skip with + # !cancelled() or the entire publish pipeline silently skips and no + # artifacts are ever attached. Assertions are bound to each job's own block + # so one job cannot lose a condition while another supplies the same text. + workflow = (REPOSITORY / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + # resolve-release plus every job downstream of it carries the !cancelled() + # override that defeats the propagated skip. + for job in ( + "resolve-release", + "build-candidate", + "publication-reachability", + "promote-release", + "publish-pypi", + ): + assert "!cancelled() &&" in _job_block(workflow, job), job + + # the four consumers of resolve-release also gate on it actually succeeding. + for job in ( + "build-candidate", + "publication-reachability", + "promote-release", + "publish-pypi", + ): + assert "needs.resolve-release.result == 'success'" in _job_block(workflow, job), job def test_heavy_runner_is_limited_to_trusted_main_jobs() -> None: