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.
- defstream_redis-0.1.3/.github/workflows/ci.yml +612 -0
- defstream_redis-0.1.3/DefStream_Redis.egg-info/PKG-INFO +255 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/DefStream_Redis.egg-info/SOURCES.txt +6 -1
- defstream_redis-0.1.3/DefStream_Redis.egg-info/requires.txt +2 -0
- defstream_redis-0.1.3/PKG-INFO +255 -0
- defstream_redis-0.1.3/README.en.md +217 -0
- defstream_redis-0.1.3/README.md +224 -0
- defstream_redis-0.1.3/issues/fix/1/IMPLEMENTS.md +8 -0
- defstream_redis-0.1.3/issues/fix/1/README.md +24 -0
- defstream_redis-0.1.3/issues/fix/1/REQUIREMENTS.md +6 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/setup.py +1 -1
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Configuration.py +1 -1
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Connection.py +2 -2
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Consumer.py +3 -3
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Event.py +1 -1
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Queue.py +1 -1
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/__init__.py +2 -2
- defstream_redis-0.1.3/test/test_release_workflow.py +398 -0
- defstream_redis-0.1.2.3/.github/workflows/ci.yml +0 -403
- defstream_redis-0.1.2.3/DefStream_Redis.egg-info/PKG-INFO +0 -191
- defstream_redis-0.1.2.3/DefStream_Redis.egg-info/requires.txt +0 -2
- defstream_redis-0.1.2.3/PKG-INFO +0 -191
- defstream_redis-0.1.2.3/README.md +0 -160
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/.gitignore +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/DefStream_Redis.egg-info/dependency_links.txt +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/DefStream_Redis.egg-info/top_level.txt +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/LICENSE +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/sample/Sample.Stream.py +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/sample/Sample.py +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/setup.cfg +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Serializer.py +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/src/Subscription.py +0 -0
- {defstream_redis-0.1.2.3 → defstream_redis-0.1.3}/test/test_redis.py +0 -0
- {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
|