Selaa lähdekoodia

Fix how releases are done

Jari 2 kuukautta sitten
vanhempi
commit
6f47607eaf
3 muutettua tiedostoa jossa 91 lisäystä ja 25 poistoa
  1. 29 21
      .github/workflows/release.yml
  2. 8 1
      docs/BUILD_AND_DEPLOY.md
  3. 54 3
      docs/index.html

+ 29 - 21
.github/workflows/release.yml

@@ -163,28 +163,36 @@ jobs:
           test -n "$(find release_assets -maxdepth 1 -type f -print -quit)"
           test -n "$(find release_assets -maxdepth 1 -type f -print -quit)"
           ls -la release_assets
           ls -la release_assets
 
 
-      - name: Create GitHub Release
-        uses: softprops/action-gh-release@v3
-        with:
-          tag_name: ${{ steps.release_version.outputs.version }}
-          name: ${{ format('OpenPuck {0}', steps.release_version.outputs.version) }}
-          prerelease: false
-          files: release_assets/*
-          body: |
-            OpenPuck Firmware Release
-
-            Version: ${{ steps.release_version.outputs.version }}
-            Commit: ${{ github.sha }}
-
-            ### Files included:
-
-            - `OpenPuck-*-standard.*` — puck firmware (standard)
-            - `OpenPuck-*-factory-reset.*` — puck firmware (factory reset)
-            - `ReversePuckFirmware-*.*` — reverse-puck firmware (controller emulator)
-            - `.uf2` files for drag-and-drop flashing (BOOT/UF2 drive)
-            - `.hex` files for direct flashing workflows
+      - name: Publish artifacts to the GitHub Release
+        # NEVER rewrite an existing release's notes, title or pre-release flag. This job also runs on
+        # `release: published`, i.e. right after a human wrote release notes in the UI (or marked the
+        # release as a pre-release) -- rewriting the release here would throw that away. So: upload the
+        # assets onto an existing release, and only author a default title/body when we are the ones
+        # creating the release (a bare semver tag push, no release object yet).
         env:
         env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          VERSION: ${{ steps.release_version.outputs.version }}
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -eu
+          if gh release view "$VERSION" >/dev/null 2>&1; then
+            echo "release $VERSION already exists; uploading assets only (notes/title/pre-release preserved)"
+            gh release upload "$VERSION" release_assets/* --clobber
+          else
+            gh release create "$VERSION" release_assets/* \
+              --title "OpenPuck $VERSION" \
+              --notes "OpenPuck Firmware Release
+
+          Version: ${VERSION}
+          Commit: ${GITHUB_SHA}
+
+          ### Files included:
+
+          - \`OpenPuck-*-standard.*\` — puck firmware (standard)
+          - \`OpenPuck-*-factory-reset.*\` — puck firmware (factory reset)
+          - \`ReversePuckFirmware-*.*\` — reverse-puck firmware (controller emulator)
+          - \`.uf2\` files for drag-and-drop flashing (BOOT/UF2 drive)
+          - \`.hex\` files for direct flashing workflows"
+          fi
 
 
       - name: Mirror OpenPuck UF2s to the firmware branch
       - name: Mirror OpenPuck UF2s to the firmware branch
         # The WebUSB panel's "update from a release" flow downloads these in-browser. GitHub's release-asset
         # The WebUSB panel's "update from a release" flow downloads these in-browser. GitHub's release-asset

+ 8 - 1
docs/BUILD_AND_DEPLOY.md

@@ -234,7 +234,9 @@ A board already running OpenPuck (status protocol v15+) can be updated entirely
 [WebUSB configurator](https://safijari.github.io/openpuck/)'s **Firmware update** tab: drag-and-drop a
 [WebUSB configurator](https://safijari.github.io/openpuck/)'s **Firmware update** tab: drag-and-drop a
 `.uf2` (or click to browse), or pick a version from the built-in **releases list** — each release offers the
 `.uf2` (or click to browse), or pick a version from the built-in **releases list** — each release offers the
 standard build or, via its checkbox, the `-factory-reset` build (wipes settings + pairing once on first
 standard build or, via its checkbox, the `-factory-reset` build (wipes settings + pairing once on first
-boot). A blocking modal shows download/transfer/verify/apply progress and reports the old → new build once
+boot). Each entry shows the release's GitHub notes under a collapsible **release notes** toggle and, when
+the release is marked as a pre-release on GitHub, a **pre-release** badge (un-marking it on GitHub removes
+the badge on the next list refresh; pre-releases are never suggested by the "update available" notice). A blocking modal shows download/transfer/verify/apply progress and reports the old → new build once
 the puck reconnects. Under the hood the panel extracts the app image from the `.uf2` and streams it over the
 the puck reconnects. Under the hood the panel extracts the app image from the `.uf2` and streams it over the
 normal WebUSB connection into spare flash high in the app region (~15 s; the running firmware keeps working);
 normal WebUSB connection into spare flash high in the app region (~15 s; the running firmware keeps working);
 the firmware CRC32-verifies what landed and commits a one-page "apply on reboot" record; a final automatic
 the firmware CRC32-verifies what landed and commits a one-page "apply on reboot" record; a final automatic
@@ -247,6 +249,11 @@ reboot copies staged→app from RAM (~5 s dark) and comes back up on the new fir
 > `raw.githubusercontent.com/safijari/openpuck/firmware/<asset>` (which is CORS-clean). If a release is
 > `raw.githubusercontent.com/safijari/openpuck/firmware/<asset>` (which is CORS-clean). If a release is
 > missing from the mirror, the panel falls back to opening the asset in a new tab for manual drag-and-drop.
 > missing from the mirror, the panel falls back to opening the asset in a new tab for manual drag-and-drop.
 
 
+> **Release notes and the pre-release flag are owned by GitHub, not the workflow:** the release job uploads
+> its artifacts onto an existing release (`gh release upload --clobber`) and only authors a title/body when
+> it creates the release itself from a bare tag push. Notes written in the GitHub UI, a custom release
+> title, and the pre-release checkbox therefore survive the artifact build that follows publishing.
+
 Failure safety: **nothing is armed until the staged image verifies in flash**, so a disconnect, error, or
 Failure safety: **nothing is armed until the staged image verifies in flash**, so a disconnect, error, or
 power cut during the transfer leaves the current firmware untouched. The apply step erases the app's vector
 power cut during the transfer leaves the current firmware untouched. The apply step erases the app's vector
 page first and rewrites it last (first word dead-last), so even a power cut mid-apply leaves the board
 page first and rewrites it last (first word dead-last), so even a power cut mid-apply leaves the board

+ 54 - 3
docs/index.html

@@ -90,6 +90,21 @@
   .rel-row .grow{flex:1}
   .rel-row .grow{flex:1}
   .rel-row label{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12px;cursor:pointer}
   .rel-row label{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12px;cursor:pointer}
   .rel-row input[type=checkbox]{accent-color:var(--bad)}
   .rel-row input[type=checkbox]{accent-color:var(--bad)}
+  /* one release = a row plus (optionally) its GitHub release notes, collapsed */
+  .rel-item{border-top:1px solid #232a3a}
+  .rel-item:first-child{border-top:0}
+  .rel-item .rel-row{border-top:0}
+  .pill.pre{background:#3a2f14;color:var(--warn)}
+  .rel-notes{margin:0 2px 10px;padding:0}
+  .rel-notes>summary{cursor:pointer;color:var(--acc2);font-size:12px;list-style:none;padding:2px 0}
+  .rel-notes>summary::-webkit-details-marker{display:none}
+  .rel-notes>summary::before{content:"\25B8 ";display:inline-block;transition:transform .1s}
+  .rel-notes[open]>summary::before{content:"\25BE "}
+  .rel-notes .body{white-space:pre-wrap;word-break:break-word;color:var(--fg);font-size:12.5px;
+    background:#151925;border:1px solid #232a3a;border-radius:8px;padding:9px 11px;margin-top:4px;
+    max-height:280px;overflow:auto}
+  .rel-notes .body code{background:#0f131c;border-radius:4px;padding:1px 4px;font-size:12px}
+  .rel-notes .body a{color:var(--acc2)}
   /* firmware-tab gate: when the connected puck's firmware predates panel updates, both update cards go
   /* firmware-tab gate: when the connected puck's firmware predates panel updates, both update cards go
      inert (pointer-events kills clicks, drops, and the file picker) and the banner explains why */
      inert (pointer-events kills clicks, drops, and the file picker) and the banner explains why */
   .fwupcard.gated{opacity:.45;pointer-events:none;user-select:none}
   .fwupcard.gated{opacity:.45;pointer-events:none;user-select:none}
@@ -2099,6 +2114,21 @@ function flashRelease(rel,factory){
     +"automatic reboot. A failed or interrupted transfer leaves the current firmware untouched.",
     +"automatic reboot. A failed or interrupted transfer leaves the current firmware untouched.",
     ()=>downloadAsset(asset));
     ()=>downloadAsset(asset));
 }
 }
+// Release notes come straight from GitHub (rel.body: markdown written by a human in the release UI).
+// Render a deliberately small subset -- headings, bullets, `code`, **bold**, links, and bare/markdown URLs
+// -- from ESCAPED text, so nothing in a release body can inject markup into the panel.
+function relNotesHtml(md){
+  const esc=t=>t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
+  return esc((md||"").replace(/\r\n/g,"\n").trim())
+    .replace(/`([^`\n]+)`/g,(m,c)=>"<code>"+c+"</code>")
+    .replace(/\*\*([^*\n]+)\*\*/g,(m,b)=>"<b>"+b+"</b>")
+    .replace(/^(#{1,6})\s+(.+)$/gm,(m,h,t)=>"<b>"+t+"</b>")
+    .replace(/^\s*[-*]\s+/gm,"• ")
+    .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g,
+             (m,t,u)=>'<a href="'+u+'" target="_blank" rel="noopener">'+t+"</a>")
+    .replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g,
+             (m,pre,u)=>pre+'<a href="'+u+'" target="_blank" rel="noopener">'+u+"</a>");
+}
 function renderReleases(){
 function renderReleases(){
   const list=$("#relList");
   const list=$("#relList");
   list.textContent="";
   list.textContent="";
@@ -2107,10 +2137,11 @@ function renderReleases(){
     const std=relAsset(rel,false), fac=relAsset(rel,true);
     const std=relAsset(rel,false), fac=relAsset(rel,true);
     if(!std && !fac) continue;
     if(!std && !fac) continue;
     shown++;
     shown++;
+    const item=document.createElement("div"); item.className="rel-item";
     const row=document.createElement("div"); row.className="rel-row";
     const row=document.createElement("div"); row.className="rel-row";
     const ver=document.createElement("span"); ver.className="ver"; ver.textContent=rel.tag_name;
     const ver=document.createElement("span"); ver.className="ver"; ver.textContent=rel.tag_name;
     const date=document.createElement("span"); date.className="date";
     const date=document.createElement("span"); date.className="date";
-    date.textContent=(rel.published_at||"").slice(0,10)+(rel.prerelease?" · pre":"");
+    date.textContent=(rel.published_at||"").slice(0,10);
     const name=document.createElement("span"); name.className="grow note"; name.style.margin="0";
     const name=document.createElement("span"); name.className="grow note"; name.style.margin="0";
     name.textContent=rel.name && rel.name!==rel.tag_name ? rel.name : "";
     name.textContent=rel.name && rel.name!==rel.tag_name ? rel.name : "";
     const lab=document.createElement("label");
     const lab=document.createElement("label");
@@ -2121,6 +2152,15 @@ function renderReleases(){
     const btn=document.createElement("button"); btn.textContent="Flash "+rel.tag_name;
     const btn=document.createElement("button"); btn.textContent="Flash "+rel.tag_name;
     btn.onclick=()=>flashRelease(rel,cb.checked);
     btn.onclick=()=>flashRelease(rel,cb.checked);
     row.append(ver,date,name);
     row.append(ver,date,name);
+    // Pre-release marking is read live from the GitHub API on every refresh, so un-marking a release on
+    // GitHub makes the badge disappear here as soon as the list is reloaded.
+    if(rel.prerelease){
+      const pre=document.createElement("span"); pre.className="pill pre";
+      pre.textContent="pre-release";
+      pre.title="Marked as a pre-release on GitHub: a test build, not the recommended version. "
+        +"It flashes like any other release, and the update notice never points at it.";
+      row.appendChild(pre);
+    }
     if(relPanelUpdatable(rel)===false){
     if(relPanelUpdatable(rel)===false){
       const pill=document.createElement("span"); pill.className="pill dn";
       const pill=document.createElement("span"); pill.className="pill dn";
       pill.textContent="no panel updates";
       pill.textContent="no panel updates";
@@ -2129,7 +2169,16 @@ function renderReleases(){
       row.appendChild(pill);
       row.appendChild(pill);
     }
     }
     row.append(lab,btn);
     row.append(lab,btn);
-    list.appendChild(row);
+    item.appendChild(row);
+    if((rel.body||"").trim()){
+      const det=document.createElement("details"); det.className="rel-notes";
+      const sum=document.createElement("summary"); sum.textContent="release notes";
+      const body=document.createElement("div"); body.className="body";
+      body.innerHTML=relNotesHtml(rel.body);
+      det.append(sum,body);
+      item.appendChild(det);
+    }
+    list.appendChild(item);
   }
   }
   if(!shown) list.textContent="no releases with OpenPuck .uf2 assets found";
   if(!shown) list.textContent="no releases with OpenPuck .uf2 assets found";
 }
 }
@@ -2139,8 +2188,10 @@ async function loadReleases(force){
   list.textContent="loading…";
   list.textContent="loading…";
   try{
   try{
     const [r,mr]=await Promise.all([
     const [r,mr]=await Promise.all([
+      // no-store: the pre-release flag and the notes are edited on GitHub after the fact, and a cached
+      // list would keep showing the stale state after a refresh.
       fetch("https://api.github.com/repos/"+REL_REPO+"/releases?per_page=15",
       fetch("https://api.github.com/repos/"+REL_REPO+"/releases?per_page=15",
-            {headers:{Accept:"application/vnd.github+json"}}),
+            {cache:"no-store",headers:{Accept:"application/vnd.github+json"}}),
       fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/manifest.json",{cache:"no-store"})
       fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/manifest.json",{cache:"no-store"})
         .catch(()=>null),
         .catch(()=>null),
     ]);
     ]);