@signalk/app-dock 1.2.1 → 1.3.1

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 CHANGED
@@ -37,6 +37,36 @@ 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(
52
+ {
53
+ type: 'signalk-app-dock:pointerdown',
54
+ x: e.clientX,
55
+ y: e.clientY,
56
+ t: performance.timeOrigin + e.timeStamp
57
+ },
58
+ '*'
59
+ )
60
+ },
61
+ { passive: true, capture: true }
62
+ )
63
+ }
64
+ ```
65
+
66
+ `t` is when the tap happened. It is optional, but a page that is busy, rendering a radar display for example, can post its messages late, and without `t` the dock can only go by when they arrive, which can split a double-tap in two.
67
+
68
+ 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.
69
+
40
70
  ## Install as an app
41
71
 
42
72
  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.2.1",
3
+ "version": "1.3.1",
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,22 @@
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
676
+
677
+ // Taps are timed by when they happened, not when their handler ran: a page
678
+ // busy rendering can hold a pointerdown back long enough to split a
679
+ // double-tap. Times are on the clock every document in the browser shares,
680
+ // performance.timeOrigin plus the page's own high-resolution time.
681
+ function nowTime() {
682
+ return performance.timeOrigin + performance.now()
683
+ }
684
+
685
+ function eventTime(doc, e) {
686
+ const perf = doc.defaultView && doc.defaultView.performance
687
+ return perf ? perf.timeOrigin + e.timeStamp : nowTime()
688
+ }
668
689
 
669
690
  function triggerDock() {
670
691
  if (DEBUG) console.log('[Dock] triggerDock called, dockVisible=', dockVisible)
@@ -679,15 +700,14 @@
679
700
 
680
701
  const DEBUG = new URLSearchParams(location.search).has('debugDock')
681
702
 
682
- function handleTap(clientX, clientY) {
703
+ function handleTap(clientX, clientY, space = null, time = nowTime()) {
683
704
  if (dockVisible) return
684
- const now = Date.now()
685
705
  const dx = clientX - lastTapX
686
706
  const dy = clientY - lastTapY
687
707
  const dist = Math.round(Math.sqrt(dx * dx + dy * dy))
688
- const close = dist <= DOUBLE_TAP_SLOP
689
- const gap = now - lastTapTime
690
- const match = gap < DOUBLE_TAP_MS && close
708
+ const close = dist <= DOUBLE_TAP_SLOP && space === lastTapSpace
709
+ const gap = time - lastTapTime
710
+ const match = gap >= 0 && gap < DOUBLE_TAP_MS && close
691
711
  if (DEBUG) {
692
712
  console.log('[Dock] tap', { x: Math.round(clientX), y: Math.round(clientY), gap, dist, close, match })
693
713
  }
@@ -695,9 +715,10 @@
695
715
  lastTapTime = 0
696
716
  triggerDock()
697
717
  } else {
698
- lastTapTime = now
718
+ lastTapTime = time
699
719
  lastTapX = clientX
700
720
  lastTapY = clientY
721
+ lastTapSpace = space
701
722
  }
702
723
  }
703
724
 
@@ -713,22 +734,23 @@
713
734
 
714
735
  // Dedupe: touchstart and pointerdown often both fire for the same gesture.
715
736
  // We accept whichever arrives first and ignore the other if it's within
716
- // 50 ms (the browser's synthesized-event coalescence window).
717
- let lastEventAt = 0
737
+ // 50 ms (the browser's synthesized-event coalescence window). The window is
738
+ // measured in event time: after the page stalls, two real taps can be
739
+ // handled back to back, and neither may be taken for the other's twin.
740
+ let lastEventAt = -Infinity
718
741
  const COALESCE_MS = 50
719
742
 
720
- const onDown = (x, y) => {
721
- const now = Date.now()
722
- if (now - lastEventAt < COALESCE_MS) return
723
- lastEventAt = now
724
- handleTap(x, y)
743
+ const onDown = (x, y, time) => {
744
+ if (Math.abs(time - lastEventAt) < COALESCE_MS) return
745
+ lastEventAt = time
746
+ handleTap(x, y, null, time)
725
747
  }
726
748
 
727
749
  doc.addEventListener(
728
750
  'pointerdown',
729
751
  (e) => {
730
752
  const o = offset()
731
- onDown(e.clientX + o.x, e.clientY + o.y)
753
+ onDown(e.clientX + o.x, e.clientY + o.y, eventTime(doc, e))
732
754
  },
733
755
  { passive: true, capture: true }
734
756
  )
@@ -743,7 +765,7 @@
743
765
  if (!e.touches || e.touches.length === 0) return
744
766
  const t = e.touches[0]
745
767
  const o = offset()
746
- onDown(t.clientX + o.x, t.clientY + o.y)
768
+ onDown(t.clientX + o.x, t.clientY + o.y, eventTime(doc, e))
747
769
  },
748
770
  { passive: true, capture: true }
749
771
  )
@@ -756,12 +778,14 @@
756
778
 
757
779
  function attachToIframe(frame) {
758
780
  const wire = () => {
759
- let doc
781
+ let doc = null
760
782
  try {
761
783
  doc = frame.contentDocument
762
784
  } catch {
763
- return // cross-origin; can't reach
785
+ // cross-origin; can't reach
764
786
  }
787
+ const app = appFrameOf(frame)
788
+ if (app) noteFrameReach(app, frame, doc !== null)
765
789
  if (!doc || attachedDocs.has(doc)) return
766
790
  attachedDocs.add(doc)
767
791
  attachTapListener(doc, frame)
@@ -786,11 +810,140 @@
786
810
  const nested = n.querySelectorAll && n.querySelectorAll('iframe')
787
811
  if (nested) nested.forEach((f) => attachToIframe(f))
788
812
  })
813
+ // A removed frame may have been the one keeping the handle up.
814
+ if (r.removedNodes.length > 0) updateHandle()
789
815
  }
790
816
  })
791
817
  obs.observe(root, { childList: true, subtree: true })
792
818
  }
793
819
 
820
+ // ─── Apps on another origin ──────────────────────────────────────────────────
821
+ // The listeners above cannot reach a page from another origin, so a
822
+ // double-tap over one goes unseen. That page may be the app itself or a
823
+ // frame inside it. Such a page can forward its pointerdowns instead, and
824
+ // while any page in the active app is out of reach and not forwarding, a
825
+ // tab at the dock's edge opens the dock with a single tap.
826
+ //
827
+ // A forwarded pointerdown is a message posted to the dock's window:
828
+ // window.top.postMessage({ type: 'signalk-app-dock:pointerdown', x: e.clientX, y: e.clientY }, '*')
829
+ // It counts only from inside a frame of the active app that the listeners
830
+ // cannot reach, so no tap is counted twice, and all it can do is feed the
831
+ // double-tap detection.
832
+ const FORWARDED_POINTERDOWN = 'signalk-app-dock:pointerdown'
833
+ const FORWARD_COALESCE_MS = 50
834
+ // Per app frame, the frames in it (itself included) the listeners cannot
835
+ // reach, and which of those have sent a pointerdown of their own since they
836
+ // loaded.
837
+ const unreachableFrames = new WeakMap()
838
+ const forwardingFrames = new WeakSet()
839
+ let lastForwardedAt = -Infinity
840
+
841
+ // The app frame a frame belongs to: the frame itself, or the one holding
842
+ // the document it sits in, found through same-origin parents.
843
+ function appFrameOf(frame) {
844
+ let f = frame
845
+ while (f && f.parentElement !== $iframeContainer) {
846
+ const win = f.ownerDocument && f.ownerDocument.defaultView
847
+ f = win ? win.frameElement : null
848
+ }
849
+ return f
850
+ }
851
+
852
+ // Runs on every load of a frame in an app. A new document starts over:
853
+ // whether it forwards is not known until it does, and an app frame's new
854
+ // document brings its own frames.
855
+ function noteFrameReach(app, frame, reachable) {
856
+ forwardingFrames.delete(frame)
857
+ let frames = unreachableFrames.get(app)
858
+ if (frame === app || !frames) {
859
+ frames = new Set()
860
+ unreachableFrames.set(app, frames)
861
+ }
862
+ if (reachable) frames.delete(frame)
863
+ else frames.add(frame)
864
+ updateHandle()
865
+ }
866
+
867
+ // Frames removed from their document, or left in one that has been
868
+ // navigated away, are dropped as they are found.
869
+ function liveUnreachableFrames(app) {
870
+ const frames = (app && unreachableFrames.get(app)) || new Set()
871
+ for (const f of frames) {
872
+ if (!f.isConnected || !f.ownerDocument.defaultView) frames.delete(f)
873
+ }
874
+ return frames
875
+ }
876
+
877
+ function updateHandle() {
878
+ const app = $iframeContainer.querySelector('iframe.active')
879
+ let show = false
880
+ if (!dockVisible) {
881
+ for (const f of liveUnreachableFrames(app)) {
882
+ if (!forwardingFrames.has(f)) show = true
883
+ }
884
+ }
885
+ $dockHandle.classList.toggle('visible', show)
886
+ }
887
+
888
+ // Where a frame's content starts in the dock's viewport, adding up the
889
+ // frames it sits in.
890
+ function offsetInDock(frame) {
891
+ let x = 0
892
+ let y = 0
893
+ for (let f = frame; f; ) {
894
+ const r = f.getBoundingClientRect()
895
+ x += r.left
896
+ y += r.top
897
+ if (f.parentElement === $iframeContainer) break
898
+ const win = f.ownerDocument.defaultView
899
+ f = win ? win.frameElement : null
900
+ }
901
+ return { x, y }
902
+ }
903
+
904
+ function isInsideFrame(source, frameWindow) {
905
+ for (let w = source; w; w = w.parent) {
906
+ if (w === frameWindow) return true
907
+ if (w === w.parent) return false
908
+ }
909
+ return false
910
+ }
911
+
912
+ window.addEventListener('message', (e) => {
913
+ const data = e.data
914
+ if (!data || data.type !== FORWARDED_POINTERDOWN) return
915
+ if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return
916
+ const app = $iframeContainer.querySelector('iframe.active')
917
+ if (!app || !e.source) return
918
+ let from = null
919
+ for (const f of liveUnreachableFrames(app)) {
920
+ if (isInsideFrame(e.source, f.contentWindow)) from = f
921
+ }
922
+ if (!from) return
923
+ // A frame inside the page forwarding says nothing about taps on the page
924
+ // around it, so only the page's own message stops the handle.
925
+ if (e.source === from.contentWindow) {
926
+ forwardingFrames.add(from)
927
+ updateHandle()
928
+ }
929
+ // The page can say when the tap happened. Without that, or with a time
930
+ // that has not come yet, the message's arrival stands in for it.
931
+ const received = nowTime()
932
+ const time = Number.isFinite(data.t) && data.t <= received ? data.t : received
933
+ if (Math.abs(time - lastForwardedAt) < FORWARD_COALESCE_MS) return
934
+ lastForwardedAt = time
935
+ // A frame inside the page sits somewhere the dock cannot see, so its
936
+ // taps keep their own coordinates and pair up only with each other.
937
+ if (e.source === from.contentWindow) {
938
+ const o = offsetInDock(from)
939
+ handleTap(data.x + o.x, data.y + o.y, null, time)
940
+ } else {
941
+ handleTap(data.x, data.y, e.source, time)
942
+ }
943
+ })
944
+
945
+ $dockHandle.addEventListener('click', triggerDock)
946
+
794
947
  attachTapListener(document, null)
795
948
  attachedDocs.add(document)
796
949
  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
@@ -1,5 +0,0 @@
1
- # Force LF line endings on checkout regardless of the host's
2
- # core.autocrlf setting. Prettier/eslint enforce LF; without this,
3
- # Windows CI runners check out CRLF and `prettier --check` reports
4
- # every line as "Delete `␍`".
5
- * text=auto eol=lf
@@ -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
- - '*'
@@ -1,11 +0,0 @@
1
- name: SignalK Plugin CI
2
-
3
- on:
4
- push:
5
- branches: [main, master]
6
- pull_request:
7
- branches: [main, master]
8
-
9
- jobs:
10
- test:
11
- uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@master
@@ -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 }}"