|
| 1 | +# Release orchestrator: verifies the artifacts attached to a published GitHub |
| 2 | +# release actually work, then publishes them stage by stage, verifying each |
| 3 | +# stage before starting the next: |
| 4 | +# |
| 5 | +# MSI install/upgrade test (test-installer.yml) |
| 6 | +# -> TestPyPI publish -> pip smoke install from TestPyPI |
| 7 | +# -> PyPI publish -> pip smoke install from PyPI |
| 8 | +# -> Docker publish -> docker pull + smoke run from GHCR |
| 9 | +# |
| 10 | +# Publishing is intentionally NOT triggered automatically by the release event - |
| 11 | +# this workflow is dispatched manually once the GitHub release is published, and |
| 12 | +# the publish steps only run after every verification step succeeds. Each stage |
| 13 | +# is driven through the existing workflows via `gh workflow run` (rather than |
| 14 | +# workflow_call) so they keep working standalone and the PyPI trusted-publisher |
| 15 | +# configuration (which is bound to publish-pypi.yml as the top-level workflow) |
| 16 | +# is unaffected. |
| 17 | + |
| 18 | +name: Release Orchestrator |
| 19 | + |
| 20 | +on: |
| 21 | + workflow_dispatch: |
| 22 | + inputs: |
| 23 | + tag: |
| 24 | + description: 'Release tag to verify and publish (e.g. v0.7.1)' |
| 25 | + required: true |
| 26 | + verify-only: |
| 27 | + description: 'Stop after verification (no PyPI/Docker publish)' |
| 28 | + type: boolean |
| 29 | + default: false |
| 30 | + |
| 31 | +permissions: |
| 32 | + contents: read |
| 33 | + actions: write # `gh workflow run` on the workflows this one orchestrates |
| 34 | + |
| 35 | +# The run-id lookup after each dispatch assumes this is the only orchestrator |
| 36 | +# running; never allow two concurrent releases. |
| 37 | +concurrency: |
| 38 | + group: release-orchestrator |
| 39 | + |
| 40 | +jobs: |
| 41 | + orchestrate: |
| 42 | + name: ${{ inputs.verify-only && 'Verify' || 'Verify + Publish' }} ${{ inputs.tag }} |
| 43 | + runs-on: ubuntu-latest |
| 44 | + timeout-minutes: 120 |
| 45 | + env: |
| 46 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 47 | + GH_REPO: ${{ github.repository }} |
| 48 | + TAG: ${{ inputs.tag }} |
| 49 | + steps: |
| 50 | + - name: Validate release |
| 51 | + run: | |
| 52 | + set -euo pipefail |
| 53 | + state=$(gh release view "$TAG" --json isDraft,isPrerelease \ |
| 54 | + -q 'if .isDraft then "draft" elif .isPrerelease then "prerelease" else "published" end') |
| 55 | + if [[ "$state" == "draft" ]]; then |
| 56 | + echo "::error::Release $TAG is still a draft; publish it before running the orchestrator." |
| 57 | + exit 1 |
| 58 | + fi |
| 59 | + echo "Release $TAG is $state." |
| 60 | + # Display version used by the pip smoke test; mirrors the tag |
| 61 | + # normalization in release-test.yml (both vX.Y[.Z] and the legacy |
| 62 | + # vX.Y[.Z]-release tag styles are accepted). |
| 63 | + VERSION="${TAG#v}" |
| 64 | + VERSION="${VERSION%-release}" |
| 65 | + echo "VERSION=$VERSION" >> "$GITHUB_ENV" |
| 66 | +
|
| 67 | + - name: Write dispatch helper |
| 68 | + # `gh workflow run` returns no run id, so the helper polls for the |
| 69 | + # newest workflow_dispatch run created at/after dispatch time (the |
| 70 | + # concurrency group above guarantees it is ours), then watches it to |
| 71 | + # completion, propagating failure. |
| 72 | + run: | |
| 73 | + cat > "$RUNNER_TEMP/dispatch.sh" <<'EOF' |
| 74 | + dispatch_and_watch() { |
| 75 | + local workflow="$1" ref="$2" |
| 76 | + shift 2 |
| 77 | + local start_epoch |
| 78 | + start_epoch=$(date -u +%s) |
| 79 | + echo "::group::Dispatch $workflow (ref $ref) $*" |
| 80 | + gh workflow run "$workflow" --ref "$ref" "$@" |
| 81 | + local run_id="" |
| 82 | + for _ in $(seq 1 24); do |
| 83 | + sleep 5 |
| 84 | + run_id=$(gh run list --workflow "$workflow" --event workflow_dispatch --limit 5 \ |
| 85 | + --json databaseId,createdAt \ |
| 86 | + -q "[.[] | select((.createdAt | fromdateiso8601) >= $((start_epoch - 5)))] | first | .databaseId // \"\"") |
| 87 | + [[ -n "$run_id" ]] && break |
| 88 | + done |
| 89 | + if [[ -z "$run_id" ]]; then |
| 90 | + echo "::error::Dispatched $workflow but its run never appeared." |
| 91 | + return 1 |
| 92 | + fi |
| 93 | + echo "Watching run $run_id: https://github.com/$GH_REPO/actions/runs/$run_id" |
| 94 | + echo "::endgroup::" |
| 95 | + gh run watch "$run_id" --exit-status --interval 30 |
| 96 | + } |
| 97 | + EOF |
| 98 | +
|
| 99 | + - name: Write pip smoke-install helper |
| 100 | + # Same smoke test for TestPyPI and production PyPI: fresh venv, install |
| 101 | + # the exact release version, and check `scenedetect version` reports it. |
| 102 | + # Both indexes can lag a fresh upload, so installs are retried briefly. |
| 103 | + run: | |
| 104 | + cat > "$RUNNER_TEMP/smoke.sh" <<'EOF' |
| 105 | + pip_smoke_install() { |
| 106 | + local venv="$1" |
| 107 | + shift |
| 108 | + python3 -m venv "$venv" |
| 109 | + local ok=0 |
| 110 | + for attempt in 1 2 3 4 5; do |
| 111 | + if "$venv/bin/pip" install --quiet "$@" "scenedetect==$VERSION"; then |
| 112 | + ok=1 |
| 113 | + break |
| 114 | + fi |
| 115 | + echo "pip install attempt $attempt failed; retrying in 30s..." |
| 116 | + sleep 30 |
| 117 | + done |
| 118 | + [[ "$ok" -eq 1 ]] |
| 119 | + local out |
| 120 | + out=$("$venv/bin/scenedetect" version) |
| 121 | + echo "$out" |
| 122 | + grep -F "$VERSION" <<<"$out" |
| 123 | + } |
| 124 | + EOF |
| 125 | +
|
| 126 | + - name: 'Stage 1 - Verify: Windows installer (install + upgrade on clean runner)' |
| 127 | + run: | |
| 128 | + set -euo pipefail |
| 129 | + source "$RUNNER_TEMP/dispatch.sh" |
| 130 | + dispatch_and_watch test-installer.yml "$GITHUB_REF_NAME" -f "tag=$TAG" |
| 131 | +
|
| 132 | + - name: 'Stage 2 - Publish: TestPyPI' |
| 133 | + run: | |
| 134 | + set -euo pipefail |
| 135 | + source "$RUNNER_TEMP/dispatch.sh" |
| 136 | + dispatch_and_watch publish-pypi.yml "$GITHUB_REF_NAME" -f "tag=$TAG" -f "environment=testpypi" |
| 137 | +
|
| 138 | + - name: 'Stage 2 - Verify: pip install from TestPyPI' |
| 139 | + run: | |
| 140 | + set -euo pipefail |
| 141 | + source "$RUNNER_TEMP/smoke.sh" |
| 142 | + # Dependencies are not mirrored on TestPyPI, so resolve them from the |
| 143 | + # production index. |
| 144 | + pip_smoke_install smoke-testpypi \ |
| 145 | + --index-url https://test.pypi.org/simple/ \ |
| 146 | + --extra-index-url https://pypi.org/simple/ |
| 147 | +
|
| 148 | + - name: 'Stage 3 - Publish: PyPI (production)' |
| 149 | + if: ${{ !inputs.verify-only }} |
| 150 | + run: | |
| 151 | + set -euo pipefail |
| 152 | + source "$RUNNER_TEMP/dispatch.sh" |
| 153 | + # publish-pypi.yml additionally gates production publishes on the |
| 154 | + # build + release-test workflows being green for the tag. |
| 155 | + dispatch_and_watch publish-pypi.yml "$GITHUB_REF_NAME" -f "tag=$TAG" -f "environment=pypi" |
| 156 | +
|
| 157 | + - name: 'Stage 3 - Verify: pip install from PyPI' |
| 158 | + if: ${{ !inputs.verify-only }} |
| 159 | + run: | |
| 160 | + set -euo pipefail |
| 161 | + source "$RUNNER_TEMP/smoke.sh" |
| 162 | + pip_smoke_install smoke-pypi |
| 163 | +
|
| 164 | + - name: 'Stage 4 - Publish: Docker image (version tags + latest)' |
| 165 | + if: ${{ !inputs.verify-only }} |
| 166 | + run: | |
| 167 | + set -euo pipefail |
| 168 | + source "$RUNNER_TEMP/dispatch.sh" |
| 169 | + # Dispatched on the release tag itself so docker/metadata-action |
| 170 | + # derives the semver image tags from it (requires the tag to contain |
| 171 | + # docker-publish.yml, i.e. v0.7.1 or newer). |
| 172 | + dispatch_and_watch docker-publish.yml "$TAG" -f "tag_latest=true" |
| 173 | +
|
| 174 | + - name: 'Stage 4 - Verify: docker pull + smoke run from GHCR' |
| 175 | + if: ${{ !inputs.verify-only }} |
| 176 | + run: | |
| 177 | + set -euo pipefail |
| 178 | + image="ghcr.io/breakthrough/pyscenedetect" |
| 179 | + docker pull "$image:$VERSION" |
| 180 | + docker pull "$image:latest" |
| 181 | + # `latest` must point at the build we just published. |
| 182 | + v=$(docker image inspect "$image:$VERSION" --format '{{.Id}}') |
| 183 | + l=$(docker image inspect "$image:latest" --format '{{.Id}}') |
| 184 | + if [[ "$v" != "$l" ]]; then |
| 185 | + echo "::error::latest ($l) does not match $VERSION ($v)" |
| 186 | + exit 1 |
| 187 | + fi |
| 188 | + out=$(docker run --rm "$image:$VERSION" version) |
| 189 | + echo "$out" |
| 190 | + grep -F "$VERSION" <<<"$out" |
| 191 | +
|
| 192 | + - name: Summary |
| 193 | + run: | |
| 194 | + if [[ "${{ inputs.verify-only }}" == "true" ]]; then |
| 195 | + echo "Verification of $TAG passed. Re-run without verify-only to publish." |
| 196 | + else |
| 197 | + echo "Release $TAG verified and published:" |
| 198 | + echo " https://pypi.org/project/scenedetect/$VERSION/" |
| 199 | + echo " https://pypi.org/project/scenedetect-headless/$VERSION/" |
| 200 | + echo " https://pypi.org/project/scenedetect-core/$VERSION/" |
| 201 | + echo " https://github.com/$GH_REPO/pkgs/container/pyscenedetect" |
| 202 | + fi |
0 commit comments