-
Notifications
You must be signed in to change notification settings - Fork 7
244 lines (224 loc) · 10.7 KB
/
Copy pathpython-sdk-publish.yml
File metadata and controls
244 lines (224 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
name: Release permit python SDK
on:
release:
types: [published]
# Read-only by default; the publish job widens its own scope.
permissions:
contents: read
env:
PYTHON_VERSION: "3.11"
jobs:
# Split into build -> scan -> publish with hard `needs:` edges rather than
# bolting a scanner step onto the front of the publish job. A step that fails
# inside the publish job can be skipped or reordered; a job that never runs
# because its dependency failed cannot. The publish job is simply unreachable
# unless the scan succeeded.
build:
name: Build distribution
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: ${{ env.PYTHON_VERSION }}
# This uv binary is the build backend for the published artifacts,
# so it is pinned here, by version and by the checksum of its archive
# (uv-x86_64-unknown-linux-gnu.tar.gz, per the uv release's .sha256
# asset), and not read from uv.lock: a Dependabot bump of the dev
# group's uv must not change the tool that builds releases. Bump both
# by hand; the uv_build bound in [build-system] must allow the version.
version: "0.12.18"
checksum: "89eadd7c76fc063887959510d5ba0ab1264dfd5f1143b925ddb73021a40acf16"
# No cache on a job whose output is published: a poisoned cache
# entry would flow straight into the release artifacts.
enable-cache: false
# The release tag is attacker-influenceable text, so it is passed through
# the environment rather than interpolated into the shell body. zizmor
# flags the `${{ }}`-in-run pattern as template-injection; env-passing is
# the canonical fix.
- name: Set version from release tag
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# Strip a leading v and validate, so a crafted tag cannot smuggle
# anything into pyproject.toml.
version="${RELEASE_TAG#v}"
# A whole-string bash match, NOT grep: grep is line-oriented, so a
# multi-line tag would pass on the strength of its first line and
# the remainder would still reach pyproject.toml.
if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-z0-9.]*)$ ]]; then
echo "::error title=Invalid release tag::'${RELEASE_TAG}' is not a valid PEP 440 version."
exit 1
fi
# --frozen: rewrite [project].version only. Re-locking here would
# resolve against the live index during a release build.
uv version --frozen "${version}"
# --no-sources: build as a consumer's resolver sees it.
- name: Build Python package
run: uv build --no-sources
# Type checkers read permit's annotations only when py.typed is in the
# installed package, and see the blocking client as blocking only through
# _sync_types.pyi. Neither is a .py file, so a packaging change can drop
# them without any import failing. Check the artifacts that get
# published: the wheel, and the sdist, since a wheel built from the sdist
# (pip install --no-binary, a distribution's packager) holds only what
# the sdist does.
- name: Check the wheel and sdist ship their type information
run: |
set -euo pipefail
uv run --no-project python - dist <<'PY'
import sys
import tarfile
import zipfile
from pathlib import Path
dist = Path(sys.argv[1])
wheels = sorted(dist.glob("*.whl"))
sdists = sorted(dist.glob("*.tar.gz"))
if len(wheels) != 1 or len(sdists) != 1:
found = [path.name for path in wheels + sdists]
sys.exit(f"expected one wheel and one sdist in {dist}, found {found}")
required = ["permit/py.typed", "permit/_sync_types.pyi"]
contents = {wheels[0]: set(zipfile.ZipFile(wheels[0]).namelist())}
# Every sdist path starts with its top-level permit-<version>/ directory.
with tarfile.open(sdists[0]) as sdist:
contents[sdists[0]] = {name.partition("/")[2] for name in sdist.getnames()}
for artifact, names in contents.items():
missing = [path for path in required if path not in names]
if missing:
sys.exit(f"{artifact.name} is missing {missing}")
print(f"{artifact.name} ships {' and '.join(required)}")
PY
- name: Upload distribution
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
retention-days: 7
scan:
name: Security Gate
runs-on: ubuntu-24.04
needs: [build]
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Python setup
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
# The uv pinned in uv.lock, so a new uv release cannot change which
# trees get scanned.
version-file: "uv.lock"
# This job resolves dependency trees for scanning and installs
# nothing, so the cache buys nothing and only adds a poisoning
# vector on a workflow that publishes artifacts.
enable-cache: false
- name: Install Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: filesystem
scan-ref: .
skip-setup-trivy: false
format: table
exit-code: "0"
scanners: vuln
trivy-config: ""
# See the same step in security.yml: this only installs Trivy, and
# hide-progress keeps its empty scan from logging a warning.
hide-progress: true
# The migration skill's sample apps pin vulnerable versions on
# purpose and are never installed (skills/tests/README.md).
skip-dirs: skills/tests/fixtures
# uv.lock pins this repository's own CI environment, not what a
# consumer installs; audit-deps.sh scans the published ranges.
skip-files: uv.lock
- name: Run dependency audit
run: bash .github/scripts/audit-deps.sh /tmp/audit
- name: Publish report to job summary
if: always()
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -uo pipefail
python .github/scripts/format_audit.py \
"runtime-ceiling=/tmp/audit/trivy-runtime-ceiling.json" \
"runtime-floor=/tmp/audit/trivy-runtime-floor.json" \
"runtime-floor-pydantic-v2=/tmp/audit/trivy-runtime-floor-pydantic-v2.json" \
"dev-ceiling=/tmp/audit/trivy-dev-ceiling.json" \
--pip-audit "runtime-ceiling=/tmp/audit/pip-audit-runtime-ceiling.json" \
--pip-audit "runtime-floor=/tmp/audit/pip-audit-runtime-floor.json" \
--pip-audit "runtime-floor-pydantic-v2=/tmp/audit/pip-audit-runtime-floor-pydantic-v2.json" \
--pip-audit "dev-ceiling=/tmp/audit/pip-audit-dev-ceiling.json" \
--context "release ${RELEASE_TAG}" \
--blocking >> "$GITHUB_STEP_SUMMARY"
# NEVER add continue-on-error here. That is the single most common way a
# release gate becomes decorative.
#
# Gates on the RUNTIME trees only. A HIGH in mypy or pytest is worth
# fixing, but it is never installed by anyone who runs `pip install
# permit` -- letting a dev-tool advisory block a security release would
# be exactly backwards. The dev tree is still rendered in the summary
# above, and the PR gate does block on it.
- name: Gate on HIGH/CRITICAL (runtime dependencies)
run: |
set -uo pipefail
python .github/scripts/format_audit.py \
"runtime-ceiling=/tmp/audit/trivy-runtime-ceiling.json" \
"runtime-floor=/tmp/audit/trivy-runtime-floor.json" \
"runtime-floor-pydantic-v2=/tmp/audit/trivy-runtime-floor-pydantic-v2.json" \
--pip-audit "runtime-ceiling=/tmp/audit/pip-audit-runtime-ceiling.json" \
--pip-audit "runtime-floor=/tmp/audit/pip-audit-runtime-floor.json" \
--pip-audit "runtime-floor-pydantic-v2=/tmp/audit/pip-audit-runtime-floor-pydantic-v2.json" \
--gate
- name: Upload audit artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-dependency-audit
path: /tmp/audit/
retention-days: 90
publish:
name: Publish to PyPI
runs-on: ubuntu-24.04
needs: [scan]
environment:
name: pypi
url: https://pypi.org/p/permit
permissions:
# id-token is what lets gh-action-pypi-publish attach PEP 740 build
# attestations. contents/pull-requests write were previously granted and
# never used -- nothing in this workflow commits or opens a PR.
id-token: write
steps:
# NODE_OPTIONS: the unzip library download-artifact v8.0.1 bundles still
# calls the deprecated Buffer() constructor, so every download prints
# DEP0005 (actions/download-artifact#484). This hides DEP0005 alone;
# drop it once a release stops printing the warning.
- name: Download distribution
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
env:
NODE_OPTIONS: --disable-warning=DEP0005
with:
name: dist
path: dist/
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
# zizmor: ignore[use-trusted-publishing]
# TODO: migrate to PyPI Trusted Publishing (OIDC) and drop this
# secret. That cannot be done from this repo alone -- it requires
# registering permitio/permit-python + this workflow filename +
# the "pypi" environment as a trusted publisher on PyPI first.
# Flipping the workflow before that is configured would break the
# next release, so it is deliberately left as a follow-up.
password: ${{ secrets.PYPI_TOKEN }}