@@ -8,33 +8,52 @@ permissions:
88 contents : read
99
1010jobs :
11- test :
12- name : Run Tests Before Publish
11+ verify :
12+ name : Verify release candidate
1313 if : github.event.release.prerelease == false
1414 runs-on : ubuntu-latest
15+ timeout-minutes : 30
16+ permissions :
17+ contents : read
18+
1519 steps :
16- - uses : actions/checkout@v7
20+ - uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
21+ with :
22+ fetch-depth : 0
23+ persist-credentials : false
1724
1825 - name : Set up Python
19- uses : actions/setup-python@v7
26+ uses : actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
2027 with :
2128 python-version : " 3.12"
2229
23- - name : Install dependencies
30+ - name : Install verification dependencies
2431 run : |
2532 python -m pip install --upgrade pip
26- pip install -e '.[dev]' pip-audit
33+ python -m pip install -e '.[dev]' pip-audit build 'jsonschema>=4.17,<4.24'
2734
28- - name : Verify release tag matches package version
35+ - name : Verify release tag matches package version and protected main
2936 env :
3037 RELEASE_TAG : ${{ github.event.release.tag_name }}
3138 run : |
32- PACKAGE_VERSION="$(python -c 'from oilpriceapi.version import SDK_VERSION; print(SDK_VERSION)')"
39+ set -euo pipefail
40+ PACKAGE_VERSION="$(python scripts/package_version.py)"
3341 if [ "$RELEASE_TAG" != "v$PACKAGE_VERSION" ]; then
3442 echo "::error::Release tag $RELEASE_TAG does not match package version $PACKAGE_VERSION"
3543 exit 1
3644 fi
3745
46+ git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
47+ RELEASE_COMMIT="$(git rev-parse "$RELEASE_TAG^{commit}")"
48+ if [ "$RELEASE_COMMIT" != "$(git rev-parse HEAD)" ]; then
49+ echo "::error::Checked-out commit does not match $RELEASE_TAG"
50+ exit 1
51+ fi
52+ if ! git merge-base --is-ancestor "$RELEASE_COMMIT" origin/main; then
53+ echo "::error::$RELEASE_TAG is not reachable from protected main"
54+ exit 1
55+ fi
56+
3857 - name : Lint source with ruff
3958 run : ruff check oilpriceapi/
4059
@@ -44,45 +63,171 @@ jobs:
4463 - name : Audit installed dependencies
4564 run : pip-audit
4665
66+ - name : Validate public storefront claims
67+ run : python scripts/validate_storefront_claims.py
68+
69+ - name : Build package
70+ run : python -m build
71+
72+ - name : Install and import the exact built wheel
73+ run : ./scripts/clean-wheel-smoke.sh
74+
75+ - name : Build signed snippet manifest
76+ run : |
77+ python scripts/generate_snippet_manifest.py \
78+ --source-commit "$(git rev-parse HEAD)" \
79+ --output artifacts/snippets/oilpriceapi-python-snippets-v1.json
80+
81+ - name : Prepare checksummed release artifact
82+ run : |
83+ set -euo pipefail
84+ ARTIFACT_DIR="$RUNNER_TEMP/release-artifact"
85+ mkdir -p "$ARTIFACT_DIR/dist" "$ARTIFACT_DIR/snippets"
86+ cp dist/* "$ARTIFACT_DIR/dist/"
87+ cp artifacts/snippets/* "$ARTIFACT_DIR/snippets/"
88+ PACKAGE_VERSION="$(python scripts/package_version.py)"
89+ printf 'PACKAGE_VERSION=%s\n' "$PACKAGE_VERSION" > "$ARTIFACT_DIR/release.env"
90+ (
91+ cd "$ARTIFACT_DIR"
92+ find dist snippets -type f -print0 \
93+ | sort -z \
94+ | xargs -0 sha256sum > artifact.sha256
95+ sha256sum release.env >> artifact.sha256
96+ )
97+
98+ - uses : actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
99+ with :
100+ name : verified-pypi-package
101+ path : ${{ runner.temp }}/release-artifact/
102+ if-no-files-found : error
103+ retention-days : 1
104+
47105 publish :
48- name : Publish to PyPI
49- needs : test
106+ name : Publish verified package to PyPI
107+ needs : verify
50108 if : github.event.release.prerelease == false
51109 runs-on : ubuntu-latest
110+ timeout-minutes : 15
52111 environment : pypi
53112 permissions :
54- id-token : write # Required for trusted publishing
55- contents : write
113+ contents : read
114+ id-token : write
56115
57116 steps :
58- - uses : actions/checkout@v7
59-
60- - name : Set up Python
61- uses : actions/setup-python@v7
117+ - uses : actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
62118 with :
63- python-version : " 3.12"
119+ name : verified-pypi-package
120+ path : ${{ runner.temp }}/release-artifact
64121
65- - name : Install build dependencies
122+ - name : Verify exact artifact checksums
123+ working-directory : ${{ runner.temp }}/release-artifact
66124 run : |
67- python -m pip install --upgrade pip
68- pip install build 'jsonschema>=4.17,<4.24'
125+ set -euo pipefail
126+ manifest_files="$RUNNER_TEMP/manifest-files"
127+ actual_files="$RUNNER_TEMP/actual-files"
128+ sed -n 's/^[0-9a-f]\{64\} //p' artifact.sha256 \
129+ | LC_ALL=C sort > "$manifest_files"
130+ {
131+ find dist snippets -type f -print
132+ printf '%s\n' release.env
133+ } | LC_ALL=C sort > "$actual_files"
134+ if [ -n "$(find dist snippets -type l -print -quit)" ]; then
135+ echo "::error::Verified release artifact contains a symlink"
136+ exit 1
137+ fi
138+ if ! cmp -s "$manifest_files" "$actual_files"; then
139+ echo "::error::Checksum manifest does not cover the exact release files"
140+ exit 1
141+ fi
142+ sha256sum -c artifact.sha256
69143
70- - name : Build package
71- run : python -m build
144+ - name : Publish exact verified distributions
145+ uses : pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 2026-07-28
146+ with :
147+ packages-dir : ${{ runner.temp }}/release-artifact/dist/
148+ skip-existing : true
72149
73- - name : Install and import the exact built wheel
74- run : ./scripts/clean-wheel-smoke.sh
150+ readback :
151+ name : Verify public PyPI artifact hashes
152+ needs : publish
153+ if : github.event.release.prerelease == false
154+ runs-on : ubuntu-latest
155+ timeout-minutes : 10
156+ permissions :
157+ contents : read
75158
76- - name : Build signed snippet manifest
159+ steps :
160+ - uses : actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
161+ with :
162+ name : verified-pypi-package
163+ path : ${{ runner.temp }}/release-artifact
164+
165+ - name : Verify exact public PyPI hashes
166+ working-directory : ${{ runner.temp }}/release-artifact
77167 run : |
78- python scripts/generate_snippet_manifest.py \
79- --source-commit "$GITHUB_SHA" \
80- --output artifacts/snippets/oilpriceapi-python-snippets-v1.json
168+ set -euo pipefail
169+ sha256sum -c artifact.sha256
170+ PACKAGE_VERSION="$(sed -n 's/^PACKAGE_VERSION=//p' release.env)"
171+ if ! printf '%s' "$PACKAGE_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
172+ echo "::error::Invalid package version in verified artifact"
173+ exit 1
174+ fi
175+
176+ for attempt in $(seq 1 24); do
177+ curl --fail --silent --show-error --max-time 10 \
178+ "https://pypi.org/pypi/oilpriceapi/$PACKAGE_VERSION/json" \
179+ > "$RUNNER_TEMP/pypi.json" || true
180+ all_present=true
181+ for file in dist/*; do
182+ filename="$(basename "$file")"
183+ expected="$(sha256sum "$file" | cut -d' ' -f1)"
184+ actual="$(jq -r --arg filename "$filename" \
185+ '[.urls[]? | select(.filename == $filename) | .digests.sha256][0] // empty' \
186+ "$RUNNER_TEMP/pypi.json" 2>/dev/null || true)"
187+ if [ -z "$actual" ]; then
188+ all_present=false
189+ elif [ "$actual" != "$expected" ]; then
190+ echo "::error::PyPI $filename has an unexpected immutable hash"
191+ exit 1
192+ fi
193+ done
194+ if [ "$all_present" = true ]; then
195+ echo "Verified every public oilpriceapi $PACKAGE_VERSION distribution hash."
196+ exit 0
197+ fi
198+ if [ "$attempt" -lt 6 ]; then
199+ sleep_seconds=$((attempt * 2))
200+ else
201+ sleep_seconds=10
202+ fi
203+ sleep "$sleep_seconds"
204+ done
205+
206+ echo "::error::PyPI public readback did not expose every verified distribution"
207+ exit 1
208+
209+ release_assets :
210+ name : Attach verified release assets
211+ needs : readback
212+ if : github.event.release.prerelease == false
213+ runs-on : ubuntu-latest
214+ timeout-minutes : 10
215+ permissions :
216+ contents : write
217+
218+ steps :
219+ - uses : actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
220+ with :
221+ name : verified-pypi-package
222+ path : ${{ runner.temp }}/release-artifact
81223
82- - name : Attach snippet manifest to release
224+ - name : Attach checksummed snippet manifest
225+ working-directory : ${{ runner.temp }}/release-artifact
83226 env :
84227 GH_TOKEN : ${{ github.token }}
85- run : gh release upload "${{ github.event.release.tag_name }}" artifacts/snippets/* --clobber
86-
87- - name : Publish to PyPI
88- uses : pypa/gh-action-pypi-publish@release/v1
228+ GH_REPO : ${{ github.repository }}
229+ RELEASE_TAG : ${{ github.event.release.tag_name }}
230+ run : |
231+ set -euo pipefail
232+ sha256sum -c artifact.sha256
233+ gh release upload "$RELEASE_TAG" snippets/* artifact.sha256 --clobber
0 commit comments