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..9c588f3 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' + # 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: ${{ 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 +347,10 @@ jobs: publication-reachability: name: Require anonymous publication reachability if: >- - needs.resolve-release.outputs.release_created == 'true' && + !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' needs: @@ -336,7 +397,11 @@ jobs: promote-release: name: Attest, sign, and publish the exact candidate if: >- - needs.resolve-release.outputs.release_created == 'true' && + !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' needs: @@ -423,41 +488,114 @@ 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' && + !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' && 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/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 ed870d1..e7031f6 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", @@ -203,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/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..86ec172 --- /dev/null +++ b/tests/release/test_release_workflow.py @@ -0,0 +1,194 @@ +# Copyright (c) Picogrid, Inc. +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import json +import re +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 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: + 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.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