DefStream-RabbitMQ 0.1.2.6__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.

Potentially problematic release.


This version of DefStream-RabbitMQ might be problematic. Click here for more details.

Files changed (40) hide show
  1. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/.github/workflows/ci.yml +210 -107
  2. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3/DefStream_RabbitMQ.egg-info}/PKG-INFO +2 -2
  3. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/DefStream_RabbitMQ.egg-info/SOURCES.txt +1 -0
  4. defstream_rabbitmq-0.1.3/DefStream_RabbitMQ.egg-info/requires.txt +2 -0
  5. {defstream_rabbitmq-0.1.2.6/DefStream_RabbitMQ.egg-info → defstream_rabbitmq-0.1.3}/PKG-INFO +2 -2
  6. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/setup.py +1 -1
  7. defstream_rabbitmq-0.1.3/test/test_release_workflow.py +398 -0
  8. defstream_rabbitmq-0.1.2.6/DefStream_RabbitMQ.egg-info/requires.txt +0 -2
  9. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/.gitignore +0 -0
  10. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/DefStream_RabbitMQ.egg-info/dependency_links.txt +0 -0
  11. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/DefStream_RabbitMQ.egg-info/top_level.txt +0 -0
  12. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/LICENSE +0 -0
  13. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/README.en.md +0 -0
  14. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/README.md +0 -0
  15. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/issues/feature/1/IMPLEMENTS.md +0 -0
  16. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/issues/feature/1/README.md +0 -0
  17. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/issues/feature/1/REQUIREMENTS.md +0 -0
  18. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/issues/feature/1/TASK.md +0 -0
  19. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/issues/feature/1/ci.refs.json +0 -0
  20. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/sample/Sample.RPC.py +0 -0
  21. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/sample/Sample.Stream.py +0 -0
  22. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/sample/Sample.py +0 -0
  23. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/setup.cfg +0 -0
  24. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Configuration.py +0 -0
  25. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Connection.py +0 -0
  26. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Consumer.py +0 -0
  27. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Event.py +0 -0
  28. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Exchange.py +0 -0
  29. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Policies.py +0 -0
  30. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Queue.py +0 -0
  31. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Serializer.py +0 -0
  32. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Stream.py +0 -0
  33. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/Subscription.py +0 -0
  34. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/src/__init__.py +0 -0
  35. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/test/test_broker_methods.py +0 -0
  36. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/test/test_priorities.py +0 -0
  37. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/test/test_rabbitmq_integration.py +0 -0
  38. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/test/test_rabbitmq_worker.py +0 -0
  39. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/test/test_rpc.py +0 -0
  40. {defstream_rabbitmq-0.1.2.6 → defstream_rabbitmq-0.1.3}/test/test_rpc_integration.py +0 -0
@@ -27,6 +27,9 @@ jobs:
27
27
  outputs:
28
28
  release: ${{ steps.changes.outputs.release }}
29
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 }}
30
33
  core_ref: ${{ steps.refs.outputs.core_ref }}
31
34
  rabbitmq_ref: ${{ steps.refs.outputs.rabbitmq_ref }}
32
35
  redis_ref: ${{ steps.refs.outputs.redis_ref }}
@@ -39,10 +42,80 @@ jobs:
39
42
  uses: actions/setup-python@v7
40
43
  with:
41
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
42
112
  - name: Validate PR branch changelog
43
113
  id: changelog
44
114
  env:
45
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 }}
46
119
  run: |
47
120
  python - <<'PY'
48
121
  from json import loads
@@ -67,20 +140,38 @@ jobs:
67
140
  and pull['base']['ref'] == 'master'
68
141
  and pull['base']['repo']['full_name'] == repository
69
142
  ]
70
- if len(pulls) != 1:
71
- raise ValueError('Expected exactly one merged PR for the release commit')
72
- branch = pulls[0]['head']['ref']
73
- path = f'issues/{branch}/README.md'
74
- entry = check_output(['git', 'ls-tree', head, '--', path]).decode().split()
75
- if not entry or entry[0] not in ('100644', '100755'):
76
- raise ValueError(f'Changelog must exist as a regular file: {path}')
77
- body = check_output(['git', 'show', f'{head}:{path}'])
78
- if not body.decode('utf-8').strip():
79
- raise ValueError(f'Customer changelog is empty: {path}')
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}')
80
172
  notes = Path(environ['RUNNER_TEMP'], 'release-notes', 'README.md')
81
173
  notes.parent.mkdir(parents=True, exist_ok=True)
82
174
  notes.write_bytes(body)
83
- print(f'Customer changelog: {path}')
84
175
  with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
85
176
  output.write(f'branch={branch}\n')
86
177
  PY
@@ -129,63 +220,6 @@ jobs:
129
220
  if-no-files-found: error
130
221
  retention-days: 14
131
222
  overwrite: true
132
- - name: Check complete push range and plan version
133
- id: changes
134
- env:
135
- BEFORE: ${{ github.event.before }}
136
- RELEASE_PUSH: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
137
- run: |
138
- python - <<'PY'
139
- from os import environ
140
- from pathlib import Path
141
- from re import fullmatch
142
- from subprocess import check_output
143
-
144
- if environ['RELEASE_PUSH'] != 'true':
145
- with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
146
- output.write('release=false\n')
147
- raise SystemExit(0)
148
- series = environ['RELEASE_SERIES']
149
- if fullmatch(r'(0|[1-9]\d*)\.(0|[1-9]\d*)', series) is None:
150
- raise ValueError(f'Invalid RELEASE_SERIES: {series!r}')
151
- before = environ['BEFORE']
152
- if not before:
153
- raise ValueError('Missing push base commit')
154
- first_push = set(before) == {'0'}
155
- base = check_output(['git', 'hash-object', '-t', 'tree', '--stdin'], input='', text=True).strip() if first_push else before
156
- changed = check_output(
157
- ['git', 'diff', '--name-only', '--no-renames', base, environ['GITHUB_SHA'], '--', 'src/'],
158
- text=True,
159
- ).strip()
160
- # Anchor to the pre-push history so a rerun ignores its own release tag.
161
- tags = [] if first_push else check_output(['git', 'tag', '--merged', before], text=True).splitlines()
162
- releases = []
163
- for tag in tags:
164
- match = fullmatch(r'v?(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:\.post\d+)?', tag)
165
- if match and f'{int(match[1])}.{int(match[2])}' == series:
166
- releases.append(tuple(map(int, match.groups()[:3])) + (int(match[4] or 0),))
167
- latest = max(releases) if releases else None
168
- number = int(environ['GITHUB_RUN_NUMBER'])
169
- if number < 1:
170
- raise ValueError('Build number must be positive')
171
- if latest is not None and number <= latest[3]:
172
- raise ValueError('Build number must exceed the previous build; do not reset the workflow counter')
173
- if changed:
174
- patch = latest[2] + 1 if latest is not None else 0
175
- print(f'Source changes:\n{changed}')
176
- else:
177
- if latest is None:
178
- raise ValueError(
179
- 'No release tag in pre-push history for this series. '
180
- 'Tag the exact source commit of the existing PyPI release before retrying.'
181
- )
182
- patch = latest[2]
183
- print('No src changes: keeping MAJOR.MINOR.PATCH and increasing BUILD')
184
- version = f'{series}.{patch}.{number}'
185
- print(f'Planned version: {version}')
186
- with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
187
- output.write(f'release=true\nversion={version}\n')
188
- PY
189
223
 
190
224
  test:
191
225
  name: Test Python ${{ matrix.python }}
@@ -256,7 +290,7 @@ jobs:
256
290
  with:
257
291
  python-version: ${{ matrix.python }}
258
292
 
259
- - name: Record source commits and tag candidate locally
293
+ - name: Record source commits and configure candidate version
260
294
  env:
261
295
  RELEASE_BUILD: ${{ needs.changes.outputs.release }}
262
296
  RELEASE_VERSION: ${{ needs.changes.outputs.version }}
@@ -269,7 +303,6 @@ jobs:
269
303
  from os import environ
270
304
  from pathlib import Path
271
305
  from re import fullmatch
272
- from subprocess import check_output, run
273
306
 
274
307
  project = environ['PROJECT']
275
308
  if project not in ('DefStream', 'DefStream-RabbitMQ', 'DefStream-Redis'):
@@ -278,37 +311,70 @@ jobs:
278
311
  print('Test-only build: using the existing Git-derived development version.')
279
312
  raise SystemExit(0)
280
313
  version = environ['RELEASE_VERSION']
281
- if fullmatch(r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\.[1-9]\d*', version) is None:
314
+ if fullmatch(r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)', version) is None:
282
315
  raise ValueError(f'Invalid planned version: {version!r}')
283
- path = Path('packages') / project
284
- tag = version
285
- tags = check_output(['git', '-C', str(path), 'tag', '--list', tag], text=True).strip()
286
- if tags:
287
- tagged = check_output(['git', '-C', str(path), 'rev-parse', f'{tag}^{{commit}}'], text=True).strip()
288
- if tagged != environ['GITHUB_SHA']:
289
- raise RuntimeError(f'{tag} already points at another commit')
290
- else:
291
- run(['git', '-C', str(path), 'tag', tag, environ['GITHUB_SHA']], check=True)
292
- print(f'{project}: local candidate {tag}')
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}')
293
319
  with Path(environ['GITHUB_STEP_SUMMARY']).open('a', encoding='utf-8') as summary:
294
320
  summary.write(f'## {project} {version}\n\nSource: `{environ["GITHUB_SHA"]}`\n')
295
321
  PY
296
322
 
297
323
  - name: Install build tools
298
- run: python -m pip install --upgrade build twine
324
+ run: python -m pip install --upgrade build twine wheel
299
325
 
300
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 }}
301
331
  run: |
302
332
  mkdir -p dist dependency-wheels
303
333
  for name in DefStream DefStream-RabbitMQ DefStream-Redis; do
304
334
  if [ "$name" = "$PROJECT" ]; then
305
- python -m build --outdir "$GITHUB_WORKSPACE/dist" "packages/$name"
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
306
345
  else
307
346
  python -m build --outdir "$GITHUB_WORKSPACE/dependency-wheels" "packages/$name"
308
347
  fi
309
348
  done
310
349
  python -m twine check --strict dist/*
311
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
+
312
378
  - name: Install built wheels into a clean environment
313
379
  run: |
314
380
  python -m venv "$RUNNER_TEMP/installed"
@@ -379,20 +445,27 @@ jobs:
379
445
  env:
380
446
  PROJECT: ${{ github.event.repository.name }}
381
447
  RELEASE_VERSION: ${{ needs.changes.outputs.version }}
448
+ WHEEL_BUILD: ${{ needs.changes.outputs.build }}
449
+ NEW_VERSION: ${{ needs.changes.outputs.new_version }}
382
450
  run: |
383
451
  python - <<'PY'
384
452
  from email.parser import BytesParser
453
+ from hashlib import sha256
385
454
  from json import load
386
455
  from os import environ
387
456
  from pathlib import Path
457
+ from shutil import copy2
388
458
  from urllib.error import HTTPError
389
459
  from urllib.request import urlopen
390
460
  from zipfile import ZipFile
391
461
 
392
462
  wheels = list(Path('dist').glob('*.whl'))
393
463
  sources = list(Path('dist').glob('*.tar.gz'))
394
- if len(wheels) != 1 or len(sources) != 1:
395
- raise ValueError('Expected exactly one wheel and one source distribution')
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')
396
469
  with ZipFile(wheels[0]) as archive:
397
470
  metadata_path, = (name for name in archive.namelist() if name.endswith('.dist-info/METADATA'))
398
471
  metadata = BytesParser().parsebytes(archive.read(metadata_path))
@@ -409,34 +482,42 @@ jobs:
409
482
  except HTTPError as error:
410
483
  if error.code != 404:
411
484
  raise
412
- exists = False
485
+ published = {}
413
486
  else:
414
- expected = {wheels[0].name, sources[0].name}
415
- published = {file['filename'] for file in release['urls']}
416
- if int(environ['GITHUB_RUN_ATTEMPT']) == 1 or not expected.issubset(published):
417
- raise RuntimeError(
418
- f'{project} {version} already exists or is partially published. '
419
- 'Do not overwrite it; use a new run/version or repair the release manually.'
420
- )
421
- print(f'{project} {version}: already published; skipping this rerun')
422
- exists = True
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)
423
499
  with Path(environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output:
424
- output.write(f'publish={str(not exists).lower()}\n')
500
+ output.write(f'publish={str(any(upload.iterdir())).lower()}\n')
425
501
  output.write(f'version={version}\n')
426
502
  PY
427
503
 
428
504
  - name: Record verified release tag
429
505
  env:
430
- VERSION: ${{ steps.release.outputs.version }}
506
+ VERSION: ${{ needs.changes.outputs.tag }}
507
+ NEW_VERSION: ${{ needs.changes.outputs.new_version }}
431
508
  run: |
432
509
  tag="$VERSION"
433
510
  cd release-source
434
511
  if git show-ref --verify --quiet "refs/tags/$tag"; then
435
- if [ "$(git rev-parse "$tag^{commit}")" != "$GITHUB_SHA" ]; then
512
+ if [ "$NEW_VERSION" = "true" ] && [ "$(git rev-parse "$tag^{commit}")" != "$GITHUB_SHA" ]; then
436
513
  echo "::error::$tag already points at another commit."
437
514
  exit 1
438
515
  fi
439
516
  else
517
+ if [ "$NEW_VERSION" != "true" ]; then
518
+ echo "::error::Existing release tag is missing: $tag"
519
+ exit 1
520
+ fi
440
521
  git tag "$tag" "$GITHUB_SHA"
441
522
  fi
442
523
  git push origin "refs/tags/$tag"
@@ -455,29 +536,36 @@ jobs:
455
536
  echo "::error::Configure PYPI_API_TOKEN in the pypi environment or repository Actions secrets."
456
537
  exit 1
457
538
  fi
458
- python -m twine upload --non-interactive dist/*
539
+ python -m twine upload --non-interactive upload/*
459
540
 
460
541
  - name: Publish GitHub Release with verified distributions
461
542
  env:
462
543
  GH_TOKEN: ${{ github.token }}
463
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 }}
464
548
  run: |
465
549
  python - <<'PY'
466
550
  from json import load
551
+ from hashlib import sha256
467
552
  from os import environ
468
553
  from pathlib import Path
469
554
  from subprocess import run
555
+ from tempfile import TemporaryDirectory
470
556
  from urllib.error import HTTPError
471
557
  from urllib.parse import quote
472
558
  from urllib.request import Request, urlopen
473
559
 
474
560
  repository = environ['GITHUB_REPOSITORY']
475
561
  version = environ['VERSION']
562
+ tag = environ['RELEASE_TAG']
563
+ release = None
476
564
  notes = Path('release-notes/README.md')
477
565
  if not notes.read_text(encoding='utf-8').strip():
478
566
  raise ValueError('Customer release notes are empty')
479
567
  request = Request(
480
- f'{environ["GITHUB_API_URL"]}/repos/{repository}/releases/tags/{quote(version, safe="")}',
568
+ f'{environ["GITHUB_API_URL"]}/repos/{repository}/releases/tags/{quote(tag, safe="")}',
481
569
  headers={
482
570
  'Authorization': f'Bearer {environ["GH_TOKEN"]}',
483
571
  'Accept': 'application/vnd.github+json',
@@ -490,20 +578,35 @@ jobs:
490
578
  except HTTPError as error:
491
579
  if error.code != 404:
492
580
  raise
581
+ if environ['NEW_VERSION'] != 'true':
582
+ raise RuntimeError('Existing GitHub Release is missing') from error
493
583
  run([
494
- 'gh', 'release', 'create', version, '--repo', repository,
584
+ 'gh', 'release', 'create', tag, '--repo', repository,
495
585
  '--verify-tag', '--title', version, '--notes-file', str(notes), '--draft',
496
586
  ], check=True)
497
587
  else:
498
- if release['tag_name'] != version:
588
+ if release['tag_name'] != tag:
499
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')
500
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)
501
608
  run([
502
- 'gh', 'release', 'upload', version, '--repo', repository,
503
- '--clobber', *artifacts,
504
- ], check=True)
505
- run([
506
- 'gh', 'release', 'edit', version, '--repo', repository,
609
+ 'gh', 'release', 'edit', tag, '--repo', repository,
507
610
  '--notes-file', str(notes), '--draft=false',
508
611
  ], check=True)
509
612
  PY
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DefStream-RabbitMQ
3
- Version: 0.1.2.6
3
+ Version: 0.1.3
4
4
  Summary: RabbitMQ broker adapter for DefStream
5
5
  Home-page: https://github.com/yong5eon/DefStream-RabbitMQ
6
6
  Author: Heo Yongseon
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
- Requires-Dist: DefStream>=0.1.0
19
+ Requires-Dist: DefStream>=0.1.5
20
20
  Requires-Dist: pika>=1.3.2
21
21
  Dynamic: author
22
22
  Dynamic: classifier
@@ -32,5 +32,6 @@ test/test_broker_methods.py
32
32
  test/test_priorities.py
33
33
  test/test_rabbitmq_integration.py
34
34
  test/test_rabbitmq_worker.py
35
+ test/test_release_workflow.py
35
36
  test/test_rpc.py
36
37
  test/test_rpc_integration.py
@@ -0,0 +1,2 @@
1
+ DefStream>=0.1.5
2
+ pika>=1.3.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DefStream-RabbitMQ
3
- Version: 0.1.2.6
3
+ Version: 0.1.3
4
4
  Summary: RabbitMQ broker adapter for DefStream
5
5
  Home-page: https://github.com/yong5eon/DefStream-RabbitMQ
6
6
  Author: Heo Yongseon
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
- Requires-Dist: DefStream>=0.1.0
19
+ Requires-Dist: DefStream>=0.1.5
20
20
  Requires-Dist: pika>=1.3.2
21
21
  Dynamic: author
22
22
  Dynamic: classifier
@@ -50,7 +50,7 @@ setup(
50
50
  package_dir=PACKAGE_DIR,
51
51
  include_package_data=False,
52
52
  install_requires=[
53
- 'DefStream>=0.1.0',
53
+ 'DefStream>=0.1.5',
54
54
  'pika>=1.3.2',
55
55
  ],
56
56
  classifiers=[
@@ -0,0 +1,398 @@
1
+ from contextlib import (
2
+ closing,
3
+ redirect_stdout,
4
+ )
5
+ from hashlib import sha256
6
+ from io import (
7
+ BytesIO,
8
+ StringIO,
9
+ )
10
+ from json import dumps
11
+ from os import environ
12
+ from pathlib import Path
13
+ from subprocess import check_output
14
+ from tarfile import (
15
+ TarInfo,
16
+ open as open_tar,
17
+ )
18
+ from tempfile import TemporaryDirectory
19
+ from unittest import (
20
+ TestCase,
21
+ main,
22
+ )
23
+ from unittest.mock import patch
24
+ from urllib.error import HTTPError
25
+ from zipfile import ZipFile
26
+
27
+
28
+ WORKFLOW = Path(__file__).resolve().parents[1] / '.github' / 'workflows' / 'ci.yml'
29
+
30
+
31
+ def workflow_script(name: str) -> str:
32
+ lines = WORKFLOW.read_text(encoding='utf-8').splitlines()
33
+ start = lines.index(f' - name: {name}')
34
+ start = lines.index(" python - <<'PY'", start) + 1
35
+ end = lines.index(' PY', start)
36
+ return '\n'.join(line[10:] for line in lines[start:end])
37
+
38
+
39
+ class ReleaseWorkflowTests(TestCase):
40
+ def test_embedded_python_syntax(self) -> None:
41
+ lines = WORKFLOW.read_text(encoding='utf-8').splitlines()
42
+ for index, line in enumerate(lines):
43
+ if line == " python - <<'PY'":
44
+ end = lines.index(' PY', index + 1)
45
+ code = '\n'.join(line[10:] for line in lines[index + 1:end])
46
+ compile(code, str(WORKFLOW), 'exec')
47
+ return
48
+
49
+ def plan(
50
+ self,
51
+ tags: str,
52
+ changed: str = '',
53
+ event: str = 'true',
54
+ ) -> dict:
55
+ with TemporaryDirectory() as directory:
56
+ output = Path(directory) / 'output'
57
+ event_path = Path(directory) / 'event.json'
58
+ event_path.write_text(dumps({'pull_request': {'base': {'sha': 'pr-base'}}}))
59
+ environment = {
60
+ 'GITHUB_OUTPUT': str(output),
61
+ 'RELEASE_PUSH': event,
62
+ 'RELEASE_SERIES': '0.1',
63
+ 'BEFORE': 'before',
64
+ 'GITHUB_SHA': 'head',
65
+ 'GITHUB_RUN_NUMBER': '42',
66
+ 'GITHUB_EVENT_PATH': str(event_path),
67
+ }
68
+ with (
69
+ patch.dict(environ, environment),
70
+ patch('subprocess.check_output', side_effect=[tags, changed]) as git,
71
+ redirect_stdout(StringIO()),
72
+ ):
73
+ try:
74
+ exec(workflow_script('Check complete push range and plan version'), {})
75
+ except SystemExit as error:
76
+ self.assertEqual(error.code, 0)
77
+ self.commands = git.call_args_list
78
+ return dict(line.split('=', 1) for line in output.read_text().splitlines())
79
+
80
+ def test_code_and_packaging_changes_increase_patch(self) -> None:
81
+ for path in ['src/__init__.py', 'src/Worker/__init__.py', 'setup.py', 'pyproject.toml']:
82
+ with self.subTest(path=path):
83
+ result = self.plan('0.1.4', path)
84
+ self.assertEqual(result['version'], '0.1.5')
85
+ self.assertEqual(result['new_version'], 'true')
86
+ self.assertEqual(result['build'], '42')
87
+ return
88
+
89
+ def test_rebuild_keeps_public_version(self) -> None:
90
+ result = self.plan('0.1.3.7\n0.1.4')
91
+ self.assertEqual(result['version'], '0.1.4')
92
+ self.assertEqual(result['new_version'], 'false')
93
+ return
94
+
95
+ def test_migrating_legacy_versions_increases_patch(self) -> None:
96
+ for tag in ['0.1.4.12', 'v0.1.4.12', '0.1.4.post1', '0.1.4.0', '0.1.4.post0']:
97
+ with self.subTest(tag=tag):
98
+ result = self.plan(tag)
99
+ self.assertEqual(result['version'], '0.1.5')
100
+ self.assertEqual(result['new_version'], 'true')
101
+ return
102
+
103
+ def test_rebuild_preserves_existing_tag(self) -> None:
104
+ self.assertEqual(self.plan('v0.1.4')['tag'], 'v0.1.4')
105
+ return
106
+
107
+ def test_source_comparison_includes_previously_failed_pushes(self) -> None:
108
+ self.plan('0.1.4', 'setup.py')
109
+ self.assertIn('0.1.4', self.commands[1].args[0])
110
+ self.assertNotIn('before', self.commands[1].args[0])
111
+ return
112
+
113
+ def test_initial_release(self) -> None:
114
+ self.assertEqual(self.plan('', 'src/__init__.py')['version'], '0.1.0')
115
+ return
116
+
117
+ def test_rebuild_without_release_history_is_rejected(self) -> None:
118
+ with self.assertRaisesRegex(ValueError, 'No release tag'):
119
+ self.plan('')
120
+ return
121
+
122
+ def test_pull_request_does_not_release(self) -> None:
123
+ result = self.plan('0.1.4', event='false')
124
+ self.assertEqual(result['release'], 'false')
125
+ self.assertEqual(result['new_version'], 'false')
126
+ self.assertEqual(result['tag'], '0.1.4')
127
+ self.assertIn('pr-base', self.commands[0].args[0])
128
+ return
129
+
130
+ def test_git_pathspec_distinguishes_resources_and_packaging(self) -> None:
131
+ with TemporaryDirectory() as directory:
132
+ root = Path(directory)
133
+ check_output(['git', 'init', '--quiet', str(root)])
134
+ names = [
135
+ 'src/__init__.py',
136
+ 'src/nested/code.py',
137
+ 'src/nested/view.html',
138
+ 'setup.py',
139
+ 'setup.cfg',
140
+ 'pyproject.toml',
141
+ 'MANIFEST.in',
142
+ 'README.md',
143
+ '.github/workflows/ci.yml',
144
+ ]
145
+ for name in names:
146
+ path = root / name
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ path.write_text('test\n')
149
+ check_output(['git', '-C', str(root), 'add', '.'])
150
+ check_output([
151
+ 'git', '-C', str(root),
152
+ '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid',
153
+ 'commit', '--quiet', '-m', 'test',
154
+ ])
155
+ base = check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip()
156
+ for name in names:
157
+ (root / name).write_text('changed\n')
158
+ self.plan('0.1.4')
159
+ diff = self.commands[1].args[0]
160
+ selected = check_output(
161
+ ['git', '-C', str(root), 'diff', '--name-only', base, '--', *diff[diff.index('--') + 1:]],
162
+ text=True,
163
+ ).splitlines()
164
+ self.assertEqual(set(selected), set(names[:2] + names[3:7]))
165
+ return
166
+
167
+ def test_direct_push_generates_notes(self) -> None:
168
+ with TemporaryDirectory() as directory:
169
+ output = Path(directory) / 'output'
170
+ environment = {
171
+ 'GITHUB_OUTPUT': str(output),
172
+ 'GITHUB_SHA': 'head',
173
+ 'GITHUB_EVENT_NAME': 'push',
174
+ 'GITHUB_REPOSITORY': 'owner/project',
175
+ 'BEFORE': 'before',
176
+ 'RUNNER_TEMP': directory,
177
+ 'NEW_VERSION': 'true',
178
+ }
179
+ with (
180
+ patch.dict(environ, environment),
181
+ patch('subprocess.check_output', side_effect=[b'[[]]', b'- setup.py update (abc)\n']),
182
+ redirect_stdout(StringIO()),
183
+ ):
184
+ exec(workflow_script('Validate PR branch changelog'), {})
185
+ self.assertEqual(output.read_text().strip(), 'branch=master')
186
+ notes = (Path(directory) / 'release-notes' / 'README.md').read_text(encoding='utf-8')
187
+ self.assertIn('setup.py update', notes)
188
+ return
189
+
190
+ def test_pr_still_requires_changelog(self) -> None:
191
+ with TemporaryDirectory() as directory:
192
+ event = Path(directory) / 'event.json'
193
+ event.write_text(dumps({'pull_request': {'head': {'ref': 'fix/1'}}}))
194
+ with (
195
+ patch.dict(environ, {
196
+ 'GITHUB_EVENT_NAME': 'pull_request',
197
+ 'GITHUB_EVENT_PATH': str(event),
198
+ 'GITHUB_SHA': 'head',
199
+ 'NEW_VERSION': 'true',
200
+ }),
201
+ patch('subprocess.check_output', return_value=b''),
202
+ ):
203
+ with self.assertRaisesRegex(ValueError, 'Changelog must exist'):
204
+ exec(workflow_script('Validate PR branch changelog'), {})
205
+ return
206
+
207
+ def check_upload(
208
+ self,
209
+ published: dict,
210
+ new_version: bool = False,
211
+ ) -> tuple:
212
+ with TemporaryDirectory() as directory:
213
+ root = Path(directory)
214
+ dist = root / 'dist'
215
+ dist.mkdir()
216
+ wheel = dist / 'defstream-0.1.5-42-py3-none-any.whl'
217
+ with ZipFile(wheel, 'w') as archive:
218
+ archive.writestr('defstream-0.1.5.dist-info/METADATA', 'Name: DefStream\nVersion: 0.1.5\n')
219
+ if new_version:
220
+ (dist / 'defstream-0.1.5.tar.gz').write_bytes(b'sdist')
221
+ wheel_hash = sha256(wheel.read_bytes()).hexdigest()
222
+ files = {
223
+ name: wheel_hash if digest == 'matching' else digest
224
+ for name, digest in published.items()
225
+ }
226
+ response = StringIO(dumps({
227
+ 'urls': [
228
+ {'filename': name, 'digests': {'sha256': digest}}
229
+ for name, digest in files.items()
230
+ ],
231
+ }))
232
+ if not published:
233
+ response.close()
234
+ response = HTTPError('https://example.invalid', 404, 'not found', None, BytesIO())
235
+ with (
236
+ closing(response),
237
+ patch.dict(environ, {
238
+ 'PROJECT': 'DefStream',
239
+ 'RELEASE_VERSION': '0.1.5',
240
+ 'WHEEL_BUILD': '42',
241
+ 'NEW_VERSION': str(new_version).lower(),
242
+ 'GITHUB_OUTPUT': str(root / 'output'),
243
+ }),
244
+ patch('pathlib.Path', side_effect=lambda *args: root.joinpath(*args)),
245
+ patch('urllib.request.urlopen', side_effect=[response]),
246
+ redirect_stdout(StringIO()),
247
+ ):
248
+ exec(workflow_script('Check release status'), {})
249
+ result = dict(line.split('=', 1) for line in (root / 'output').read_text().splitlines())
250
+ return result, {path.name for path in (root / 'upload').iterdir()}
251
+
252
+ def test_new_release_uploads_wheel_and_sdist(self) -> None:
253
+ result, files = self.check_upload({}, new_version=True)
254
+ self.assertEqual(result['publish'], 'true')
255
+ self.assertEqual(files, {
256
+ 'defstream-0.1.5-42-py3-none-any.whl',
257
+ 'defstream-0.1.5.tar.gz',
258
+ })
259
+ return
260
+
261
+ def test_missing_pypi_release_does_not_enter_http_error(self) -> None:
262
+ with patch.object(HTTPError, '__enter__', side_effect=AttributeError('fp is None')) as enter:
263
+ result, files = self.check_upload({}, new_version=True)
264
+ enter.assert_not_called()
265
+ self.assertEqual(result['publish'], 'true')
266
+ self.assertEqual(len(files), 2)
267
+ return
268
+
269
+ def test_rebuild_only_uploads_new_wheel(self) -> None:
270
+ result, files = self.check_upload({'defstream-0.1.5.tar.gz': 'old-sdist-hash'})
271
+ self.assertEqual(result['publish'], 'true')
272
+ self.assertEqual(files, {'defstream-0.1.5-42-py3-none-any.whl'})
273
+ return
274
+
275
+ def test_rerun_skips_identical_published_wheel(self) -> None:
276
+ result, files = self.check_upload({'defstream-0.1.5-42-py3-none-any.whl': 'matching'})
277
+ self.assertEqual(result['publish'], 'false')
278
+ self.assertEqual(files, set())
279
+ return
280
+
281
+ def test_rerun_rejects_different_published_wheel(self) -> None:
282
+ with self.assertRaisesRegex(RuntimeError, 'refusing to overwrite'):
283
+ self.check_upload({'defstream-0.1.5-42-py3-none-any.whl': 'different'})
284
+ return
285
+
286
+ def test_rebuild_requires_existing_pypi_release(self) -> None:
287
+ with self.assertRaisesRegex(RuntimeError, 'has not been published'):
288
+ self.check_upload({})
289
+ return
290
+
291
+ def test_github_rebuild_preserves_original_notes(self) -> None:
292
+ with TemporaryDirectory() as directory:
293
+ root = Path(directory)
294
+ notes = root / 'release-notes' / 'README.md'
295
+ notes.parent.mkdir()
296
+ notes.write_text('new build notes', encoding='utf-8')
297
+ (root / 'dist').mkdir()
298
+ (root / 'dist' / 'defstream-0.1.5-42-py3-none-any.whl').write_bytes(b'wheel')
299
+ release = {'tag_name': '0.1.5', 'body': 'original release notes', 'assets': []}
300
+ for attempt in range(2):
301
+ with (
302
+ patch.dict(environ, {
303
+ 'GITHUB_REPOSITORY': 'owner/project',
304
+ 'GITHUB_API_URL': 'https://api.github.com',
305
+ 'GITHUB_SERVER_URL': 'https://github.com',
306
+ 'GITHUB_SHA': 'source-commit',
307
+ 'GH_TOKEN': 'test-token',
308
+ 'VERSION': '0.1.5',
309
+ 'RELEASE_TAG': '0.1.5',
310
+ 'WHEEL_BUILD': '42',
311
+ 'NEW_VERSION': 'false',
312
+ }),
313
+ patch('pathlib.Path', side_effect=lambda *args: root.joinpath(*args)),
314
+ patch('urllib.request.urlopen', return_value=StringIO(dumps(release))),
315
+ patch('subprocess.run') as commands,
316
+ ):
317
+ exec(workflow_script('Publish GitHub Release with verified distributions'), {})
318
+ body = notes.read_text(encoding='utf-8')
319
+ self.assertIn('original release notes', body)
320
+ self.assertEqual(body, 'original release notes')
321
+ self.assertTrue(all('--clobber' not in call.args[0] for call in commands.call_args_list))
322
+ release['body'] = body
323
+ return
324
+
325
+ def test_rebuild_reuses_existing_notes_without_branch_changelog(self) -> None:
326
+ for event_name in ['push', 'pull_request']:
327
+ with self.subTest(event=event_name), TemporaryDirectory() as directory:
328
+ root = Path(directory)
329
+ event = root / 'event.json'
330
+ event.write_text(dumps({'pull_request': {'head': {'ref': 'docs/1'}}}))
331
+ body = '## 이전 변경로그\n\n기존 내용을 그대로 유지합니다.\n'
332
+ release = {'tag_name': '0.1.4', 'body': body}
333
+ responses = [dumps(release).encode('utf-8')]
334
+ if event_name == 'push':
335
+ responses.insert(0, b'[[]]')
336
+ with (
337
+ patch.dict(environ, {
338
+ 'GITHUB_EVENT_NAME': event_name,
339
+ 'GITHUB_EVENT_PATH': str(event),
340
+ 'GITHUB_SHA': 'head',
341
+ 'GITHUB_REPOSITORY': 'owner/project',
342
+ 'NEW_VERSION': 'false',
343
+ 'RELEASE_TAG': '0.1.4',
344
+ 'GITHUB_OUTPUT': str(root / 'output'),
345
+ 'RUNNER_TEMP': directory,
346
+ }),
347
+ patch('subprocess.check_output', side_effect=responses) as commands,
348
+ redirect_stdout(StringIO()),
349
+ ):
350
+ exec(workflow_script('Validate PR branch changelog'), {})
351
+ self.assertEqual((root / 'release-notes' / 'README.md').read_bytes(), body.encode('utf-8'))
352
+ self.assertTrue(all(call.args[0][0] == 'gh' for call in commands.call_args_list))
353
+ return
354
+
355
+ def test_rebuild_rejects_empty_existing_notes(self) -> None:
356
+ with TemporaryDirectory() as directory:
357
+ event = Path(directory) / 'event.json'
358
+ event.write_text(dumps({'pull_request': {'head': {'ref': 'docs/1'}}}))
359
+ with (
360
+ patch.dict(environ, {
361
+ 'GITHUB_EVENT_NAME': 'pull_request',
362
+ 'GITHUB_EVENT_PATH': str(event),
363
+ 'GITHUB_SHA': 'head',
364
+ 'GITHUB_REPOSITORY': 'owner/project',
365
+ 'NEW_VERSION': 'false',
366
+ 'RELEASE_TAG': '0.1.4',
367
+ }),
368
+ patch('subprocess.check_output', return_value=b'{"tag_name":"0.1.4","body":""}'),
369
+ ):
370
+ with self.assertRaisesRegex(ValueError, 'missing or empty'):
371
+ exec(workflow_script('Validate PR branch changelog'), {})
372
+ return
373
+
374
+ def test_sdist_normalization_preserves_content_and_is_reproducible(self) -> None:
375
+ with TemporaryDirectory() as directory:
376
+ root = Path(directory)
377
+ dist = root / 'dist'
378
+ dist.mkdir()
379
+ for number in range(2):
380
+ with open_tar(dist / f'{number}.tar.gz', 'w:gz') as archive:
381
+ member = TarInfo('defstream-0.1.5/src/__init__.py')
382
+ member.mtime = 1700000000 + number
383
+ member.size = 4
384
+ member.pax_headers = {'mtime': str(member.mtime)}
385
+ archive.addfile(member, BytesIO(b'code'))
386
+ with (
387
+ patch.dict(environ, {'SOURCE_DATE_EPOCH': '1700000000'}),
388
+ patch('pathlib.Path', side_effect=lambda *args: root.joinpath(*args)),
389
+ ):
390
+ exec(workflow_script('Normalize source archive timestamps'), {})
391
+ self.assertEqual((dist / '0.tar.gz').read_bytes(), (dist / '1.tar.gz').read_bytes())
392
+ with open_tar(dist / '0.tar.gz', 'r:gz') as archive:
393
+ self.assertEqual(archive.extractfile(archive.getmembers()[0]).read(), b'code')
394
+ return
395
+
396
+
397
+ if __name__ == '__main__':
398
+ main()
@@ -1,2 +0,0 @@
1
- DefStream>=0.1.0
2
- pika>=1.3.2