@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 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.2.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
- return // cross-origin; can't reach
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
@@ -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 }}"
@@ -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