@signalk/app-dock 1.2.1 → 1.3.0
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.
- package/README.md +20 -0
- package/package.json +6 -1
- package/public/dock.css +62 -0
- package/public/dock.js +141 -4
- package/public/index.html +4 -0
- package/.gitattributes +0 -5
- package/.github/release.yml +0 -37
- package/.github/workflows/ci.yml +0 -11
- package/.github/workflows/label-by-title.yml +0 -67
- package/.github/workflows/release-please.yml +0 -109
- package/.github/workflows/release_on_tag.yml +0 -82
- package/.github/workflows/stale.yml +0 -42
- package/.prettierignore +0 -6
- package/.prettierrc +0 -6
- package/.release-please-manifest.json +0 -3
- package/AGENTS.md +0 -134
- package/CLAUDE.md +0 -1
- package/eslint.config.js +0 -59
- package/release-please-config.json +0 -11
- package/src/configpanel/PluginConfigurationPanel.js +0 -549
- package/src/configpanel/index.js +0 -1
- package/test/plugin.test.js +0 -696
- package/webpack.config.js +0 -39
package/README.md
CHANGED
|
@@ -37,6 +37,26 @@ Tip: double-tapping a clickable element in the underlying webapp will activate t
|
|
|
37
37
|
|
|
38
38
|
Config changes made in **Plugin Config** apply automatically within ~5 seconds: structural changes (position, icon size, etc.) reload the dock page; changes to the app list hot-update in place.
|
|
39
39
|
|
|
40
|
+
## Apps on another origin
|
|
41
|
+
|
|
42
|
+
The dock notices a double-tap by listening inside each app's page, and a browser allows that only for pages on the same origin (scheme, host and port) as the dock. A page from another port or host is out of reach: the app itself, a page the app moves its frame to (such as a radar display opened directly on its own server), or one the app shows in a frame of its own. While any such page in the app on screen does not forward its taps, a small tab at the dock's edge opens the dock with a single tap.
|
|
43
|
+
|
|
44
|
+
An app on another origin can bring back double-tap by forwarding its pointerdowns to the dock:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
if (window.self !== window.top) {
|
|
48
|
+
document.addEventListener(
|
|
49
|
+
'pointerdown',
|
|
50
|
+
(e) => {
|
|
51
|
+
window.top.postMessage({ type: 'signalk-app-dock:pointerdown', x: e.clientX, y: e.clientY }, '*')
|
|
52
|
+
},
|
|
53
|
+
{ passive: true, capture: true }
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The dock accepts these messages only from a page of the app on screen that it cannot listen to itself, or a frame inside that page, and all they do is count towards a double-tap. The tab goes away once every such page has sent one. A page that loads again starts over: the tab is back until its first tap.
|
|
59
|
+
|
|
40
60
|
## Install as an app
|
|
41
61
|
|
|
42
62
|
The dock ships a web app manifest, so iOS and Android can install it to the home screen and launch it like a native app -- no Safari/Chrome chrome, no URL bar.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalk/app-dock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "macOS-style configurable app dock for switching between SignalK webapps on touch screens",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/SignalK/app-dock"
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
]
|
|
23
23
|
},
|
|
24
24
|
"main": "plugin/index.js",
|
|
25
|
+
"files": [
|
|
26
|
+
"plugin/",
|
|
27
|
+
"public/",
|
|
28
|
+
"docs/screenshots/"
|
|
29
|
+
],
|
|
25
30
|
"scripts": {
|
|
26
31
|
"build:config": "webpack",
|
|
27
32
|
"prepublishOnly": "webpack",
|
package/public/dock.css
CHANGED
|
@@ -99,6 +99,68 @@ body {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/* ─── Handle over an app on another origin ─────────────────────────────────── */
|
|
103
|
+
/* The padding is the touch target; the bar itself is drawn by ::before. */
|
|
104
|
+
#dock-handle {
|
|
105
|
+
position: fixed;
|
|
106
|
+
z-index: 5;
|
|
107
|
+
display: none;
|
|
108
|
+
padding: 12px 18px;
|
|
109
|
+
border: none;
|
|
110
|
+
background: transparent;
|
|
111
|
+
-webkit-appearance: none;
|
|
112
|
+
appearance: none;
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
touch-action: manipulation;
|
|
115
|
+
-webkit-tap-highlight-color: transparent;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#dock-handle.visible {
|
|
119
|
+
display: block;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#dock-handle::before {
|
|
123
|
+
content: '';
|
|
124
|
+
display: block;
|
|
125
|
+
width: 56px;
|
|
126
|
+
height: 6px;
|
|
127
|
+
border-radius: 3px;
|
|
128
|
+
background: rgba(255, 255, 255, 0.6);
|
|
129
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#dock-handle.pos-bottom {
|
|
133
|
+
bottom: env(safe-area-inset-bottom, 0px);
|
|
134
|
+
left: 50%;
|
|
135
|
+
transform: translateX(-50%);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#dock-handle.pos-top {
|
|
139
|
+
top: env(safe-area-inset-top, 0px);
|
|
140
|
+
left: 50%;
|
|
141
|
+
transform: translateX(-50%);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#dock-handle.pos-left {
|
|
145
|
+
left: env(safe-area-inset-left, 0px);
|
|
146
|
+
top: 50%;
|
|
147
|
+
transform: translateY(-50%);
|
|
148
|
+
padding: 18px 12px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#dock-handle.pos-right {
|
|
152
|
+
right: env(safe-area-inset-right, 0px);
|
|
153
|
+
top: 50%;
|
|
154
|
+
transform: translateY(-50%);
|
|
155
|
+
padding: 18px 12px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#dock-handle.pos-left::before,
|
|
159
|
+
#dock-handle.pos-right::before {
|
|
160
|
+
width: 6px;
|
|
161
|
+
height: 56px;
|
|
162
|
+
}
|
|
163
|
+
|
|
102
164
|
/* ─── Backdrop ──────────────────────────────────────────────────────────────── */
|
|
103
165
|
#dock-backdrop {
|
|
104
166
|
position: fixed;
|
package/public/dock.js
CHANGED
|
@@ -164,6 +164,7 @@
|
|
|
164
164
|
const $loadingLabel = document.getElementById('loading-label')
|
|
165
165
|
const $idleHint = document.getElementById('idle-hint')
|
|
166
166
|
const $idleHintText = document.getElementById('idle-hint-text')
|
|
167
|
+
const $dockHandle = document.getElementById('dock-handle')
|
|
167
168
|
|
|
168
169
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
169
170
|
let dockVisible = false
|
|
@@ -250,6 +251,7 @@
|
|
|
250
251
|
const pos = cfg.position
|
|
251
252
|
$dock.classList.add(`pos-${pos}`)
|
|
252
253
|
$dockInner.classList.add(`align-${pos}`)
|
|
254
|
+
$dockHandle.classList.add(`pos-${pos}`)
|
|
253
255
|
|
|
254
256
|
const isVertical = pos === 'left' || pos === 'right'
|
|
255
257
|
if (isVertical) $dockInner.classList.add('layout-vertical')
|
|
@@ -576,6 +578,7 @@
|
|
|
576
578
|
dockShownAt = Date.now()
|
|
577
579
|
$dock.classList.add('visible')
|
|
578
580
|
$backdrop.classList.add('visible')
|
|
581
|
+
updateHandle()
|
|
579
582
|
}
|
|
580
583
|
|
|
581
584
|
function hideDock() {
|
|
@@ -585,6 +588,7 @@
|
|
|
585
588
|
$dock.classList.remove('visible')
|
|
586
589
|
$backdrop.classList.remove('visible')
|
|
587
590
|
resetMagnification()
|
|
591
|
+
updateHandle()
|
|
588
592
|
}
|
|
589
593
|
|
|
590
594
|
$backdrop.addEventListener('click', (e) => {
|
|
@@ -649,6 +653,7 @@
|
|
|
649
653
|
}
|
|
650
654
|
|
|
651
655
|
iframes[app.url].classList.add('active')
|
|
656
|
+
updateHandle()
|
|
652
657
|
hideDock()
|
|
653
658
|
}
|
|
654
659
|
|
|
@@ -665,6 +670,9 @@
|
|
|
665
670
|
let lastTapTime = 0
|
|
666
671
|
let lastTapX = 0
|
|
667
672
|
let lastTapY = 0
|
|
673
|
+
// Coordinates are in the dock's viewport unless a tap names another space:
|
|
674
|
+
// two taps pair up only within the same one.
|
|
675
|
+
let lastTapSpace = null
|
|
668
676
|
|
|
669
677
|
function triggerDock() {
|
|
670
678
|
if (DEBUG) console.log('[Dock] triggerDock called, dockVisible=', dockVisible)
|
|
@@ -679,13 +687,13 @@
|
|
|
679
687
|
|
|
680
688
|
const DEBUG = new URLSearchParams(location.search).has('debugDock')
|
|
681
689
|
|
|
682
|
-
function handleTap(clientX, clientY) {
|
|
690
|
+
function handleTap(clientX, clientY, space = null) {
|
|
683
691
|
if (dockVisible) return
|
|
684
692
|
const now = Date.now()
|
|
685
693
|
const dx = clientX - lastTapX
|
|
686
694
|
const dy = clientY - lastTapY
|
|
687
695
|
const dist = Math.round(Math.sqrt(dx * dx + dy * dy))
|
|
688
|
-
const close = dist <= DOUBLE_TAP_SLOP
|
|
696
|
+
const close = dist <= DOUBLE_TAP_SLOP && space === lastTapSpace
|
|
689
697
|
const gap = now - lastTapTime
|
|
690
698
|
const match = gap < DOUBLE_TAP_MS && close
|
|
691
699
|
if (DEBUG) {
|
|
@@ -698,6 +706,7 @@
|
|
|
698
706
|
lastTapTime = now
|
|
699
707
|
lastTapX = clientX
|
|
700
708
|
lastTapY = clientY
|
|
709
|
+
lastTapSpace = space
|
|
701
710
|
}
|
|
702
711
|
}
|
|
703
712
|
|
|
@@ -756,12 +765,14 @@
|
|
|
756
765
|
|
|
757
766
|
function attachToIframe(frame) {
|
|
758
767
|
const wire = () => {
|
|
759
|
-
let doc
|
|
768
|
+
let doc = null
|
|
760
769
|
try {
|
|
761
770
|
doc = frame.contentDocument
|
|
762
771
|
} catch {
|
|
763
|
-
|
|
772
|
+
// cross-origin; can't reach
|
|
764
773
|
}
|
|
774
|
+
const app = appFrameOf(frame)
|
|
775
|
+
if (app) noteFrameReach(app, frame, doc !== null)
|
|
765
776
|
if (!doc || attachedDocs.has(doc)) return
|
|
766
777
|
attachedDocs.add(doc)
|
|
767
778
|
attachTapListener(doc, frame)
|
|
@@ -786,11 +797,137 @@
|
|
|
786
797
|
const nested = n.querySelectorAll && n.querySelectorAll('iframe')
|
|
787
798
|
if (nested) nested.forEach((f) => attachToIframe(f))
|
|
788
799
|
})
|
|
800
|
+
// A removed frame may have been the one keeping the handle up.
|
|
801
|
+
if (r.removedNodes.length > 0) updateHandle()
|
|
789
802
|
}
|
|
790
803
|
})
|
|
791
804
|
obs.observe(root, { childList: true, subtree: true })
|
|
792
805
|
}
|
|
793
806
|
|
|
807
|
+
// ─── Apps on another origin ──────────────────────────────────────────────────
|
|
808
|
+
// The listeners above cannot reach a page from another origin, so a
|
|
809
|
+
// double-tap over one goes unseen. That page may be the app itself or a
|
|
810
|
+
// frame inside it. Such a page can forward its pointerdowns instead, and
|
|
811
|
+
// while any page in the active app is out of reach and not forwarding, a
|
|
812
|
+
// tab at the dock's edge opens the dock with a single tap.
|
|
813
|
+
//
|
|
814
|
+
// A forwarded pointerdown is a message posted to the dock's window:
|
|
815
|
+
// window.top.postMessage({ type: 'signalk-app-dock:pointerdown', x: e.clientX, y: e.clientY }, '*')
|
|
816
|
+
// It counts only from inside a frame of the active app that the listeners
|
|
817
|
+
// cannot reach, so no tap is counted twice, and all it can do is feed the
|
|
818
|
+
// double-tap detection.
|
|
819
|
+
const FORWARDED_POINTERDOWN = 'signalk-app-dock:pointerdown'
|
|
820
|
+
const FORWARD_COALESCE_MS = 50
|
|
821
|
+
// Per app frame, the frames in it (itself included) the listeners cannot
|
|
822
|
+
// reach, and which of those have sent a pointerdown of their own since they
|
|
823
|
+
// loaded.
|
|
824
|
+
const unreachableFrames = new WeakMap()
|
|
825
|
+
const forwardingFrames = new WeakSet()
|
|
826
|
+
let lastForwardedAt = 0
|
|
827
|
+
|
|
828
|
+
// The app frame a frame belongs to: the frame itself, or the one holding
|
|
829
|
+
// the document it sits in, found through same-origin parents.
|
|
830
|
+
function appFrameOf(frame) {
|
|
831
|
+
let f = frame
|
|
832
|
+
while (f && f.parentElement !== $iframeContainer) {
|
|
833
|
+
const win = f.ownerDocument && f.ownerDocument.defaultView
|
|
834
|
+
f = win ? win.frameElement : null
|
|
835
|
+
}
|
|
836
|
+
return f
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Runs on every load of a frame in an app. A new document starts over:
|
|
840
|
+
// whether it forwards is not known until it does, and an app frame's new
|
|
841
|
+
// document brings its own frames.
|
|
842
|
+
function noteFrameReach(app, frame, reachable) {
|
|
843
|
+
forwardingFrames.delete(frame)
|
|
844
|
+
let frames = unreachableFrames.get(app)
|
|
845
|
+
if (frame === app || !frames) {
|
|
846
|
+
frames = new Set()
|
|
847
|
+
unreachableFrames.set(app, frames)
|
|
848
|
+
}
|
|
849
|
+
if (reachable) frames.delete(frame)
|
|
850
|
+
else frames.add(frame)
|
|
851
|
+
updateHandle()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Frames removed from their document, or left in one that has been
|
|
855
|
+
// navigated away, are dropped as they are found.
|
|
856
|
+
function liveUnreachableFrames(app) {
|
|
857
|
+
const frames = (app && unreachableFrames.get(app)) || new Set()
|
|
858
|
+
for (const f of frames) {
|
|
859
|
+
if (!f.isConnected || !f.ownerDocument.defaultView) frames.delete(f)
|
|
860
|
+
}
|
|
861
|
+
return frames
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function updateHandle() {
|
|
865
|
+
const app = $iframeContainer.querySelector('iframe.active')
|
|
866
|
+
let show = false
|
|
867
|
+
if (!dockVisible) {
|
|
868
|
+
for (const f of liveUnreachableFrames(app)) {
|
|
869
|
+
if (!forwardingFrames.has(f)) show = true
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
$dockHandle.classList.toggle('visible', show)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Where a frame's content starts in the dock's viewport, adding up the
|
|
876
|
+
// frames it sits in.
|
|
877
|
+
function offsetInDock(frame) {
|
|
878
|
+
let x = 0
|
|
879
|
+
let y = 0
|
|
880
|
+
for (let f = frame; f; ) {
|
|
881
|
+
const r = f.getBoundingClientRect()
|
|
882
|
+
x += r.left
|
|
883
|
+
y += r.top
|
|
884
|
+
if (f.parentElement === $iframeContainer) break
|
|
885
|
+
const win = f.ownerDocument.defaultView
|
|
886
|
+
f = win ? win.frameElement : null
|
|
887
|
+
}
|
|
888
|
+
return { x, y }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function isInsideFrame(source, frameWindow) {
|
|
892
|
+
for (let w = source; w; w = w.parent) {
|
|
893
|
+
if (w === frameWindow) return true
|
|
894
|
+
if (w === w.parent) return false
|
|
895
|
+
}
|
|
896
|
+
return false
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
window.addEventListener('message', (e) => {
|
|
900
|
+
const data = e.data
|
|
901
|
+
if (!data || data.type !== FORWARDED_POINTERDOWN) return
|
|
902
|
+
if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return
|
|
903
|
+
const app = $iframeContainer.querySelector('iframe.active')
|
|
904
|
+
if (!app || !e.source) return
|
|
905
|
+
let from = null
|
|
906
|
+
for (const f of liveUnreachableFrames(app)) {
|
|
907
|
+
if (isInsideFrame(e.source, f.contentWindow)) from = f
|
|
908
|
+
}
|
|
909
|
+
if (!from) return
|
|
910
|
+
// A frame inside the page forwarding says nothing about taps on the page
|
|
911
|
+
// around it, so only the page's own message stops the handle.
|
|
912
|
+
if (e.source === from.contentWindow) {
|
|
913
|
+
forwardingFrames.add(from)
|
|
914
|
+
updateHandle()
|
|
915
|
+
}
|
|
916
|
+
const now = Date.now()
|
|
917
|
+
if (now - lastForwardedAt < FORWARD_COALESCE_MS) return
|
|
918
|
+
lastForwardedAt = now
|
|
919
|
+
// A frame inside the page sits somewhere the dock cannot see, so its
|
|
920
|
+
// taps keep their own coordinates and pair up only with each other.
|
|
921
|
+
if (e.source === from.contentWindow) {
|
|
922
|
+
const o = offsetInDock(from)
|
|
923
|
+
handleTap(data.x + o.x, data.y + o.y)
|
|
924
|
+
} else {
|
|
925
|
+
handleTap(data.x, data.y, e.source)
|
|
926
|
+
}
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
$dockHandle.addEventListener('click', triggerDock)
|
|
930
|
+
|
|
794
931
|
attachTapListener(document, null)
|
|
795
932
|
attachedDocs.add(document)
|
|
796
933
|
scanAndObserveDoc(document)
|
package/public/index.html
CHANGED
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
<div id="idle-hint-text"></div>
|
|
29
29
|
</div>
|
|
30
30
|
|
|
31
|
+
<!-- Tab that opens the dock over an app on another origin, where a
|
|
32
|
+
double-tap cannot be detected -->
|
|
33
|
+
<button id="dock-handle" type="button" aria-label="Open the dock"></button>
|
|
34
|
+
|
|
31
35
|
<!-- Dimming overlay shown when dock is open; tap to close -->
|
|
32
36
|
<div id="dock-backdrop"></div>
|
|
33
37
|
|
package/.gitattributes
DELETED
package/.github/release.yml
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# Categories for the release notes GitHub generates for each tag, built from
|
|
2
|
-
# the titles of the PRs merged since the previous one. release-please asks
|
|
3
|
-
# GitHub for these notes (changelog-type: github in
|
|
4
|
-
# release-please-config.json), and so does release_on_tag.yml for a
|
|
5
|
-
# pre-release tag pushed by hand.
|
|
6
|
-
#
|
|
7
|
-
# A PR lands in the first category it matches, so the catch-all is last.
|
|
8
|
-
# workflows/label-by-title.yml sets the label from the PR title's type, and
|
|
9
|
-
# this file and that workflow name the same labels. A label set by hand
|
|
10
|
-
# places a PR the same way until its title is edited; an unlabelled one
|
|
11
|
-
# still appears, under Other.
|
|
12
|
-
changelog:
|
|
13
|
-
exclude:
|
|
14
|
-
labels:
|
|
15
|
-
- skip-changelog
|
|
16
|
-
# release-please's own release PR, merged right before each tag, which
|
|
17
|
-
# would otherwise list itself.
|
|
18
|
-
- 'autorelease: pending'
|
|
19
|
-
- 'autorelease: tagged'
|
|
20
|
-
categories:
|
|
21
|
-
- title: 🚀 Features
|
|
22
|
-
labels:
|
|
23
|
-
- feature
|
|
24
|
-
- enhancement
|
|
25
|
-
- title: 🐛 Fixes
|
|
26
|
-
labels:
|
|
27
|
-
- bug
|
|
28
|
-
- fix
|
|
29
|
-
- title: 📖 Documentation
|
|
30
|
-
labels:
|
|
31
|
-
- documentation
|
|
32
|
-
- title: 📦 Dependencies
|
|
33
|
-
labels:
|
|
34
|
-
- dependencies
|
|
35
|
-
- title: Other
|
|
36
|
-
labels:
|
|
37
|
-
- '*'
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
name: label-by-title
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
pull_request_target:
|
|
5
|
-
types: [opened, edited, reopened]
|
|
6
|
-
|
|
7
|
-
permissions: {}
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
# .github/release.yml sorts the release notes by label, and a pull request
|
|
11
|
-
# opened from the command line carries none. The label follows the title's
|
|
12
|
-
# conventional-commit type. Only the title is read, with no checkout and
|
|
13
|
-
# nothing from the pull request run here, which is what makes
|
|
14
|
-
# pull_request_target safe to use and what lets a pull request from a fork
|
|
15
|
-
# be labelled.
|
|
16
|
-
#
|
|
17
|
-
# `edited` also fires for a body or base-branch edit; those leave the labels
|
|
18
|
-
# alone, so a label set by hand stays until the title changes.
|
|
19
|
-
label:
|
|
20
|
-
if: ${{ github.event.action != 'edited' || github.event.changes.title }}
|
|
21
|
-
# One run per pull request at a time: two overlapping runs would each read
|
|
22
|
-
# the labels, then each edit, and could leave two managed labels behind.
|
|
23
|
-
# Set on the job, after the condition above, so that a body edit, which
|
|
24
|
-
# skips the job, cannot cancel a title edit's run.
|
|
25
|
-
concurrency:
|
|
26
|
-
group: label-by-title-${{ github.event.pull_request.number }}
|
|
27
|
-
cancel-in-progress: true
|
|
28
|
-
runs-on: ubuntu-latest
|
|
29
|
-
timeout-minutes: 5
|
|
30
|
-
permissions:
|
|
31
|
-
pull-requests: write
|
|
32
|
-
steps:
|
|
33
|
-
- env:
|
|
34
|
-
GH_TOKEN: ${{ github.token }}
|
|
35
|
-
REPO: ${{ github.repository }}
|
|
36
|
-
NUMBER: ${{ github.event.pull_request.number }}
|
|
37
|
-
TITLE: ${{ github.event.pull_request.title }}
|
|
38
|
-
run: |
|
|
39
|
-
# The labels this workflow owns: one of them at a time, so a title
|
|
40
|
-
# edited from feat to fix swaps the label rather than adding one.
|
|
41
|
-
# A type not listed gets none of them and lands under Other.
|
|
42
|
-
managed="enhancement bug documentation skip-changelog"
|
|
43
|
-
conventional='^([a-z]+)(\([^)]*\))?!?: '
|
|
44
|
-
type=""
|
|
45
|
-
if [[ "$TITLE" =~ $conventional ]]; then
|
|
46
|
-
type="${BASH_REMATCH[1]}"
|
|
47
|
-
fi
|
|
48
|
-
case "$type" in
|
|
49
|
-
feat|perf) wanted=enhancement ;;
|
|
50
|
-
fix) wanted=bug ;;
|
|
51
|
-
docs) wanted=documentation ;;
|
|
52
|
-
build|ci|test|chore|refactor|style) wanted=skip-changelog ;;
|
|
53
|
-
*) wanted="" ;;
|
|
54
|
-
esac
|
|
55
|
-
current=$(gh pr view "$NUMBER" --repo "$REPO" --json labels --jq '[.labels[].name] | join(" ")')
|
|
56
|
-
args=()
|
|
57
|
-
for label in $managed; do
|
|
58
|
-
case " $current " in *" $label "*) has=1 ;; *) has=0 ;; esac
|
|
59
|
-
if [ "$label" = "$wanted" ] && [ "$has" = 0 ]; then args+=(--add-label "$label"); fi
|
|
60
|
-
if [ "$label" != "$wanted" ] && [ "$has" = 1 ]; then args+=(--remove-label "$label"); fi
|
|
61
|
-
done
|
|
62
|
-
if [ "${#args[@]}" = 0 ]; then
|
|
63
|
-
echo "#$NUMBER '$TITLE': nothing to change (${wanted:-no managed label})"
|
|
64
|
-
else
|
|
65
|
-
gh pr edit "$NUMBER" --repo "$REPO" "${args[@]}"
|
|
66
|
-
echo "#$NUMBER '$TITLE': ${args[*]}"
|
|
67
|
-
fi
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
name: release-please
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
# The gate below runs release-please only for a push that carries a commit
|
|
7
|
-
# users get, so a release pull request it has already opened is not
|
|
8
|
-
# refreshed by a docs or ci merge, and a config change is exactly such a
|
|
9
|
-
# merge. This trigger re-runs it on demand, which is what rewrites an
|
|
10
|
-
# outstanding release pull request against the current configuration.
|
|
11
|
-
workflow_dispatch:
|
|
12
|
-
|
|
13
|
-
permissions: {}
|
|
14
|
-
|
|
15
|
-
# One release-please run at a time: a push and a manual run, or two pushes
|
|
16
|
-
# landing close together, would each read the release pull request's state
|
|
17
|
-
# and each write it, and the later write could undo the earlier one.
|
|
18
|
-
# cancel-in-progress is false so a run already creating a tag or dispatching
|
|
19
|
-
# the publish is never killed part-way. A run waiting behind it can still be
|
|
20
|
-
# replaced by a newer one, which costs nothing here: release-please derives
|
|
21
|
-
# the release pull request from the commit range, so the newest run produces
|
|
22
|
-
# the same result the superseded one would have.
|
|
23
|
-
#
|
|
24
|
-
# The group is a constant rather than keyed on github.ref: the action is
|
|
25
|
-
# given no target-branch, so every run, including a dispatch launched from
|
|
26
|
-
# some other branch, acts on the default branch's release pull request.
|
|
27
|
-
concurrency:
|
|
28
|
-
group: release-please
|
|
29
|
-
cancel-in-progress: false
|
|
30
|
-
|
|
31
|
-
jobs:
|
|
32
|
-
# GitHub's release notes are never empty, so release-please would propose
|
|
33
|
-
# a release after every merge, a docs or ci change included. Only a push
|
|
34
|
-
# carrying a change users get, or the merge of the release PR itself,
|
|
35
|
-
# which is what creates the tag, goes on to it.
|
|
36
|
-
#
|
|
37
|
-
# Every commit in the push is checked, so a merge commit's PR commits count
|
|
38
|
-
# through their own subjects. A breaking change counts whatever its type, a
|
|
39
|
-
# `revert:` because what it undoes has shipped, and a `Release-As:` footer,
|
|
40
|
-
# matched in any case as release-please does, because it asks for a release
|
|
41
|
-
# on any subject. GitHub's own `Revert "…"` subject is not conventional, so
|
|
42
|
-
# release-please ignores it and it is not let through here either. The last
|
|
43
|
-
# alternative is the release PR's own title, pull-request-title-pattern in
|
|
44
|
-
# release-please-config.json, and must change with it, or the release PR's
|
|
45
|
-
# merge is judged non-releasable and no tag is ever created. The payload
|
|
46
|
-
# holds at most 2048 commits, so a push that fills it may hide more and is
|
|
47
|
-
# let through.
|
|
48
|
-
gate:
|
|
49
|
-
runs-on: ubuntu-latest
|
|
50
|
-
timeout-minutes: 5
|
|
51
|
-
outputs:
|
|
52
|
-
releasable: ${{ steps.check.outputs.releasable }}
|
|
53
|
-
steps:
|
|
54
|
-
# A manual run carries no commits to judge, and asking for one is the
|
|
55
|
-
# statement that it should happen, so it bypasses the check below
|
|
56
|
-
# rather than being skipped by it for having an empty payload.
|
|
57
|
-
- id: check
|
|
58
|
-
env:
|
|
59
|
-
DISPATCHED: ${{ github.event_name == 'workflow_dispatch' }}
|
|
60
|
-
MESSAGES: ${{ toJSON(github.event.commits.*.message) }}
|
|
61
|
-
RELEASABLE: '^((feat|fix|perf|revert)(\([^)]*\))?!?: |[a-z]+(\([^)]*\))?!: |chore\(release\): v?[0-9])'
|
|
62
|
-
run: |
|
|
63
|
-
if [ "$DISPATCHED" = true ]; then
|
|
64
|
-
echo "releasable=true" >> "$GITHUB_OUTPUT"
|
|
65
|
-
echo "Run requested manually; release-please not gated."
|
|
66
|
-
exit 0
|
|
67
|
-
fi
|
|
68
|
-
if jq -e --arg re "$RELEASABLE" '(length >= 2048) or ([.[] | (split("\n")[0] | test($re)) or test("\nBREAKING[- ]CHANGE: ") or test("\nrelease-as: "; "i")] | any)' <<< "$MESSAGES" > /dev/null; then
|
|
69
|
-
echo "releasable=true" >> "$GITHUB_OUTPUT"
|
|
70
|
-
else
|
|
71
|
-
echo "releasable=false" >> "$GITHUB_OUTPUT"
|
|
72
|
-
echo "No releasable commit in this push; release-please not run."
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
release-please:
|
|
76
|
-
needs: gate
|
|
77
|
-
if: ${{ needs.gate.outputs.releasable == 'true' }}
|
|
78
|
-
runs-on: ubuntu-latest
|
|
79
|
-
timeout-minutes: 10
|
|
80
|
-
permissions:
|
|
81
|
-
contents: write
|
|
82
|
-
pull-requests: write
|
|
83
|
-
# release-please creates its `autorelease: pending` / `autorelease:
|
|
84
|
-
# tagged` labels through the issues API the first time it needs them.
|
|
85
|
-
issues: write
|
|
86
|
-
outputs:
|
|
87
|
-
releases_created: ${{ steps.rp.outputs.releases_created }}
|
|
88
|
-
tag_name: ${{ steps.rp.outputs.tag_name }}
|
|
89
|
-
steps:
|
|
90
|
-
- uses: googleapis/release-please-action@v5
|
|
91
|
-
id: rp
|
|
92
|
-
|
|
93
|
-
# The tag release-please just pushed does not start release_on_tag.yml by
|
|
94
|
-
# itself: an event raised with GITHUB_TOKEN starts no workflow, with
|
|
95
|
-
# workflow_dispatch as the exception. So release_on_tag.yml is dispatched on
|
|
96
|
-
# the tag. It has to stay the file that publishes, since npm's trusted
|
|
97
|
-
# publishing is bound to that workflow file. This is its own job, so that
|
|
98
|
-
# the right to start workflows is held by nothing but this one command.
|
|
99
|
-
publish:
|
|
100
|
-
needs: release-please
|
|
101
|
-
if: ${{ needs.release-please.outputs.releases_created == 'true' }}
|
|
102
|
-
runs-on: ubuntu-latest
|
|
103
|
-
timeout-minutes: 5
|
|
104
|
-
permissions:
|
|
105
|
-
actions: write
|
|
106
|
-
steps:
|
|
107
|
-
- env:
|
|
108
|
-
GH_TOKEN: ${{ github.token }}
|
|
109
|
-
run: gh workflow run release_on_tag.yml --repo "$GITHUB_REPOSITORY" --ref "${{ needs.release-please.outputs.tag_name }}"
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# Publishes the tag it runs on to npm. Release tags come from release-please:
|
|
2
|
-
# merging its release PR creates the tag and the GitHub Release, then
|
|
3
|
-
# release-please.yml dispatches this workflow on the tag. Pre-release tags
|
|
4
|
-
# (-beta.N, -rc.N, -alpha.N) are pushed by hand, and only a pushed tag gets
|
|
5
|
-
# its GitHub Release created here.
|
|
6
|
-
name: 'Release on tag'
|
|
7
|
-
|
|
8
|
-
on:
|
|
9
|
-
push:
|
|
10
|
-
tags:
|
|
11
|
-
- 'v*'
|
|
12
|
-
# How release-please.yml starts this on a tag it created: that tag, pushed
|
|
13
|
-
# with GITHUB_TOKEN, raises no push event.
|
|
14
|
-
workflow_dispatch:
|
|
15
|
-
|
|
16
|
-
concurrency:
|
|
17
|
-
group: release-${{ github.ref }}
|
|
18
|
-
cancel-in-progress: false
|
|
19
|
-
|
|
20
|
-
jobs:
|
|
21
|
-
# A dispatched run is on a tag release-please made, and release-please made
|
|
22
|
-
# its Release too; creating one here would be a second for the same tag.
|
|
23
|
-
release:
|
|
24
|
-
if: ${{ github.event_name == 'push' }}
|
|
25
|
-
permissions:
|
|
26
|
-
contents: write
|
|
27
|
-
runs-on: ubuntu-latest
|
|
28
|
-
timeout-minutes: 10
|
|
29
|
-
steps:
|
|
30
|
-
- name: Create Release
|
|
31
|
-
uses: softprops/action-gh-release@v3
|
|
32
|
-
with:
|
|
33
|
-
tag_name: ${{ github.ref_name }}
|
|
34
|
-
name: ${{ github.ref_name }}
|
|
35
|
-
generate_release_notes: true
|
|
36
|
-
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }}
|
|
37
|
-
env:
|
|
38
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
39
|
-
|
|
40
|
-
publish:
|
|
41
|
-
needs: release
|
|
42
|
-
# Runs after the Release on a pushed tag, and without it on a dispatched
|
|
43
|
-
# one, where that job is skipped. A dispatch can name any ref; only a
|
|
44
|
-
# release tag is published.
|
|
45
|
-
if: ${{ !cancelled() && needs.release.result != 'failure' && startsWith(github.ref, 'refs/tags/v') }}
|
|
46
|
-
permissions:
|
|
47
|
-
id-token: write
|
|
48
|
-
contents: read
|
|
49
|
-
runs-on: ubuntu-latest
|
|
50
|
-
timeout-minutes: 10
|
|
51
|
-
steps:
|
|
52
|
-
- uses: actions/checkout@v6
|
|
53
|
-
|
|
54
|
-
- uses: actions/setup-node@v6
|
|
55
|
-
with:
|
|
56
|
-
node-version: '24.x'
|
|
57
|
-
registry-url: 'https://registry.npmjs.org'
|
|
58
|
-
|
|
59
|
-
- run: npm ci
|
|
60
|
-
|
|
61
|
-
- name: Verify the tag matches package.json
|
|
62
|
-
run: |
|
|
63
|
-
TAG="${GITHUB_REF_NAME#v}"
|
|
64
|
-
PKG="$(node -p "require('./package.json').version")"
|
|
65
|
-
if [ "$TAG" != "$PKG" ]; then
|
|
66
|
-
echo "::error::tag $GITHUB_REF_NAME does not match package.json version $PKG"
|
|
67
|
-
exit 1
|
|
68
|
-
fi
|
|
69
|
-
echo "tag and package.json agree on $PKG"
|
|
70
|
-
|
|
71
|
-
- name: Publish to npm
|
|
72
|
-
run: |
|
|
73
|
-
tag="${GITHUB_REF#refs/tags/}"
|
|
74
|
-
if [[ "$tag" == *alpha* ]]; then
|
|
75
|
-
npm publish --provenance --access public --tag alpha
|
|
76
|
-
elif [[ "$tag" == *beta* ]]; then
|
|
77
|
-
npm publish --provenance --access public --tag beta
|
|
78
|
-
elif [[ "$tag" == *rc* ]]; then
|
|
79
|
-
npm publish --provenance --access public --tag rc
|
|
80
|
-
else
|
|
81
|
-
npm publish --provenance --access public
|
|
82
|
-
fi
|