Skip to content

Commit 365d978

Browse files
committed
[build] Add release orchestrator and Windows installer test workflows
1 parent fb5d53e commit 365d978

5 files changed

Lines changed: 869 additions & 11 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Build and publish PySceneDetect Docker image to GitHub Container Registry (GHCR).
22
name: Publish Docker Image
33

4+
# Publishing a release build is driven by release.yml, which dispatches this workflow after
5+
# artifact verification passes.
46
on:
57
workflow_dispatch:
68
inputs:
@@ -10,8 +12,6 @@ on:
1012
default: false
1113
push:
1214
branches: [ "main" ]
13-
release:
14-
types: [published]
1515

1616
env:
1717
REGISTRY: ghcr.io

.github/workflows/release-test.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ jobs:
3737
# so only enforce the heading on stable releases.
3838
if [[ "$TAG_VERSION" == *-dev* ]]; then
3939
echo "Pre-release ($TAG_VERSION); skipping changelog heading check."
40-
elif ! grep -q "^## PySceneDetect $TAG_VERSION" website/pages/changelog.md; then
41-
echo "Changelog is missing a '## PySceneDetect $TAG_VERSION' heading"
40+
# Major/minor releases use a '## PySceneDetect X.Y' heading; patch
41+
# releases nest under it as '### PySceneDetect X.Y.Z (date)'.
42+
elif ! grep -Eq "^#{2,3} (PySceneDetect )?$TAG_VERSION( |$)" website/pages/changelog.md; then
43+
echo "Changelog is missing a heading for $TAG_VERSION (e.g. '### PySceneDetect $TAG_VERSION (...)')"
4244
exit 1
4345
fi
4446
fi

.github/workflows/release.yml

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)