Skip to content

Commit c4e222e

Browse files
authored
Release Python SDK 1.12.5 with well workflow support (#89)
Support production well-permit envelopes, document coverage-gated permit-to-production lookup, and harden the exact PyPI release chain.
1 parent ba974eb commit c4e222e

14 files changed

Lines changed: 650 additions & 73 deletions

.github/workflows/github-pages.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,20 @@ concurrency:
1717

1818
jobs:
1919
deploy:
20+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
2021
environment:
2122
name: github-pages
2223
url: ${{ steps.deployment.outputs.page_url }}
2324
runs-on: ubuntu-latest
25+
timeout-minutes: 15
2426
steps:
2527
- name: Checkout
26-
uses: actions/checkout@v7
28+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
29+
with:
30+
persist-credentials: false
2731

2832
- name: Set up Python
29-
uses: actions/setup-python@v7
33+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
3034
with:
3135
python-version: "3.12"
3236

@@ -39,13 +43,13 @@ jobs:
3943
run: mkdocs build
4044

4145
- name: Setup Pages
42-
uses: actions/configure-pages@v6
46+
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
4347

4448
- name: Upload artifact
45-
uses: actions/upload-pages-artifact@v5
49+
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
4650
with:
4751
path: "./site"
4852

4953
- name: Deploy to GitHub Pages
5054
id: deployment
51-
uses: actions/deploy-pages@v5
55+
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5

.github/workflows/live-tests.yml

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ name: Live API Tests
66
# secret. Runs unconditionally on every push/PR, so route health and
77
# envelope-shape coverage can never silently skip (this tier caught
88
# the 442->436 catalog change keyless).
9-
# 2. Keyed live tests — auth path + gated endpoints, only when the
10-
# OILPRICEAPI_TEST_KEY secret is available (skips loudly on forks).
9+
# 2. Keyed live tests — auth path + gated endpoints, required for exact
10+
# default-branch code and never exposed to pull requests or other refs.
1111

1212
on:
1313
push:
@@ -16,16 +16,22 @@ on:
1616
branches: [main]
1717
workflow_dispatch: {}
1818

19+
permissions:
20+
contents: read
21+
1922
jobs:
2023
live-tests:
2124
name: Live API tests
2225
runs-on: ubuntu-latest
26+
timeout-minutes: 15
2327

2428
steps:
25-
- uses: actions/checkout@v7
29+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
30+
with:
31+
persist-credentials: false
2632

2733
- name: Set up Python
28-
uses: actions/setup-python@v7
34+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
2935
with:
3036
python-version: "3.12"
3137

@@ -38,24 +44,26 @@ jobs:
3844
- name: Keyless demo smoke (always runs)
3945
run: pytest tests/integration/test_demo_contract.py -m live --no-cov -v
4046

41-
# Tier 2: full live suite, gated on the repo secret (forks skip loudly).
47+
# Tier 2: full live suite is required only for protected default-branch code.
4248
- name: Keyed live tests
49+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
4350
env:
4451
OILPRICEAPI_TEST_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }}
4552
run: |
4653
if [ -z "$OILPRICEAPI_TEST_KEY" ]; then
47-
echo "::warning::OILPRICEAPI_TEST_KEY not available (fork?); keyed live tests skipped. Keyless demo smoke above still ran."
48-
exit 0
54+
echo "::error::OILPRICEAPI_TEST_KEY is required for default-branch live tests"
55+
exit 1
4956
fi
5057
pytest tests/integration -m live --no-cov -v
5158
5259
- name: Run canonical success snippets against production
60+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
5361
env:
5462
OILPRICEAPI_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }}
5563
run: |
5664
if [ -z "$OILPRICEAPI_KEY" ]; then
57-
echo "::warning::OILPRICEAPI_TEST_KEY not available; canonical snippet smoke skipped."
58-
exit 0
65+
echo "::error::OILPRICEAPI_TEST_KEY is required for default-branch snippet smoke"
66+
exit 1
5967
fi
6068
python examples/snippets/latest_price.py
6169
python examples/snippets/history.py

.github/workflows/publish.yml

Lines changed: 178 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,52 @@ permissions:
88
contents: read
99

1010
jobs:
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

Comments
 (0)