Repository navigation
884 lines (829 loc) · 43.8 KB
/
Copy pathnightly.yml
File metadata and controls
884 lines (829 loc) · 43.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
name: Nightly snapshot
# Builds the newest green `develop` commit into the release assets and the Python
# and Node client packages, and publishes them as the per-night prerelease
# `nightly-<yyyymmdd>-<commit>` (kept 14 days) and the moving alias `nightly`,
# never marked latest. The clients go to PyPI and npm as development versions
# that no default install picks up. See docs/project/nightly.md.
on:
schedule:
- cron: '23 3 * * *'
workflow_dispatch:
inputs:
force:
description: Publish the newest green develop commit as tonight's snapshot even when it is already the published one (a night already published is never rebuilt)
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly-snapshot
cancel-in-progress: false
env:
BRANCH: develop
TAG: nightly
# Per-night releases older than this are deleted, tag and all.
RETENTION_DAYS: 14
# The commit is built by its own copies of these scripts; a green commit that predates one cannot be built.
BUILD_SCRIPT: scripts/build-release-artifacts.sh
KERNEL_DIST_SCRIPT: scripts/build-jupyter-kernel-dist.sh
SNAPSHOT_SCRIPT: client/python/scripts/snapshot_version.py
PYPI_PROJECT: opensysml
KERNEL_PYPI_PROJECT: jupyter-opensysml-kernel
NPM_DIST_TAG: nightly
jobs:
select:
name: Pick the commit
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
sha: ${{ steps.pick.outputs.sha }}
date: ${{ steps.pick.outputs.date }}
night: ${{ steps.pick.outputs.night }}
publish: ${{ steps.pick.outputs.publish }}
steps:
- name: Check out develop
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ env.BRANCH }}
# Full history: the candidates are the branch's own commits, walked from the head.
fetch-depth: 0
- name: Find the newest green develop commit
id: pick
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
FORCE: ${{ inputs.force }}
run: |
# The tag moves before the release exists, so a night counts as done only once a published
# release sits at it; `gh release create` uploads into a draft and publishes last.
# Both lookups answer an empty match, not 404, when there is no snapshot yet, so an API failure
# stops the job rather than passing for "no snapshot". The release is recreated nightly, so it is recent.
published=$(gh api "repos/$GH_REPO/git/matching-refs/tags/$TAG" \
--jq ".[] | select(.ref == \"refs/tags/$TAG\") | .object.sha")
released=$(gh api "repos/$GH_REPO/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"$TAG\" and .draft == false) | .id")
# First-parent commits only (a merged branch's commits carry its PR's statuses); CircleCI's
# combined state is `success` only when every job passed. The walk ends at the published snapshot.
sha=
while read -r candidate; do
state=$(gh api "repos/$GH_REPO/commits/$candidate/status" --jq .state)
if [ "$state" = success ]; then
for script in "$BUILD_SCRIPT" "$KERNEL_DIST_SCRIPT" "$SNAPSHOT_SCRIPT"; do
if ! git cat-file -e "$candidate:$script" 2>/dev/null; then
state="success, but has no $script"
break
fi
done
fi
echo "$candidate: $state"
if [ "$state" = success ]; then
sha=$candidate
break
fi
if [ "$candidate" = "$published" ]; then
break
fi
done < <(git rev-list --first-parent --max-count=100 HEAD)
if [ -z "$sha" ]; then
if [ -n "$published" ]; then
echo "::notice::Nothing on $BRANCH newer than the published snapshot ${published:0:7} is green among the newest 100 commits; the previous snapshot stands."
echo "publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::No commit among the newest 100 on $BRANCH has a green CircleCI status; nothing to publish."
exit 1
fi
date=$(date -u +%Y%m%d)
night="$TAG-$date-$(git rev-parse --short "$sha")"
{
echo "sha=$sha"
echo "date=$date"
echo "night=$night"
} >> "$GITHUB_OUTPUT"
if [ "$published" = "$sha" ] && [ -n "$released" ] && [ "$FORCE" != true ]; then
echo "::notice::$TAG already points at $sha, the newest green commit on $BRANCH, with its release; nothing to rebuild."
echo "publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A published night is immutable, forced or not: the packages already on PyPI and npm
# pin the digests of its binaries, and a rebuild would not reproduce them byte for byte.
# A draft is what an interrupted upload leaves behind; it pins nothing and is replaced.
if draft=$(gh release view "$night" --json isDraft --jq .isDraft 2>/dev/null) && [ "$draft" != true ]; then
echo "::notice::$night is already published and is never rebuilt; to retry its PyPI or npm publication, re-run the failed jobs of the run that published it."
echo "publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Publishing $sha as $night and $TAG (previously ${published:-unpublished})."
echo "publish=true" >> "$GITHUB_OUTPUT"
publish:
name: Build and publish the snapshot
needs: select
if: needs.select.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
id-token: write
outputs:
tag: ${{ steps.version.outputs.tag }}
pypi: ${{ steps.version.outputs.pypi }}
npm: ${{ steps.version.outputs.npm }}
steps:
- name: Check out the selected commit
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ needs.select.outputs.sha }}
# Full history: the notes count commits since the last `v*` tag, and the
# snapshot version depends on whether the declared release is tagged.
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version-file: go.mod
cache: true
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '22'
cache: npm
cache-dependency-path: |
editors/vscode/package-lock.json
client/node/package-lock.json
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install the Python build tooling
run: python -m pip install --only-binary ":all:" pip==26.2.1 build==1.6.1 twine==7.0.0 packaging==26.3
# Before the manifest is stamped: `npm ci` requires package.json and its lock
# file to agree, and the stamp pins the platform packages at the snapshot version.
- name: Install the Node client's dependencies
run: cd client/node && npm ci --ignore-scripts
# One version per registry, derived by the commit's own script from the version
# the tree declares (see the script's docstring), and stamped into the client
# manifests of this checkout. Every binary reports the release tag.
- name: Derive the snapshot versions
id: version
env:
SNAPSHOT_DATE: ${{ needs.select.outputs.date }}
NIGHT: ${{ needs.select.outputs.night }}
run: |
commit=$(git rev-parse --short HEAD)
versions=$(python "$SNAPSHOT_SCRIPT" --date "$SNAPSHOT_DATE" --commit "$commit" --stamp)
echo "$versions"
# The select job found no release under this name; the script must derive the same one.
if [[ "$versions" != *"tag=$NIGHT"* ]]; then
echo "::error::The snapshot is named $(grep '^tag=' <<<"$versions"), not the $NIGHT the select job checked for."
exit 1
fi
{
echo "$versions"
echo "date=$(date -u -d "$SNAPSHOT_DATE" +%Y-%m-%d)"
echo "base=$(git describe --tags --abbrev=0 --match 'v[0-9]*' HEAD)"
} >> "$GITHUB_OUTPUT"
git diff --stat -- client/python/opensysml/_version.py client/node/package.json \
client/jupyter-kernel/jupyter_opensysml_kernel/_version.py
- name: Build the release artifacts
env:
VERSION: ${{ steps.version.outputs.tag }}
run: '"$BUILD_SCRIPT" dist'
# The extension is side-loaded, not published to a marketplace, so the snapshot
# is its distribution. Its version is the manifest's with the snapshot version as
# the pre-release part, so two nights' builds are told apart in VS Code and any
# stable release of the extension ranks above them. The selected commit may be
# older than this workflow, so the version is passed to the extension's own
# package script rather than through a Makefile variable it may not have yet.
- name: Package the VS Code extension
env:
VERSION: ${{ steps.version.outputs.tag }}
run: |
base=$(node -p "require('./editors/vscode/package.json').version")
expected="${base}-${VERSION}"
(cd editors/vscode && npm ci --ignore-scripts && npm run package -- "$expected" --no-update-package-json)
unzip -q -o editors/vscode/opensysml-sysml.vsix extension/package.json -d /tmp/vsix
stamped=$(node -p "require('/tmp/vsix/extension/package.json').version")
if [ "$stamped" != "$expected" ]; then
echo "::error::opensysml-sysml.vsix carries version '$stamped', not the '$expected' it is being published as."
exit 1
fi
echo "ok: opensysml-sysml.vsix carries $stamped"
cp editors/vscode/opensysml-sysml.vsix dist/
(cd dist && sha256sum opensysml-sysml.vsix >> SHA256SUMS.txt && cat SHA256SUMS.txt)
# The wheel installs this night's `sysml-grpc` on its own only through the digests
# it ships, so the binaries just built are pinned under the per-night tag in the
# table the distribution carries, as the release pipeline does for a release.
- name: Stamp the snapshot's service digests into the Python distribution
env:
VERSION: ${{ steps.version.outputs.tag }}
run: |
python client/python/scripts/pin_release_checksums.py \
--version "$VERSION" \
--from-binaries dist/grpc \
--table client/python/opensysml/release-digests.json
git diff --stat -- client/python/opensysml/release-digests.json
- name: Build the Python wheel and sdist
env:
PYPI_VERSION: ${{ steps.version.outputs.pypi }}
run: |
rm -rf client/python/dist
python -m build --outdir client/python/dist client/python/
ls -l client/python/dist
# The version is PEP 440 in canonical form, so these are the names the build tools give the files.
ls client/python/dist/"opensysml-${PYPI_VERSION}-py3-none-any.whl" \
client/python/dist/"opensysml-${PYPI_VERSION}.tar.gz" > /dev/null
python -m twine check --strict client/python/dist/*
# A broken wheel fails here rather than on PyPI: it must import at the snapshot
# version, name this night's release as the one it was built against, and pin the
# digest of every service binary built above for that release; the sdist the same.
- name: Verify the built distribution installs this snapshot's service
env:
VERSION: ${{ steps.version.outputs.tag }}
PYPI_VERSION: ${{ steps.version.outputs.pypi }}
run: |
python -m venv /tmp/verify-wheel
/tmp/verify-wheel/bin/python -m pip install --upgrade pip
/tmp/verify-wheel/bin/python -m pip install client/python/dist/*.whl
/tmp/verify-wheel/bin/python - "$VERSION" "$PYPI_VERSION" client/python/dist/opensysml-*-py3-none-any.whl client/python/dist/opensysml-[0-9]*.tar.gz <<'EOF'
import hashlib
import json
import os
import sys
import tarfile
import zipfile
import opensysml
from opensysml.binary import built_against_releases, pinned_digest
tag, version, wheel_path, sdist_path = sys.argv[1:]
if opensysml.__version__ != version:
raise SystemExit(f"Error: the installed wheel reports {opensysml.__version__}, not {version}")
if built_against_releases() != (tag,):
raise SystemExit(f"Error: the installed wheel is built against {built_against_releases()}, not ({tag!r},)")
required = {
"sysml-grpc-darwin-amd64",
"sysml-grpc-darwin-arm64",
"sysml-grpc-linux-amd64",
"sysml-grpc-linux-arm64",
"sysml-grpc-windows-amd64.exe",
}
built = {}
for asset in sorted(required):
with open(os.path.join("dist", "grpc", asset), "rb") as f:
built[asset] = hashlib.sha256(f.read()).hexdigest()
if pinned_digest(tag, asset) != built[asset]:
raise SystemExit(f"Error: the installed wheel pins {pinned_digest(tag, asset)} for {asset} of {tag}, which hashes to {built[asset]}")
def check(what, table):
digests = table.get("Open-MBEE/OpenSysML", {}).get(tag, {})
missing = sorted(required - digests.keys())
if missing:
raise SystemExit(f"Error: {what} has no complete digest pin for {tag}: {', '.join(missing)}")
wrong = sorted(a for a in required if digests[a] != built[a])
if wrong:
raise SystemExit(f"Error: {what} pins a digest for {tag} other than the one its binary hashes to: {', '.join(wrong)}")
print(f"Verified {what} embeds all five service digests for {tag}.")
with zipfile.ZipFile(wheel_path) as wheel:
with wheel.open("opensysml/release-digests.json") as member:
check("the wheel", json.load(member))
with tarfile.open(sdist_path, "r:gz") as sdist:
name = next((n for n in sdist.getnames() if n.endswith("/opensysml/release-digests.json")), None)
if name is None:
raise SystemExit("Error: the sdist ships no opensysml/release-digests.json")
check("the sdist", json.load(sdist.extractfile(name)))
print(f"opensysml {version}, built against {tag}, imported from the built wheel")
EOF
cp client/python/dist/opensysml-*.whl client/python/dist/opensysml-*.tar.gz dist/
(cd dist && sha256sum opensysml-*.whl opensysml-*.tar.gz >> SHA256SUMS.txt)
# jupyter-opensysml-kernel's platform wheels bundle this night's
# `sysml-jupyter-kernel`, and its sdist downloads it by the digests it ships, so
# the kernels just built are pinned into the table every distribution carries.
- name: Stamp the snapshot's kernel digests into the Jupyter kernel distribution
env:
VERSION: ${{ steps.version.outputs.tag }}
run: |
python client/python/scripts/pin_release_checksums.py \
--version "$VERSION" \
--from-binaries dist/jupyter \
--table client/jupyter-kernel/jupyter_opensysml_kernel/release-digests.json
git diff --stat -- client/jupyter-kernel/jupyter_opensysml_kernel/release-digests.json
- name: Build the Jupyter kernel sdist and platform wheels
env:
PYPI_VERSION: ${{ steps.version.outputs.pypi }}
NPM_VERSION: ${{ steps.version.outputs.npm }}
run: |
# jupyterlab provides `jupyter labextension build`, which bundles the
# JupyterLab extension every distribution ships (make jupyterlab-build).
python -m pip install --only-binary ":all:" jupyterlab==4.6.4
# `jupyter labextension list` reports the extension's manifest version, so the
# manifest is stamped with the snapshot's npm version first; `npm version` keeps
# the lock file in step, as `npm ci` requires.
(cd editors/jupyterlab && npm version --no-git-tag-version "$NPM_VERSION")
git diff --stat -- editors/jupyterlab/package.json editors/jupyterlab/package-lock.json
# The extension, the sdist, then one wheel per platform bundling the kernel
# from dist/jupyter; the script opens each and checks what it carries.
"$KERNEL_DIST_SCRIPT" dist/jupyter client/jupyter-kernel/dist
# The version is PEP 440 in canonical form and the build tools normalize the
# name, so these are the files.
ls client/jupyter-kernel/dist/"jupyter_opensysml_kernel-${PYPI_VERSION}.tar.gz" > /dev/null
set -- client/jupyter-kernel/dist/jupyter_opensysml_kernel-"${PYPI_VERSION}"-py3-none-*.whl
if [[ ! -f "$1" || $# -ne 5 ]]; then
echo "::error::expected five platform wheels for ${PYPI_VERSION}, found $#: $*"
exit 1
fi
stamped=$(unzip -p "$1" '*.data/data/share/jupyter/labextensions/jupyterlab-opensysml/package.json' \
| python -c 'import json, sys; print(json.load(sys.stdin)["version"])')
if [ "$stamped" != "$NPM_VERSION" ]; then
echo "::error::the labextension in $1 carries version '$stamped', not the '$NPM_VERSION' it is being published as."
exit 1
fi
echo "ok: the labextension carries $stamped"
python -m twine check --strict client/jupyter-kernel/dist/*
# Like the client's wheel: this platform's wheel must import at the snapshot
# version, name this night's release, register the `sysml` kernelspec under the
# virtualenv and start the kernel it bundles; every wheel and the sdist must pin
# the digest of every kernel built above.
- name: Verify the built kernel distributions install this snapshot's kernel
env:
VERSION: ${{ steps.version.outputs.tag }}
PYPI_VERSION: ${{ steps.version.outputs.pypi }}
run: |
python -m venv /tmp/verify-kernel-wheel
/tmp/verify-kernel-wheel/bin/python -m pip install --upgrade pip
/tmp/verify-kernel-wheel/bin/python -m pip install client/jupyter-kernel/dist/jupyter_opensysml_kernel-*-py3-none-manylinux*_x86_64*.whl
/tmp/verify-kernel-wheel/bin/python -m pip install jupyter_client
/tmp/verify-kernel-wheel/bin/python -m jupyter_opensysml_kernel kernelspec
/tmp/verify-kernel-wheel/bin/python - "$VERSION" "$PYPI_VERSION" client/jupyter-kernel/dist/jupyter_opensysml_kernel-[0-9]*.tar.gz client/jupyter-kernel/dist/jupyter_opensysml_kernel-*-py3-none-*.whl <<'EOF'
import hashlib
import json
import os
import subprocess
import sys
import tarfile
import zipfile
import jupyter_opensysml_kernel
from jupyter_opensysml_kernel.binary import built_against_releases, bundled_binary, pinned_digest
tag, version, sdist_path, *wheel_paths = sys.argv[1:]
if len(wheel_paths) != 5:
raise SystemExit(f"Error: expected five platform wheels, got {wheel_paths}")
if jupyter_opensysml_kernel.__version__ != version:
raise SystemExit(f"Error: the installed wheel reports {jupyter_opensysml_kernel.__version__}, not {version}")
if built_against_releases() != [tag]:
raise SystemExit(f"Error: the installed wheel is built against {built_against_releases()}, not [{tag!r}]")
if bundled_binary() is None:
raise SystemExit("Error: the installed wheel bundles no sysml-jupyter-kernel")
specs = json.loads(subprocess.check_output(
[sys.executable, "-m", "jupyter_client.kernelspecapp", "list", "--json"], text=True,
))["kernelspecs"]
resource_dir = specs.get("sysml", {}).get("resource_dir", "")
wanted = os.path.join(sys.prefix, "share", "jupyter", "kernels", "sysml")
if os.path.realpath(resource_dir) != os.path.realpath(wanted):
raise SystemExit(f"Error: Jupyter lists the sysml kernelspec at {resource_dir!r}, not {wanted}")
reported = subprocess.check_output([sys.executable, "-m", "jupyter_opensysml_kernel", "-version"], text=True)
if f"version {tag}" not in reported:
raise SystemExit(f"Error: the bundled kernel reports {reported!r}, not {tag}")
required = {
"sysml-jupyter-kernel-darwin-amd64",
"sysml-jupyter-kernel-darwin-arm64",
"sysml-jupyter-kernel-linux-amd64",
"sysml-jupyter-kernel-linux-arm64",
"sysml-jupyter-kernel-windows-amd64.exe",
}
built = {}
for asset in sorted(required):
with open(os.path.join("dist", "jupyter", asset), "rb") as f:
built[asset] = hashlib.sha256(f.read()).hexdigest()
if pinned_digest(tag, asset) != built[asset]:
raise SystemExit(f"Error: the installed wheel pins {pinned_digest(tag, asset)} for {asset} of {tag}, which hashes to {built[asset]}")
def check(what, table):
digests = table.get("Open-MBEE/OpenSysML", {}).get(tag, {})
missing = sorted(required - digests.keys())
if missing:
raise SystemExit(f"Error: {what} has no complete digest pin for {tag}: {', '.join(missing)}")
wrong = sorted(a for a in required if digests[a] != built[a])
if wrong:
raise SystemExit(f"Error: {what} pins a digest for {tag} other than the one its binary hashes to: {', '.join(wrong)}")
print(f"Verified {what} embeds all five kernel digests for {tag}.")
for wheel_path in wheel_paths:
with zipfile.ZipFile(wheel_path) as wheel:
with wheel.open("jupyter_opensysml_kernel/release-digests.json") as member:
check(os.path.basename(wheel_path), json.load(member))
with tarfile.open(sdist_path, "r:gz") as sdist:
name = next((n for n in sdist.getnames() if n.endswith("/jupyter_opensysml_kernel/release-digests.json")), None)
if name is None:
raise SystemExit("Error: the sdist ships no jupyter_opensysml_kernel/release-digests.json")
check("the sdist", json.load(sdist.extractfile(name)))
print(f"jupyter-opensysml-kernel {version}, built against {tag}, imported from the built wheel")
EOF
cp client/jupyter-kernel/dist/jupyter_opensysml_kernel-*.whl client/jupyter-kernel/dist/jupyter_opensysml_kernel-*.tar.gz dist/
(cd dist && sha256sum jupyter_opensysml_kernel-*.whl jupyter_opensysml_kernel-*.tar.gz >> SHA256SUMS.txt)
# The platform packages carry the binaries built above, checked against their
# sidecars by the generator; the client's stamped manifest pins them at the
# snapshot version. All seven are packed as the tarballs the npm job publishes.
- name: Build and pack the Node client packages
env:
NPM_VERSION: ${{ steps.version.outputs.npm }}
run: |
cd client/node
npm run build
npm run platform-packages -- --binaries ../../dist/grpc --wasm ../../dist/wasm --version "$NPM_VERSION"
generated=$(node -p "require('./packages/packages.json').version")
if [ "$generated" != "$NPM_VERSION" ]; then
echo "::error::The platform packages carry version '$generated', not the '$NPM_VERSION' being published."
exit 1
fi
mkdir -p ../../dist/npm
npm pack --pack-destination ../../dist/npm ./packages/sysml-wasm ./packages/sysml-grpc-* .
cd ../../dist/npm
ls -l
tarballs=(./*.tgz)
if [ "${#tarballs[@]}" -ne 7 ]; then
echo "::error::Expected 7 npm tarballs (client + 5 platform + WASM), packed ${#tarballs[@]}."
exit 1
fi
# The client tarball pins exactly what was packed beside it.
tar -xOf "openmbee-opensysml-${NPM_VERSION}.tgz" package/package.json > client-package.json
node - "$NPM_VERSION" <<'EOF'
const fs = require("node:fs");
const [version] = process.argv.slice(2);
const pkg = JSON.parse(fs.readFileSync("client-package.json", "utf8"));
const pins = Object.entries({ ...pkg.optionalDependencies, ...pkg.peerDependencies })
.filter(([name]) => name.startsWith(`${pkg.name}-`));
const wrong = pins.filter(([, pinned]) => pinned !== version).map(([name, pinned]) => `${name}@${pinned}`);
if (pkg.version !== version || pins.length !== 6 || wrong.length > 0) {
console.error(`Error: ${pkg.name}@${pkg.version} pins ${pins.length} own packages, wrong: ${wrong.join(", ") || "none"}; expected 6 at ${version}`);
process.exit(1);
}
console.log(`${pkg.name}@${pkg.version} pins its six packages at ${version}`);
EOF
rm client-package.json
# Keyless, like the stable release; the identity is this workflow's OIDC identity.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.3
- name: Sign the checksum manifest with cosign keyless
run: |
cd dist
cosign sign-blob SHA256SUMS.txt \
--bundle SHA256SUMS.txt.bundle \
--use-signing-config=false \
--yes
- name: Verify the signature against the identity the snapshot page documents
env:
WORKFLOW_REF: ${{ github.workflow_ref }}
run: |
cd dist
cosign verify-blob SHA256SUMS.txt \
--bundle SHA256SUMS.txt.bundle \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity "https://github.com/${WORKFLOW_REF}"
- name: Write the release notes
env:
GH_REPO: ${{ github.repository }}
VERSION: ${{ steps.version.outputs.tag }}
PYPI_VERSION: ${{ steps.version.outputs.pypi }}
NPM_VERSION: ${{ steps.version.outputs.npm }}
DATE: ${{ steps.version.outputs.date }}
BASE: ${{ steps.version.outputs.base }}
WORKFLOW_REF: ${{ github.workflow_ref }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
sha=$(git rev-parse HEAD)
count=$(git rev-list --count "$BASE..HEAD")
case $count in 1) commits="1 commit" ;; *) commits="$count commits" ;; esac
# GitHub renders a release body with hard line breaks, so each paragraph is one line.
{
printf '%s' "**Development snapshot of \`$BRANCH\`, not a release.** Built $DATE from"
printf '%s' " [\`${sha:0:7}\`](https://github.com/$GH_REPO/commit/$sha), $commits after"
printf '%s' " [$BASE](https://github.com/$GH_REPO/releases/tag/$BASE)"
printf '%s' " ([compare](https://github.com/$GH_REPO/compare/$BASE...$sha)), by"
printf '%s\n' " [this workflow run]($RUN_URL). Every binary reports \`$VERSION\`."
echo
printf '%s' "The snapshot is published twice: as \`$VERSION\`, which is kept for $RETENTION_DAYS days,"
printf '%s' " and as the moving alias \`$TAG\`, which the next night's snapshot replaces. It is never"
printf '%s' " the \`latest\` release; Homebrew and the Windows installer follow the stable line only."
if [[ -f dist/wasm/sysml-wasm.wasm ]]; then
printf '%s' " The \`sysml-wasm.wasm\` module and matching \`wasm_exec.js\` are available in its assets."
fi
printf '%s' " The"
printf '%s' " [snapshot page](https://runtime.opensysml.org/project/nightly/) explains what it is, how"
printf '%s' " to verify it and what to expect from it; the"
printf '%s\n' " [install guide](https://runtime.opensysml.org/guide/01-install/) covers the stable releases."
echo
printf '%s' "The Python and Node clients built from the same commit are published as development"
printf '%s' " versions that no default install picks up: \`pip install opensysml==$PYPI_VERSION\`"
printf '%s' " and \`npm install @openmbee/opensysml@$NPM_VERSION\` (the \`$NPM_DIST_TAG\` dist-tag)."
printf '%s' " Each starts this snapshot's \`sysml-grpc\`: the wheel pins the digests of the"
printf '%s\n' " \`$VERSION\` assets, and the npm platform packages carry the binaries."
printf '%s' " The Jupyter kernel built from it is \`pip install jupyter-opensysml-kernel==$PYPI_VERSION\`,"
printf '%s\n' " whose platform wheels bundle this snapshot's \`sysml-jupyter-kernel\` and whose sdist downloads it by the same pinned digests."
echo
printf '%s' "\`opensysml-sysml.vsix\` is the VS Code extension built from the same commit:"
printf '%s' " \`code --install-extension opensysml-sysml.vsix\`, with a snapshot's \`sysml-lsp\`"
printf '%s\n' " on your \`PATH\` or named by \`opensysml.server.path\`."
echo
echo "Verify a download against the signed manifest:"
echo
echo '```bash'
echo "sha256sum -c --ignore-missing SHA256SUMS.txt"
echo "cosign verify-blob SHA256SUMS.txt --bundle SHA256SUMS.txt.bundle \\"
echo " --certificate-oidc-issuer https://token.actions.githubusercontent.com \\"
echo " --certificate-identity https://github.com/$WORKFLOW_REF"
echo '```'
echo
echo "## Unreleased changes in this snapshot"
echo
printf '%s' "One line per entry; the full text is under"
printf '%s' " [\`changes/unreleased/\`](https://github.com/$GH_REPO/tree/$sha/changes/unreleased)"
printf '%s\n' " and \`CHANGELOG.md\` at this commit."
echo
# GitHub caps a release body at 125,000 characters; a one-line summary stays far under it.
python3 scripts/changelog.py summary | head -c 100000
} > notes.md
cat notes.md
# The distributions go up before the release does: a night whose release is published
# is never rebuilt, so the registry jobs must be able to re-run from what this run kept.
# A re-run of this job replaces what its earlier attempt kept, so it never trips over it.
- name: Keep the Python distribution for the PyPI job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-dist
path: client/python/dist/
if-no-files-found: error
overwrite: true
retention-days: 7
- name: Keep the Jupyter kernel distribution for the PyPI job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jupyter-kernel-dist
path: client/jupyter-kernel/dist/
if-no-files-found: error
overwrite: true
retention-days: 7
- name: Keep the npm tarballs for the npm job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-dist
path: dist/npm/*.tgz
if-no-files-found: error
overwrite: true
retention-days: 7
# The per-night release is the one the clients pin, so it is created here and kept.
- name: Publish the per-night prerelease
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ steps.version.outputs.tag }}
DATE: ${{ steps.version.outputs.date }}
run: |
sha=$(git rev-parse HEAD)
assets=(
dist/*.tar.gz
dist/*.zip
dist/*.whl
dist/opensysml-sysml.vsix
dist/SHA256SUMS.txt
dist/SHA256SUMS.txt.bundle
dist/grpc/sysml-grpc-*
dist/jupyter/sysml-jupyter-kernel-*
)
if [[ -f dist/wasm/sysml-wasm.wasm ]]; then
assets+=(
dist/wasm/sysml-wasm.wasm
dist/wasm/wasm_exec.js
dist/wasm/sysml-wasm.wasm.sha256
dist/wasm/wasm_exec.js.sha256
)
fi
for asset in "${assets[@]}"; do
if [[ ! -f "$asset" ]]; then
printf '::error::Missing release asset: %s\n' "$asset"
exit 1
fi
done
# A published night is never replaced: the packages on PyPI and npm pin its binaries.
# Only a draft, left by an interrupted upload, gives way; nothing pins a draft.
if draft=$(gh release view "$VERSION" --json isDraft --jq .isDraft 2>/dev/null); then
if [[ "$draft" != true ]]; then
echo "::error::$VERSION is already published; a night's release is never replaced."
exit 1
fi
gh release delete "$VERSION" --yes
fi
gh release create "$VERSION" \
--target "$sha" \
--title "Nightly snapshot $DATE ($VERSION)" \
--notes-file notes.md \
--prerelease \
--latest=false \
"${assets[@]}"
gh release view "$VERSION" --json url,assets --jq '.url, (.assets[].name)'
# Its own job, so that re-running the failed jobs of a run finishes a night whose release
# is published without rebuilding it: the alias is recreated from the night's own assets,
# so no asset of a previous night survives under it. `gh release delete` leaves a tag
# behind; the alias tag is moved by hand.
alias:
name: Move the alias and prune old nights
needs: [select, publish]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Check out the selected commit
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ needs.select.outputs.sha }}
- name: Recreate the alias at this night, with its assets, title and notes
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ needs.publish.outputs.tag }}
run: |
sha=$(git rev-parse HEAD)
expected=$(gh release view "$VERSION" --json assets --jq '.assets | length')
gh release download "$VERSION" --dir assets
assets=(assets/*)
if [[ "$expected" -eq 0 || "${#assets[@]}" -ne "$expected" ]]; then
echo "::error::$VERSION has $expected assets; downloaded ${#assets[@]}."
exit 1
fi
title=$(gh release view "$VERSION" --json name --jq .name)
gh release view "$VERSION" --json body --jq .body > notes.md
if gh release view "$TAG" --json tagName >/dev/null 2>&1; then
gh release delete "$TAG" --yes
fi
git tag --force "$TAG" "$sha"
git push --force origin "refs/tags/$TAG"
gh release create "$TAG" \
--target "$sha" \
--title "$title" \
--notes-file notes.md \
--prerelease \
--latest=false \
"${assets[@]}"
gh release view "$TAG" --json url --jq .url
# Only per-night releases are candidates: the stable `v*` releases and the alias
# match neither the tag form nor the age test. The previous step recreated the alias
# at this night, so sparing this night by its exact tag spares the alias's night; a
# night at the same commit under an earlier date is a different night and ages out.
# Nothing is deleted on a night that publishes nothing, so the alias never outlives
# the release it names.
- name: Delete per-night releases older than the retention period
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ needs.publish.outputs.tag }}
run: |
cutoff=$(date -u -d "$RETENTION_DAYS days ago" +%Y-%m-%dT%H:%M:%SZ)
echo "Deleting per-night releases published before $cutoff, except $VERSION, which $TAG names."
gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq '.[] | select(.tag_name | test("^nightly-[0-9]{8}-[0-9a-f]+$")) | [.tag_name, (.published_at // .created_at)] | @tsv' |
while IFS=$'\t' read -r tag published; do
if [[ "$tag" == "$VERSION" || ! "$published" < "$cutoff" ]]; then
echo "keep $tag ($published)"
continue
fi
echo "delete $tag ($published)"
gh release delete "$tag" --cleanup-tag --yes
done
# Trusted publishing: PyPI accepts this workflow's OIDC token for the `opensysml`
# project once a maintainer registers it as a trusted publisher (repository
# Open-MBEE/OpenSysML, workflow nightly.yml, environment nightly); there is no token
# to store. A development version never becomes the project's default install.
pypi:
name: Publish the Python client to PyPI
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 15
environment: nightly
permissions:
contents: read
id-token: write
steps:
- name: Fetch the Python distribution
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-dist
path: client/python/dist/
- name: Fetch the Jupyter kernel distribution
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jupyter-kernel-dist
path: client/jupyter-kernel/dist/
# A PyPI version is immutable, and two snapshots of one day share one version,
# so a second build of a day leaves the first on PyPI rather than failing.
- name: Skip a version the index already has
id: index
env:
PYPI_VERSION: ${{ needs.publish.outputs.pypi }}
run: |
ls -l client/python/dist
status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PYPI_PROJECT}/${PYPI_VERSION}/json")
case "$status" in
200)
echo "::notice::${PYPI_PROJECT} ${PYPI_VERSION} is already on PyPI; today's snapshot of the Python client stands as published."
echo "publish=false" >> "$GITHUB_OUTPUT" ;;
404)
echo "pypi.org has no ${PYPI_PROJECT} ${PYPI_VERSION}; publishing"
echo "publish=true" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::pypi.org answered HTTP ${status} for ${PYPI_PROJECT} ${PYPI_VERSION}; not publishing blind."
exit 1 ;;
esac
- name: Publish to PyPI with trusted publishing
if: steps.index.outputs.publish == 'true'
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: client/python/dist/
print-hash: true
# The kernel package is a second PyPI project under the same trusted publisher
# (repository, workflow and environment), checked and published the same way.
- name: Skip a kernel version the index already has
id: kernel-index
env:
PYPI_VERSION: ${{ needs.publish.outputs.pypi }}
run: |
ls -l client/jupyter-kernel/dist
status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${KERNEL_PYPI_PROJECT}/${PYPI_VERSION}/json")
case "$status" in
200)
echo "::notice::${KERNEL_PYPI_PROJECT} ${PYPI_VERSION} is already on PyPI; today's snapshot of the Jupyter kernel stands as published."
echo "publish=false" >> "$GITHUB_OUTPUT" ;;
404)
echo "pypi.org has no ${KERNEL_PYPI_PROJECT} ${PYPI_VERSION}; publishing"
echo "publish=true" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::pypi.org answered HTTP ${status} for ${KERNEL_PYPI_PROJECT} ${PYPI_VERSION}; not publishing blind."
exit 1 ;;
esac
- name: Publish the Jupyter kernel to PyPI with trusted publishing
if: steps.kernel-index.outputs.publish == 'true'
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: client/jupyter-kernel/dist/
print-hash: true
# Trusted publishing: npm accepts this workflow's OIDC token for each of the seven
# packages once a maintainer registers it as a trusted publisher on each (repository
# Open-MBEE/OpenSysML, workflow nightly.yml, environment nightly), and signs
# provenance for it. The `nightly` dist-tag leaves `latest` where the release left it.
npm:
name: Publish the Node client to npm
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 15
environment: nightly
permissions:
contents: read
id-token: write
steps:
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '22'
registry-url: https://registry.npmjs.org
# Trusted publishing needs npm 11.5.1 or later, newer than Node 22 bundles.
- name: Update npm
run: |
npm install -g --ignore-scripts npm@11.21.0
npm --version
node -e "const [major, minor, patch] = require('child_process').execSync('npm --version').toString().trim().split('.').map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error('npm 11.5.1 or later is required for trusted publishing'); process.exit(1); }"
- name: Fetch the npm tarballs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-dist
path: dist/npm/
# The platform and WASM packages go first, because the client pins them at this
# exact version. A version already on the registry is skipped, never replaced,
# so a second build of a day leaves the first published.
- name: Publish the seven packages under the nightly dist-tag
env:
NPM_VERSION: ${{ needs.publish.outputs.npm }}
run: |
cd dist/npm
ls -l
tarballs=(
openmbee-opensysml-wasm-"$NPM_VERSION".tgz
openmbee-opensysml-sysml-grpc-*-"$NPM_VERSION".tgz
openmbee-opensysml-"$NPM_VERSION".tgz
)
if [ "${#tarballs[@]}" -ne 7 ]; then
echo "::error::Expected 7 tarballs at $NPM_VERSION, found ${#tarballs[@]}: ${tarballs[*]}"
exit 1
fi
for tarball in "${tarballs[@]}"; do
name=$(tar -xOf "$tarball" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).name")
if npm view "${name}@${NPM_VERSION}" version > /dev/null 2>&1; then
echo "::notice::${name}@${NPM_VERSION} is already on npm; skipping it."
continue
fi
npm publish "$tarball" --access public --tag "$NPM_DIST_TAG"
done
- name: Verify the latest dist-tag did not move
env:
NPM_VERSION: ${{ needs.publish.outputs.npm }}
run: |
npm dist-tag ls @openmbee/opensysml
latest=$(npm view @openmbee/opensysml dist-tags.latest)
if [ "$latest" = "$NPM_VERSION" ]; then
echo "::error::@openmbee/opensysml's latest dist-tag now names the snapshot $NPM_VERSION; point it back at the stable release by hand."
exit 1
fi
echo "latest is $latest; $NPM_DIST_TAG is $(npm view @openmbee/opensysml "dist-tags.$NPM_DIST_TAG")"