DefStream-Redis 0.1.2.3__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. defstream_redis-0.1.3/.github/workflows/ci.yml +612 -0
  2. defstream_redis-0.1.3/DefStream_Redis.egg-info/PKG-INFO +255 -0
  3. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/DefStream_Redis.egg-info/SOURCES.txt +6 -1
  4. defstream_redis-0.1.3/DefStream_Redis.egg-info/requires.txt +2 -0
  5. defstream_redis-0.1.3/PKG-INFO +255 -0
  6. defstream_redis-0.1.3/README.en.md +217 -0
  7. defstream_redis-0.1.3/README.md +224 -0
  8. defstream_redis-0.1.3/issues/fix/1/IMPLEMENTS.md +8 -0
  9. defstream_redis-0.1.3/issues/fix/1/README.md +24 -0
  10. defstream_redis-0.1.3/issues/fix/1/REQUIREMENTS.md +6 -0
  11. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/setup.py +1 -1
  12. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Configuration.py +1 -1
  13. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Connection.py +2 -2
  14. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Consumer.py +3 -3
  15. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Event.py +1 -1
  16. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Queue.py +1 -1
  17. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/__init__.py +2 -2
  18. defstream_redis-0.1.3/test/test_release_workflow.py +398 -0
  19. defstream_redis-0.1.2.3/.github/workflows/ci.yml +0 -403
  20. defstream_redis-0.1.2.3/DefStream_Redis.egg-info/PKG-INFO +0 -191
  21. defstream_redis-0.1.2.3/DefStream_Redis.egg-info/requires.txt +0 -2
  22. defstream_redis-0.1.2.3/PKG-INFO +0 -191
  23. defstream_redis-0.1.2.3/README.md +0 -160
  24. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/.gitignore +0 -0
  25. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/DefStream_Redis.egg-info/dependency_links.txt +0 -0
  26. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/DefStream_Redis.egg-info/top_level.txt +0 -0
  27. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/LICENSE +0 -0
  28. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/sample/Sample.Stream.py +0 -0
  29. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/sample/Sample.py +0 -0
  30. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/setup.cfg +0 -0
  31. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Serializer.py +0 -0
  32. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Subscription.py +0 -0
  33. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/test/test_redis.py +0 -0
  34. {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/test/test_redis_integration.py +0 -0
@@ -0,0 +1,612 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [master]
6
+ push:
7
+ branches: [master]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ env:
13
+ RELEASE_SERIES: '0.1'
14
+
15
+ concurrency:
16
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
17
+ cancel-in-progress: false
18
+
19
+ jobs:
20
+ changes:
21
+ name: Plan release version
22
+ permissions:
23
+ contents: read
24
+ pull-requests: read
25
+ runs-on: ubuntu-latest
26
+ timeout-minutes: 5
27
+ outputs:
28
+ release: ${{ steps.changes.outputs.release }}
29
+ version: ${{ steps.changes.outputs.version }}
30
+ build: ${{ steps.changes.outputs.build }}
31
+ new_version: ${{ steps.changes.outputs.new_version }}
32
+ tag: ${{ steps.changes.outputs.tag }}
33
+ core_ref: ${{ steps.refs.outputs.core_ref }}
34
+ rabbitmq_ref: ${{ steps.refs.outputs.rabbitmq_ref }}
35
+ redis_ref: ${{ steps.refs.outputs.redis_ref }}
36
+ steps:
37
+ - uses: actions/checkout@v7
38
+ with:
39
+ fetch-depth: 0
40
+ persist-credentials: false
41
+ - name: Set up release planner Python
42
+ uses: actions/setup-python@v7
43
+ with:
44
+ python-version: '3.13'
45
+ - name: Check complete push range and plan version
46
+ id: changes
47
+ env:
48
+ BEFORE: ${{ github.event.before }}
49
+ RELEASE_PUSH: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
50
+ run: |
51
+ python - <<'PY'
52
+ from json import loads
53
+ from os import environ
54
+ from pathlib import Path
55
+ from re import fullmatch
56
+ from subprocess import check_output
57
+
58
+ release_push = environ['RELEASE_PUSH'] == 'true'
59
+ series = environ['RELEASE_SERIES']
60
+ if fullmatch(r'(0|[1-9]\d*)\.(0|[1-9]\d*)', series) is None:
61
+ raise ValueError(f'Invalid RELEASE_SERIES: {series!r}')
62
+ before = environ['BEFORE']
63
+ if not release_push:
64
+ event = loads(Path(environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8'))
65
+ before = event['pull_request']['base']['sha']
66
+ if not before:
67
+ raise ValueError('Missing push base commit')
68
+ first_push = set(before) == {'0'}
69
+ base = check_output(['git', 'hash-object', '-t', 'tree', '--stdin'], input='', text=True).strip() if first_push else before
70
+ # 재실행 시 이번 실행에서 만든 태그는 제외한다.
71
+ tags = [] if first_push else check_output(['git', 'tag', '--merged', before], text=True).splitlines()
72
+ releases = []
73
+ release_tags = {}
74
+ for tag in tags:
75
+ match = fullmatch(r'v?(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:\.post(\d+))?', tag)
76
+ if match and f'{int(match[1])}.{int(match[2])}' == series:
77
+ release = tuple(map(int, match.groups()[:3])) + (
78
+ int(match[4] or 0), int(match[5] or 0), match[4] is not None or match[5] is not None,
79
+ )
80
+ releases.append(release)
81
+ release_tags[release] = tag
82
+ latest = max(releases) if releases else None
83
+ if latest is not None:
84
+ base = release_tags[latest]
85
+ changed = check_output(
86
+ [
87
+ 'git', 'diff', '--name-only', '--no-renames', base, environ['GITHUB_SHA'],
88
+ '--', ':(glob)src/**/*.py', 'setup.py', 'setup.cfg', 'pyproject.toml', 'MANIFEST.in',
89
+ ],
90
+ text=True,
91
+ ).strip()
92
+ number = int(environ['GITHUB_RUN_NUMBER'])
93
+ if number < 1:
94
+ raise ValueError('Build number must be positive')
95
+ if latest is None and not changed:
96
+ raise ValueError('No release tag or source/packaging changes; tag the existing release before rebuilding')
97
+ migrate = latest is not None and latest[5]
98
+ new_version = bool(changed) or latest is None or migrate
99
+ if new_version:
100
+ patch = latest[2] + 1 if latest is not None else 0
101
+ print(f'New public version: source/packaging changes or initial/migration release\n{changed}')
102
+ else:
103
+ patch = latest[2]
104
+ print('Keeping MAJOR.MINOR.PATCH; publishing a new wheel build')
105
+ version = f'{series}.{patch}'
106
+ print(f'Planned version: {version}, wheel build: {number}')
107
+ with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
108
+ output.write(f'release={str(release_push).lower()}\nversion={version}\n')
109
+ output.write(f'build={number}\nnew_version={str(new_version).lower()}\n')
110
+ output.write(f'tag={version if new_version else release_tags[latest]}\n')
111
+ PY
112
+ - name: Validate PR branch changelog
113
+ id: changelog
114
+ env:
115
+ GH_TOKEN: ${{ github.token }}
116
+ BEFORE: ${{ github.event.before }}
117
+ NEW_VERSION: ${{ steps.changes.outputs.new_version }}
118
+ RELEASE_TAG: ${{ steps.changes.outputs.tag }}
119
+ run: |
120
+ python - <<'PY'
121
+ from json import loads
122
+ from os import environ
123
+ from pathlib import Path
124
+ from subprocess import check_output
125
+
126
+ head = environ['GITHUB_SHA']
127
+ if environ['GITHUB_EVENT_NAME'] == 'pull_request':
128
+ event = loads(Path(environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8'))
129
+ branch = event['pull_request']['head']['ref']
130
+ else:
131
+ repository = environ['GITHUB_REPOSITORY']
132
+ pages = loads(check_output([
133
+ 'gh', 'api', '--paginate', '--slurp',
134
+ f'repos/{repository}/commits/{head}/pulls?per_page=100',
135
+ ]))
136
+ pulls = [
137
+ pull for page in pages for pull in page
138
+ if pull['merged_at'] is not None
139
+ and pull['merge_commit_sha'] == head
140
+ and pull['base']['ref'] == 'master'
141
+ and pull['base']['repo']['full_name'] == repository
142
+ ]
143
+ if len(pulls) > 1:
144
+ raise ValueError('Multiple merged PRs found for the release commit')
145
+ branch = pulls[0]['head']['ref'] if pulls else 'master'
146
+ if environ['NEW_VERSION'] == 'false':
147
+ tag = environ['RELEASE_TAG']
148
+ release = loads(check_output([
149
+ 'gh', 'api', f'repos/{environ["GITHUB_REPOSITORY"]}/releases/tags/{tag}',
150
+ ]))
151
+ if release['tag_name'] != tag or not release.get('body', '').strip():
152
+ raise ValueError(f'Existing release notes are missing or empty: {tag}')
153
+ body = release['body'].encode('utf-8')
154
+ print(f'Reusing release notes unchanged: {tag}')
155
+ elif environ['GITHUB_EVENT_NAME'] == 'push' and not pulls:
156
+ before = environ['BEFORE']
157
+ if not before:
158
+ raise ValueError('Missing push base commit')
159
+ revision = head if set(before) == {'0'} else f'{before}..{head}'
160
+ commits = check_output(['git', 'log', '--reverse', '--format=- %s (%h)', revision])
161
+ body = '## master 직접 push\n\n'.encode('utf-8') + commits
162
+ print('Direct master push: generating release notes from commits')
163
+ else:
164
+ path = f'issues/{branch}/README.md'
165
+ entry = check_output(['git', 'ls-tree', head, '--', path]).decode().split()
166
+ if not entry or entry[0] not in ('100644', '100755'):
167
+ raise ValueError(f'Changelog must exist as a regular file: {path}')
168
+ body = check_output(['git', 'show', f'{head}:{path}'])
169
+ if not body.decode('utf-8').strip():
170
+ raise ValueError(f'Customer changelog is empty: {path}')
171
+ print(f'Customer changelog: {path}')
172
+ notes = Path(environ['RUNNER_TEMP'], 'release-notes', 'README.md')
173
+ notes.parent.mkdir(parents=True, exist_ok=True)
174
+ notes.write_bytes(body)
175
+ with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
176
+ output.write(f'branch={branch}\n')
177
+ PY
178
+ - name: Resolve dependency refs
179
+ id: refs
180
+ env:
181
+ BRANCH: ${{ steps.changelog.outputs.branch }}
182
+ run: |
183
+ python - <<'PY'
184
+ from json import loads
185
+ from os import environ
186
+ from pathlib import Path
187
+ from subprocess import check_output, run
188
+
189
+ head = environ['GITHUB_SHA']
190
+ path = f'issues/{environ["BRANCH"]}/ci.refs.json'
191
+ entry = check_output(['git', 'ls-tree', head, '--', path]).decode().split()
192
+ refs = {}
193
+ if entry:
194
+ if entry[0] not in ('100644', '100755'):
195
+ raise ValueError(f'Dependency refs must be a regular file: {path}')
196
+ refs = loads(check_output(['git', 'show', f'{head}:{path}']).decode('utf-8'))
197
+ else:
198
+ print(f'{path} not found: dependency refs default to master')
199
+ projects = {
200
+ 'DefStream': 'core_ref',
201
+ 'DefStream-RabbitMQ': 'rabbitmq_ref',
202
+ 'DefStream-Redis': 'redis_ref',
203
+ }
204
+ if not isinstance(refs, dict) or refs.keys() - projects.keys():
205
+ raise ValueError(f'Expected an object with only supported project names: {path}')
206
+ with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
207
+ for project, key in projects.items():
208
+ ref = refs.get(project, 'master')
209
+ if not isinstance(ref, str) or not ref or ref.startswith('-'):
210
+ raise ValueError(f'Invalid dependency ref for {project}: {ref!r}')
211
+ run(['git', 'check-ref-format', '--allow-onelevel', ref], check=True)
212
+ output.write(f'{key}={ref}\n')
213
+ print(f'{project} dependency ref: {ref}')
214
+ PY
215
+ - name: Preserve customer release notes
216
+ uses: actions/upload-artifact@v7
217
+ with:
218
+ name: release-notes
219
+ path: ${{ runner.temp }}/release-notes/README.md
220
+ if-no-files-found: error
221
+ retention-days: 14
222
+ overwrite: true
223
+
224
+ test:
225
+ name: Test Python ${{ matrix.python }}
226
+ needs: changes
227
+ runs-on: ubuntu-latest
228
+ timeout-minutes: 25
229
+ strategy:
230
+ fail-fast: false
231
+ matrix:
232
+ python: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
233
+ env:
234
+ PROJECT: ${{ github.event.repository.name }}
235
+ DEFSTREAM_RABBITMQ_TESTS: '1'
236
+ DEFSTREAM_REDIS_TESTS: '1'
237
+ services:
238
+ rabbitmq:
239
+ image: rabbitmq:4.1
240
+ ports:
241
+ - 5672:5672
242
+ env:
243
+ RABBITMQ_DEFAULT_USER: guest
244
+ RABBITMQ_DEFAULT_PASS: guest
245
+ RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: '-rabbit loopback_users []'
246
+ options: >-
247
+ --health-cmd "rabbitmq-diagnostics -q ping"
248
+ --health-interval 10s
249
+ --health-timeout 10s
250
+ --health-retries 12
251
+ redis:
252
+ image: redis:7.4
253
+ ports:
254
+ - 6379:6379
255
+ options: >-
256
+ --health-cmd "redis-cli ping"
257
+ --health-interval 5s
258
+ --health-timeout 5s
259
+ --health-retries 12
260
+ steps:
261
+ - name: Check out core
262
+ uses: actions/checkout@v7
263
+ with:
264
+ repository: yong5eon/DefStream
265
+ ref: ${{ github.event.repository.name == 'DefStream' && github.sha || needs.changes.outputs.core_ref }}
266
+ path: packages/DefStream
267
+ fetch-depth: 0
268
+ persist-credentials: false
269
+
270
+ - name: Check out RabbitMQ adapter
271
+ uses: actions/checkout@v7
272
+ with:
273
+ repository: yong5eon/DefStream-RabbitMQ
274
+ ref: ${{ github.event.repository.name == 'DefStream-RabbitMQ' && github.sha || needs.changes.outputs.rabbitmq_ref }}
275
+ path: packages/DefStream-RabbitMQ
276
+ fetch-depth: 0
277
+ persist-credentials: false
278
+
279
+ - name: Check out Redis adapter
280
+ uses: actions/checkout@v7
281
+ with:
282
+ repository: yong5eon/DefStream-Redis
283
+ ref: ${{ github.event.repository.name == 'DefStream-Redis' && github.sha || needs.changes.outputs.redis_ref }}
284
+ path: packages/DefStream-Redis
285
+ fetch-depth: 0
286
+ persist-credentials: false
287
+
288
+ - name: Set up Python
289
+ uses: actions/setup-python@v7
290
+ with:
291
+ python-version: ${{ matrix.python }}
292
+
293
+ - name: Record source commits and configure candidate version
294
+ env:
295
+ RELEASE_BUILD: ${{ needs.changes.outputs.release }}
296
+ RELEASE_VERSION: ${{ needs.changes.outputs.version }}
297
+ run: |
298
+ for package in packages/*; do
299
+ echo "$package: $(git -C "$package" rev-parse HEAD)"
300
+ done
301
+ echo "SOURCE_DATE_EPOCH=$(git -C "packages/$PROJECT" show -s --format=%ct HEAD)" >> "$GITHUB_ENV"
302
+ python - <<'PY'
303
+ from os import environ
304
+ from pathlib import Path
305
+ from re import fullmatch
306
+
307
+ project = environ['PROJECT']
308
+ if project not in ('DefStream', 'DefStream-RabbitMQ', 'DefStream-Redis'):
309
+ raise ValueError(f'Unexpected project: {project}')
310
+ if environ['RELEASE_BUILD'] != 'true':
311
+ print('Test-only build: using the existing Git-derived development version.')
312
+ raise SystemExit(0)
313
+ version = environ['RELEASE_VERSION']
314
+ if fullmatch(r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)', version) is None:
315
+ raise ValueError(f'Invalid planned version: {version!r}')
316
+ with Path(environ['GITHUB_ENV']).open('a', encoding='utf-8') as output:
317
+ output.write(f'RELEASE_VERSION={version}\n')
318
+ print(f'{project}: candidate {version}')
319
+ with Path(environ['GITHUB_STEP_SUMMARY']).open('a', encoding='utf-8') as summary:
320
+ summary.write(f'## {project} {version}\n\nSource: `{environ["GITHUB_SHA"]}`\n')
321
+ PY
322
+
323
+ - name: Install build tools
324
+ run: python -m pip install --upgrade build twine wheel
325
+
326
+ - name: Build source distributions and wheels
327
+ env:
328
+ RELEASE_BUILD: ${{ needs.changes.outputs.release }}
329
+ NEW_VERSION: ${{ needs.changes.outputs.new_version }}
330
+ WHEEL_BUILD: ${{ needs.changes.outputs.build }}
331
+ run: |
332
+ mkdir -p dist dependency-wheels
333
+ for name in DefStream DefStream-RabbitMQ DefStream-Redis; do
334
+ if [ "$name" = "$PROJECT" ]; then
335
+ if [ "$RELEASE_BUILD" = "true" ]; then
336
+ if [ "$NEW_VERSION" = "true" ]; then
337
+ SETUPTOOLS_SCM_PRETEND_VERSION="$RELEASE_VERSION" python -m build --outdir "$GITHUB_WORKSPACE/dist" "packages/$name"
338
+ else
339
+ SETUPTOOLS_SCM_PRETEND_VERSION="$RELEASE_VERSION" python -m build --wheel --outdir "$GITHUB_WORKSPACE/dist" "packages/$name"
340
+ fi
341
+ python -m wheel tags --build "$WHEEL_BUILD" --remove dist/*.whl
342
+ else
343
+ python -m build --outdir "$GITHUB_WORKSPACE/dist" "packages/$name"
344
+ fi
345
+ else
346
+ python -m build --outdir "$GITHUB_WORKSPACE/dependency-wheels" "packages/$name"
347
+ fi
348
+ done
349
+ python -m twine check --strict dist/*
350
+
351
+ - name: Normalize source archive timestamps
352
+ run: |
353
+ python - <<'PY'
354
+ from gzip import GzipFile
355
+ from io import BytesIO
356
+ from os import environ
357
+ from pathlib import Path
358
+ from tarfile import open as open_tar
359
+
360
+ epoch = int(environ['SOURCE_DATE_EPOCH'])
361
+ for path in Path('dist').glob('*.tar.gz'):
362
+ with BytesIO() as buffer:
363
+ with open_tar(path, 'r:gz') as source:
364
+ with GzipFile(fileobj=buffer, mode='wb', filename='', mtime=epoch) as compressed:
365
+ with open_tar(fileobj=compressed, mode='w') as target:
366
+ for member in sorted(source.getmembers(), key=lambda item: item.name):
367
+ member.mtime = epoch
368
+ member.uid = member.gid = 0
369
+ member.uname = member.gname = ''
370
+ member.pax_headers = {
371
+ key: value for key, value in member.pax_headers.items()
372
+ if key not in ('mtime', 'atime', 'ctime')
373
+ }
374
+ target.addfile(member, source.extractfile(member) if member.isfile() else None)
375
+ path.write_bytes(buffer.getvalue())
376
+ PY
377
+
378
+ - name: Install built wheels into a clean environment
379
+ run: |
380
+ python -m venv "$RUNNER_TEMP/installed"
381
+ "$RUNNER_TEMP/installed/bin/python" -m pip install dist/*.whl dependency-wheels/*.whl
382
+ "$RUNNER_TEMP/installed/bin/python" -m pip check
383
+
384
+ - name: Test installed package
385
+ run: |
386
+ cd "$RUNNER_TEMP"
387
+ "$RUNNER_TEMP/installed/bin/python" -m unittest discover \
388
+ -s "$GITHUB_WORKSPACE/packages/$PROJECT/test" -p 'test_*.py' -v
389
+
390
+ - name: Set up Node for core UI tests
391
+ if: env.PROJECT == 'DefStream'
392
+ uses: actions/setup-node@v7
393
+ with:
394
+ node-version: '22'
395
+
396
+ - name: Test core UI
397
+ if: env.PROJECT == 'DefStream'
398
+ run: node --test packages/DefStream/test/test_dashboard_ui.cjs
399
+
400
+ - name: Preserve verified release distributions
401
+ if: needs.changes.outputs.release == 'true' && matrix.python == '3.13'
402
+ uses: actions/upload-artifact@v7
403
+ with:
404
+ name: distributions
405
+ path: dist/*
406
+ if-no-files-found: error
407
+ retention-days: 14
408
+
409
+ publish:
410
+ name: Publish PyPI
411
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.changes.outputs.release == 'true'
412
+ needs: [changes, test]
413
+ runs-on: ubuntu-latest
414
+ timeout-minutes: 10
415
+ environment: pypi
416
+ permissions:
417
+ contents: write
418
+ steps:
419
+ - name: Check out release source for tagging
420
+ uses: actions/checkout@v7
421
+ with:
422
+ ref: ${{ github.sha }}
423
+ path: release-source
424
+ fetch-depth: 0
425
+
426
+ - name: Download verified distributions
427
+ uses: actions/download-artifact@v8
428
+ with:
429
+ name: distributions
430
+ path: dist
431
+
432
+ - name: Download customer release notes
433
+ uses: actions/download-artifact@v8
434
+ with:
435
+ name: release-notes
436
+ path: release-notes
437
+
438
+ - name: Set up Python
439
+ uses: actions/setup-python@v7
440
+ with:
441
+ python-version: '3.13'
442
+
443
+ - name: Check release status
444
+ id: release
445
+ env:
446
+ PROJECT: ${{ github.event.repository.name }}
447
+ RELEASE_VERSION: ${{ needs.changes.outputs.version }}
448
+ WHEEL_BUILD: ${{ needs.changes.outputs.build }}
449
+ NEW_VERSION: ${{ needs.changes.outputs.new_version }}
450
+ run: |
451
+ python - <<'PY'
452
+ from email.parser import BytesParser
453
+ from hashlib import sha256
454
+ from json import load
455
+ from os import environ
456
+ from pathlib import Path
457
+ from shutil import copy2
458
+ from urllib.error import HTTPError
459
+ from urllib.request import urlopen
460
+ from zipfile import ZipFile
461
+
462
+ wheels = list(Path('dist').glob('*.whl'))
463
+ sources = list(Path('dist').glob('*.tar.gz'))
464
+ expected_sources = 1 if environ['NEW_VERSION'] == 'true' else 0
465
+ if len(wheels) != 1 or len(sources) != expected_sources:
466
+ raise ValueError('Unexpected wheel/source distribution count for the release plan')
467
+ if wheels[0].name.split('-')[2] != environ['WHEEL_BUILD']:
468
+ raise ValueError('Wheel build tag does not match the release plan')
469
+ with ZipFile(wheels[0]) as archive:
470
+ metadata_path, = (name for name in archive.namelist() if name.endswith('.dist-info/METADATA'))
471
+ metadata = BytesParser().parsebytes(archive.read(metadata_path))
472
+ project = environ['PROJECT']
473
+ if metadata['Name'] != project:
474
+ raise ValueError(f'Unexpected distribution: {metadata["Name"]}')
475
+ version = metadata['Version']
476
+ expected_version = environ['RELEASE_VERSION']
477
+ if version != expected_version:
478
+ raise ValueError(f'Expected {expected_version}, got {version}')
479
+ try:
480
+ with urlopen(f'https://pypi.org/pypi/{project}/{version}/json', timeout=30) as response:
481
+ release = load(response)
482
+ except HTTPError as error:
483
+ if error.code != 404:
484
+ raise
485
+ published = {}
486
+ else:
487
+ published = {file['filename']: file['digests']['sha256'] for file in release['urls']}
488
+ if environ['NEW_VERSION'] != 'true' and not published:
489
+ raise RuntimeError('Cannot rebuild a version that has not been published on PyPI')
490
+ upload = Path('upload')
491
+ upload.mkdir()
492
+ for path in [*wheels, *sources]:
493
+ if path.name in published:
494
+ if sha256(path.read_bytes()).hexdigest() != published[path.name]:
495
+ raise RuntimeError(f'Published file differs; refusing to overwrite {path.name}')
496
+ print(f'Already published: {path.name}')
497
+ else:
498
+ copy2(path, upload / path.name)
499
+ with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
500
+ output.write(f'publish={str(any(upload.iterdir())).lower()}\n')
501
+ output.write(f'version={version}\n')
502
+ PY
503
+
504
+ - name: Record verified release tag
505
+ env:
506
+ VERSION: ${{ needs.changes.outputs.tag }}
507
+ NEW_VERSION: ${{ needs.changes.outputs.new_version }}
508
+ run: |
509
+ tag="$VERSION"
510
+ cd release-source
511
+ if git show-ref --verify --quiet "refs/tags/$tag"; then
512
+ if [ "$NEW_VERSION" = "true" ] && [ "$(git rev-parse "$tag^{commit}")" != "$GITHUB_SHA" ]; then
513
+ echo "::error::$tag already points at another commit."
514
+ exit 1
515
+ fi
516
+ else
517
+ if [ "$NEW_VERSION" != "true" ]; then
518
+ echo "::error::Existing release tag is missing: $tag"
519
+ exit 1
520
+ fi
521
+ git tag "$tag" "$GITHUB_SHA"
522
+ fi
523
+ git push origin "refs/tags/$tag"
524
+
525
+ - name: Install upload tool
526
+ if: steps.release.outputs.publish == 'true'
527
+ run: python -m pip install --upgrade twine
528
+
529
+ - name: Upload to PyPI
530
+ if: steps.release.outputs.publish == 'true'
531
+ env:
532
+ TWINE_USERNAME: __token__
533
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
534
+ run: |
535
+ if [ -z "$TWINE_PASSWORD" ]; then
536
+ echo "::error::Configure PYPI_API_TOKEN in the pypi environment or repository Actions secrets."
537
+ exit 1
538
+ fi
539
+ python -m twine upload --non-interactive upload/*
540
+
541
+ - name: Publish GitHub Release with verified distributions
542
+ env:
543
+ GH_TOKEN: ${{ github.token }}
544
+ VERSION: ${{ steps.release.outputs.version }}
545
+ RELEASE_TAG: ${{ needs.changes.outputs.tag }}
546
+ WHEEL_BUILD: ${{ needs.changes.outputs.build }}
547
+ NEW_VERSION: ${{ needs.changes.outputs.new_version }}
548
+ run: |
549
+ python - <<'PY'
550
+ from json import load
551
+ from hashlib import sha256
552
+ from os import environ
553
+ from pathlib import Path
554
+ from subprocess import run
555
+ from tempfile import TemporaryDirectory
556
+ from urllib.error import HTTPError
557
+ from urllib.parse import quote
558
+ from urllib.request import Request, urlopen
559
+
560
+ repository = environ['GITHUB_REPOSITORY']
561
+ version = environ['VERSION']
562
+ tag = environ['RELEASE_TAG']
563
+ release = None
564
+ notes = Path('release-notes/README.md')
565
+ if not notes.read_text(encoding='utf-8').strip():
566
+ raise ValueError('Customer release notes are empty')
567
+ request = Request(
568
+ f'{environ["GITHUB_API_URL"]}/repos/{repository}/releases/tags/{quote(tag, safe="")}',
569
+ headers={
570
+ 'Authorization': f'Bearer {environ["GH_TOKEN"]}',
571
+ 'Accept': 'application/vnd.github+json',
572
+ 'X-GitHub-Api-Version': '2022-11-28',
573
+ },
574
+ )
575
+ try:
576
+ with urlopen(request, timeout=30) as response:
577
+ release = load(response)
578
+ except HTTPError as error:
579
+ if error.code != 404:
580
+ raise
581
+ if environ['NEW_VERSION'] != 'true':
582
+ raise RuntimeError('Existing GitHub Release is missing') from error
583
+ run([
584
+ 'gh', 'release', 'create', tag, '--repo', repository,
585
+ '--verify-tag', '--title', version, '--notes-file', str(notes), '--draft',
586
+ ], check=True)
587
+ else:
588
+ if release['tag_name'] != tag:
589
+ raise ValueError('GitHub Release does not match the verified version')
590
+ if environ['NEW_VERSION'] != 'true':
591
+ if not (release['body'] or '').strip():
592
+ raise ValueError('Existing release notes are empty')
593
+ notes.write_text(release['body'], encoding='utf-8')
594
+ artifacts = sorted(str(path) for path in Path('dist').iterdir() if path.is_file())
595
+ existing = {asset['name'] for asset in release['assets']} if release is not None else set()
596
+ for artifact in artifacts:
597
+ path = Path(artifact)
598
+ if path.name in existing:
599
+ with TemporaryDirectory() as directory:
600
+ run([
601
+ 'gh', 'release', 'download', tag, '--repo', repository,
602
+ '--pattern', path.name, '--dir', directory,
603
+ ], check=True)
604
+ if sha256((Path(directory) / path.name).read_bytes()).digest() != sha256(path.read_bytes()).digest():
605
+ raise RuntimeError(f'GitHub asset differs; refusing to overwrite {path.name}')
606
+ else:
607
+ run(['gh', 'release', 'upload', tag, '--repo', repository, artifact], check=True)
608
+ run([
609
+ 'gh', 'release', 'edit', tag, '--repo', repository,
610
+ '--notes-file', str(notes), '--draft=false',
611
+ ], check=True)
612
+ PY