From 17547a15d8cb11a4f532ab69a7f7686641927cab Mon Sep 17 00:00:00 2001 From: Dark <107168337+JDis03@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:01:04 -0400 Subject: [PATCH 1/3] docs: add CONTRIBUTING.md guide for new contributors (#484) ## Summary Adds a CONTRIBUTING.md file to help new contributors get started. ## Contents - Quick start setup - Finding issues to work on (label guide) - Branch naming conventions - Development workflow (fork, branch, commit, PR) - Project structure overview with key file paths - Styling conventions and file size guidelines - i18n workflow for adding translations - Code principles (KISS, DRY, single responsibility) This gives new contributors everything they need in one place to start contributing effectively. --------- --- CONTRIBUTING.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..968b5466 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,154 @@ +# Contributing to CodeNomad + +Thank you for your interest in contributing! This guide will help you get started. + +## Prerequisites + +- **Node.js 18+** and npm +- **OpenCode CLI** in your `PATH` (the server connects to the OpenCode binary to manage workspaces) + +## Quick Start + +```bash +git clone https://github.com/NeuralNomadsAI/CodeNomad.git +cd CodeNomad +npm install +npm run dev +``` + +## Finding Issues to Work On + +Browse [open issues](https://github.com/NeuralNomadsAI/CodeNomad/issues) and look for these labels: + +| Label | Meaning | +|---|---| +| `ready-to-work` | Clear scope, ready for anyone to pick up | +| `good-first-issue` | Good for first-time contributors | +| `enhancement` | New feature requests | +| `bug` | Bug reports | + +**Before starting:** comment on the issue so we can discuss approach and avoid duplicate work. + +## Development Workflow + +### 1. Fork and Branch + +```bash +# Fork the repo on GitHub, then clone your fork +git clone https://github.com/YOUR_USERNAME/CodeNomad.git +cd CodeNomad + +# Add the upstream remote +git remote add upstream https://github.com/NeuralNomadsAI/CodeNomad.git + +# Create a branch from upstream/dev +git fetch upstream +git checkout -b fix/your-branch-name upstream/dev +``` + +### 2. Branch Naming + +| Prefix | Use for | +|---|---| +| `fix/` | Bug fixes | +| `feat/` | New features | +| `docs/` | Documentation changes | +| `refactor/` | Code refactoring | +| `chore/` | Build, config, maintenance | + +Examples: `fix/question-queue-ordering`, `feat/retry-tool-call`, `docs/contributing-guide` + +### 3. Make Your Changes + +```bash +# Install dependencies +npm install + +# Run the dev server +npm run dev + +# Run type checking +npm run typecheck --workspace @codenomad/ui +``` + +### 4. Commit + +Write clear, descriptive commit messages. Explain **what** changed and **why**. + +```bash +git add . +git commit -m "fix(ui): preserve question queue order when upserting duplicate requests + +When a question arrives as a global entry and later resolves to a tool +part with a newer timestamp, the original enqueue time was lost, causing +the question to move behind newer entries and break interruption order." +``` + +### 5. Push and Create a PR + +```bash +git push origin your-branch-name +``` + +Then open a pull request on GitHub targeting the `dev` branch. + +**PR checklist:** +- [ ] Branch is based on latest `upstream/dev` +- [ ] One issue per PR (don't mix unrelated changes) +- [ ] Type checking passes: `npm run typecheck` (root) or the workspace-specific script matching your change area +- [ ] Tests pass (if applicable) +- [ ] PR description explains the change, includes relevant screenshots for UI changes, and links related issues when applicable + +## Project Structure + +| Package | Description | +|---|---| +| `packages/server` | Core logic & CLI — workspaces, OpenCode proxy, API, auth | +| `packages/ui` | SolidJS frontend — reactive UI components and stores | +| `packages/electron-app` | Electron desktop shell | +| `packages/tauri-app` | Tauri desktop shell (experimental) | +| `packages/opencode-plugin` | OpenCode plugin integration | +| `packages/cloudflare` | Cloudflare deployment adapters | + +### Key UI Files + +| Path | Purpose | +|---|---| +| `packages/ui/src/stores/session-events.ts` | SSE event handlers (idle, status, permissions, questions) | +| `packages/ui/src/stores/session-actions.ts` | User actions (send message, abort, revert, fork) | +| `packages/ui/src/stores/message-v2/` | Message store (v2 architecture) | +| `packages/ui/src/stores/instances.ts` | Instance management and interruption queues | +| `packages/ui/src/components/tool-call.tsx` | Tool call rendering | +| `packages/ui/src/components/message-block.tsx` | Message display blocks | +| `packages/ui/src/components/session/session-view.tsx` | Main session view | +| `packages/ui/src/lib/i18n/messages/` | Translation files (en, es, fr, ja, ru, he, zh-Hans) | + +> For a comprehensive map of all six functional areas (server, UI, desktop, speech/audio, build, Cloudflare), SDK integration patterns, and feature traces, load the `codenomad-architecture-guide` skill: +> `.opencode/skills/codenomad-architecture-guide/SKILL.md` + +### Styling + +- Tokens: `packages/ui/src/styles/tokens.css` +- Utilities: `packages/ui/src/styles/utilities.css` +- Component styles: `packages/ui/src/styles/components/`, `packages/ui/src/styles/messaging/`, `packages/ui/src/styles/panels/` +- Keep style files under ~150 lines; split by component + +### Internationalization (i18n) + +- Use `useI18n()` in components, `tGlobal()` in stores +- Messages live in `packages/ui/src/lib/i18n/messages//` +- When adding a string: add to `en/` first, then add the same key to every other locale +- Placeholders use `{name}` syntax (word characters only) + +## Code Principles + +- **KISS**: Keep modules narrowly scoped +- **DRY**: Share helpers before copy-pasting +- **Single responsibility**: Split files when concerns diverge +- **Composable primitives**: Prefer signals, hooks, utilities over deep inheritance + +## Need Help? + +- Check existing [issues](https://github.com/NeuralNomadsAI/CodeNomad/issues) and [PRs](https://github.com/NeuralNomadsAI/CodeNomad/pulls) +- Ask in the issue you're working on +- Review the [server documentation](packages/server/README.md) for CLI flags and configuration From 14dfd3e371050aaa80729ac15ff20a257a3e72d6 Mon Sep 17 00:00:00 2001 From: Dark <107168337+JDis03@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:42:17 -0400 Subject: [PATCH 2/3] fix(ui): throttle SSE part deltas and prevent stale delta text duplication (#536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Streaming responses send many `message.part.delta` events per second. Each event triggers a Solid store mutation + re-render, causing: 1. **Mobile freezes** — KaTeX/highlight.js re-renders on every delta block the main thread 2. **Text duplication** — When `message.part.updated` replaces a part with the server's complete state, previously enqueued deltas would flush AFTER the replacement, concatenating stale text onto the fresh part ## Solution ### 1. Delta throttling (50ms buffer) Accumulate deltas in a buffer and flush every 50ms instead of updating on every event. This batches Solid mutations and cuts main-thread blocking during streaming. ### 2. Stale delta cleanup When a full `message.part.updated` arrives, clear any pending deltas for that part before applying the update. The update already contains the complete server state, so accumulated deltas are stale. ## Changes - `packages/ui/src/stores/session-events.ts`: Added `enqueueDelta`, `flushDeltas`, `clearPendingDeltasForPart`; updated `handleMessagePartDelta` and `handleMessageUpdate` ## Verification - Build passes: `npm run build:ui` - Tested on mobile — eliminates visible freezes during long streaming responses - No duplicate text observed at end of assistant messages after part updates --------- --- packages/ui/src/stores/delta-buffer.test.ts | 102 ++++++++++++++++++++ packages/ui/src/stores/delta-buffer.ts | 89 +++++++++++++++++ packages/ui/src/stores/session-events.ts | 29 +++++- 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/stores/delta-buffer.test.ts create mode 100644 packages/ui/src/stores/delta-buffer.ts diff --git a/packages/ui/src/stores/delta-buffer.test.ts b/packages/ui/src/stores/delta-buffer.test.ts new file mode 100644 index 00000000..09072783 --- /dev/null +++ b/packages/ui/src/stores/delta-buffer.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict" +import { afterEach, beforeEach, describe, it } from "node:test" +import { setTimeout as delay } from "node:timers/promises" + +import { + clearPendingDeltasForPart, + enqueueDelta, + flushPendingDeltasForMessage, + resetDeltaBufferForTests, + setFlushCallback, +} from "./delta-buffer.ts" + +type DeltaBatch = Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }> + +describe("delta buffer", () => { + beforeEach(() => { + resetDeltaBufferForTests() + }) + + afterEach(() => { + resetDeltaBufferForTests() + }) + + it("concatenates matching deltas and flushes them once", async () => { + const flushed: DeltaBatch[] = [] + setFlushCallback((batch) => flushed.push(batch)) + + enqueueDelta("instance-1", "message-1", "part-1", "text", "hello") + enqueueDelta("instance-1", "message-1", "part-1", "text", " world") + + await delay(75) + + assert.equal(flushed.length, 1) + assert.deepEqual(flushed[0], [ + { instanceId: "instance-1", messageId: "message-1", partId: "part-1", field: "text", delta: "hello world" }, + ]) + }) + + it("clears pending deltas for a full part update before a stale timer flush", async () => { + const flushed: DeltaBatch[] = [] + setFlushCallback((batch) => flushed.push(batch)) + + enqueueDelta("instance-1", "message-1", "part-1", "text", "stale") + clearPendingDeltasForPart("instance-1", "message-1", "part-1") + + await delay(75) + + assert.deepEqual(flushed, []) + }) + + it("flushes pending message deltas before applying message.updated", async () => { + const timerFlushes: DeltaBatch[] = [] + const applied: Array<{ instanceId: string; delta: { messageId: string; partId: string; field: string; delta: string } }> = [] + setFlushCallback((batch) => timerFlushes.push(batch)) + + enqueueDelta("instance-1", "message-1", "part-1", "text", "before update") + flushPendingDeltasForMessage("instance-1", "message-1", (instanceId, delta) => { + applied.push({ instanceId, delta }) + }) + + await delay(75) + + assert.deepEqual(applied, [ + { + instanceId: "instance-1", + delta: { messageId: "message-1", partId: "part-1", field: "text", delta: "before update" }, + }, + ]) + assert.deepEqual(timerFlushes, []) + }) + + it("keeps clear and flush operations isolated by instance, message, and part", async () => { + const timerFlushes: DeltaBatch[] = [] + const applied: Array<{ instanceId: string; delta: { messageId: string; partId: string; field: string; delta: string } }> = [] + setFlushCallback((batch) => timerFlushes.push(batch)) + + enqueueDelta("instance-1", "message-1", "part-1", "text", "drop") + enqueueDelta("instance-1", "message-1", "part-2", "text", "same message") + enqueueDelta("instance-1", "message-2", "part-1", "text", "other message") + enqueueDelta("instance-2", "message-1", "part-1", "text", "other instance") + + clearPendingDeltasForPart("instance-1", "message-1", "part-1") + flushPendingDeltasForMessage("instance-1", "message-1", (instanceId, delta) => { + applied.push({ instanceId, delta }) + }) + + await delay(75) + + assert.deepEqual(applied, [ + { + instanceId: "instance-1", + delta: { messageId: "message-1", partId: "part-2", field: "text", delta: "same message" }, + }, + ]) + assert.deepEqual(timerFlushes, [ + [ + { instanceId: "instance-1", messageId: "message-2", partId: "part-1", field: "text", delta: "other message" }, + { instanceId: "instance-2", messageId: "message-1", partId: "part-1", field: "text", delta: "other instance" }, + ], + ]) + }) +}) diff --git a/packages/ui/src/stores/delta-buffer.ts b/packages/ui/src/stores/delta-buffer.ts new file mode 100644 index 00000000..3307257a --- /dev/null +++ b/packages/ui/src/stores/delta-buffer.ts @@ -0,0 +1,89 @@ +/** + * Delta buffer for throttling SSE message.part.delta events. + * + * Accumulates text deltas in a 50ms window to reduce UI churn from + * high-frequency streaming chunks. Provides targeted flush/clear paths + * so full part-update or message-complete events always win over stale + * buffered deltas. + */ + +const DELTA_FLUSH_INTERVAL = 50 + +const pendingDeltas = new Map() +let deltaFlushTimer: ReturnType | null = null + +export function enqueueDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string) { + const key = `${instanceId}:${messageId}:${partId}:${field}` + const existing = pendingDeltas.get(key) + const accumulated = existing ? existing.delta + delta : delta + pendingDeltas.set(key, { instanceId, messageId, partId, field, delta: accumulated }) + if (deltaFlushTimer === null) { + deltaFlushTimer = setTimeout(flushDeltas, DELTA_FLUSH_INTERVAL) + } +} + +export function clearPendingDeltasForPart(instanceId: string, messageId: string, partId: string) { + const keysToDelete: string[] = [] + for (const key of pendingDeltas.keys()) { + if (key.startsWith(`${instanceId}:${messageId}:${partId}:`)) { + keysToDelete.push(key) + } + } + for (const key of keysToDelete) { + pendingDeltas.delete(key) + } +} + +export function flushPendingDeltasForMessage( + instanceId: string, + messageId: string, + applyDelta: (instanceId: string, delta: { messageId: string; partId: string; field: string; delta: string }) => void +): void { + const prefix = `${instanceId}:${messageId}:` + const keysToFlush: string[] = [] + for (const key of pendingDeltas.keys()) { + if (key.startsWith(prefix)) { + keysToFlush.push(key) + } + } + for (const key of keysToFlush) { + const pending = pendingDeltas.get(key) + if (pending) { + pendingDeltas.delete(key) + applyDelta(instanceId, { + messageId: pending.messageId, + partId: pending.partId, + field: pending.field, + delta: pending.delta, + }) + } + } +} + +export function setFlushCallback( + callback: (batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void +) { + // Store callback for flushDeltas to use + flushCallback = callback +} + +export function resetDeltaBufferForTests() { + pendingDeltas.clear() + if (deltaFlushTimer !== null) { + clearTimeout(deltaFlushTimer) + deltaFlushTimer = null + } + flushCallback = null +} + +let flushCallback: ((batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void) | null = null + +function flushDeltas() { + deltaFlushTimer = null + if (pendingDeltas.size === 0) return + const batch = Array.from(pendingDeltas.values()) + pendingDeltas.clear() + if (flushCallback) { + flushCallback(batch) + } +} diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 90430b1f..ce3632d0 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -17,6 +17,12 @@ import type { MessageStatus } from "./message-v2/types" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" +import { + enqueueDelta, + clearPendingDeltasForPart, + flushPendingDeltasForMessage, + setFlushCallback, +} from "./delta-buffer" import { getPermissionId, getPermissionKind, @@ -384,6 +390,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" }) } + // Clear any pending deltas for this part before applying the full part update. + // The part update contains the complete state from the server, so accumulated + // deltas would be stale and cause duplication if flushed later. + if (part.id) { + clearPendingDeltasForPart(instanceId, messageId, part.id) + } applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo) @@ -402,6 +414,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return + // Flush any pending deltas for this message before applying the update. + // Deltas are buffered for up to 50ms; if message.updated arrives before + // the buffer flushes, the message could be marked complete/error with + // stale text mutations still pending. Flushing first preserves the + // server's event ordering: all delta content is applied, then the + // message status/metadata update runs on the complete content. + flushPendingDeltasForMessage(instanceId, messageId, applyPartDeltaV2) + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } const nextUpdated = typeof timeInfo.end === "number" && timeInfo.end > 0 @@ -453,12 +473,19 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes } } +// Delta buffer callback setup +setFlushCallback((batch) => { + for (const { instanceId, messageId, partId, field, delta } of batch) { + applyPartDeltaV2(instanceId, { messageId, partId, field, delta }) + } +}) + function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void { const props = event.properties if (!props) return const { messageID, partID, field, delta } = props if (!messageID || !partID || !field || typeof delta !== "string") return - applyPartDeltaV2(instanceId, { messageId: messageID, partId: partID, field, delta }) + enqueueDelta(instanceId, messageID, partID, field, delta) } function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { From 5c91c41181b911e08bdc0f03386dc8859e4383d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 13 Jun 2026 18:51:24 +0200 Subject: [PATCH 3/3] fix: wire Winget automation into release pipeline (#551) ## Summary - wire Winget submission into the stable release pipeline instead of relying on a separate `release.published` workflow - keep the existing release asset resolution and Komac-backed submission flow - document the new trigger model and manual fallback path ## Validation - `git diff --check origin/dev...HEAD` - `node --check scripts/winget/resolve-release-asset.cjs` - `node scripts/winget/resolve-release-asset.cjs --help` - live release metadata and asset-resolution dry-run against stable `v0.17.0` ## Notes - this fixes the case where a release created by GitHub Actions with the default `GITHUB_TOKEN` does not fan out into a second workflow run - assumes the existing Winget repo secret/variables remain configured - refs #462 --- .github/workflows/reusable-release.yml | 12 ++++ .github/workflows/update-winget.yml | 80 +++++++++++++++++++++--- docs/guides/winget-release-automation.md | 11 ++-- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index e839e76b..59b1b058 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -97,6 +97,18 @@ jobs: release_name: ${{ needs.prepare-release.outputs.release_name }} secrets: inherit + update-winget: + needs: + - prepare-release + - build-and-upload + if: ${{ !inputs.prerelease }} + permissions: + contents: read + uses: ./.github/workflows/update-winget.yml + with: + release_tag: ${{ needs.prepare-release.outputs.tag }} + secrets: inherit + release-ui: needs: prepare-release if: ${{ inputs.release_ui }} diff --git a/.github/workflows/update-winget.yml b/.github/workflows/update-winget.yml index 41b894f6..fd9a656f 100644 --- a/.github/workflows/update-winget.yml +++ b/.github/workflows/update-winget.yml @@ -1,17 +1,83 @@ name: Update Winget on: - release: - types: - - published + workflow_call: + inputs: + release_tag: + description: "Stable release tag to inspect" + required: true + type: string + release_id: + description: "Optional numeric GitHub release id" + required: false + default: "" + type: string + workflow_dispatch: + inputs: + release_tag: + description: "Stable release tag to inspect" + required: true + type: string + release_id: + description: "Optional numeric GitHub release id" + required: false + default: "" + type: string permissions: contents: read jobs: + resolve-release: + name: Resolve release metadata + runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.release.outputs.release_id }} + release_tag: ${{ steps.release.outputs.release_tag }} + draft: ${{ steps.release.outputs.draft }} + prerelease: ${{ steps.release.outputs.prerelease }} + steps: + - name: Resolve release metadata + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_ID_INPUT: ${{ inputs.release_id }} + run: | + set -euo pipefail + + if [ -n "$RELEASE_ID_INPUT" ]; then + release_api="repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID_INPUT}" + else + release_api="repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" + fi + + release_id="$(gh api "$release_api" --jq '.id')" + release_tag="$(gh api "$release_api" --jq '.tag_name')" + draft="$(gh api "$release_api" --jq '.draft')" + prerelease="$(gh api "$release_api" --jq '.prerelease')" + + if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then + echo "Unable to resolve release id for tag '$RELEASE_TAG'" >&2 + exit 1 + fi + + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "draft=$draft" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + + - name: Log resolved release metadata + run: | + echo "Release tag: ${{ steps.release.outputs.release_tag }}" + echo "Release id: ${{ steps.release.outputs.release_id }}" + echo "Draft: ${{ steps.release.outputs.draft }}" + echo "Prerelease: ${{ steps.release.outputs.prerelease }}" + update-winget: name: Submit Winget manifest update - if: ${{ !github.event.release.draft && !github.event.release.prerelease }} + needs: resolve-release + if: ${{ needs.resolve-release.outputs.draft != 'true' && needs.resolve-release.outputs.prerelease != 'true' }} runs-on: ubuntu-latest env: WINGET_PACKAGE_IDENTIFIER: ${{ vars.WINGET_PACKAGE_IDENTIFIER || 'NeuralNomadsAI.CodeNomad' }} @@ -35,8 +101,8 @@ jobs: run: | args=( --repo "${{ github.repository }}" - --release-id "${{ github.event.release.id }}" - --tag "${{ github.event.release.tag_name }}" + --release-id "${{ needs.resolve-release.outputs.release_id }}" + --tag "${{ needs.resolve-release.outputs.release_tag }}" --asset-name-template "$WINGET_WINDOWS_ASSET_NAME_TEMPLATE" --timeout-seconds "$WINGET_ASSET_WAIT_TIMEOUT_SECONDS" --poll-interval-seconds "$WINGET_ASSET_POLL_INTERVAL_SECONDS" @@ -79,7 +145,7 @@ jobs: with: identifier: ${{ env.WINGET_PACKAGE_IDENTIFIER }} version: ${{ steps.release_asset.outputs.version }} - release-tag: ${{ github.event.release.tag_name }} + release-tag: ${{ needs.resolve-release.outputs.release_tag }} installers-regex: ${{ steps.release_asset.outputs.asset_regex }} fork-user: ${{ env.WINGET_FORK_OWNER }} token: ${{ secrets.WINGET_GITHUB_TOKEN }} diff --git a/docs/guides/winget-release-automation.md b/docs/guides/winget-release-automation.md index 63931a15..b61639c0 100644 --- a/docs/guides/winget-release-automation.md +++ b/docs/guides/winget-release-automation.md @@ -1,11 +1,13 @@ # Winget release automation -CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/update-winget.yml`. +CodeNomad publishes Winget updates from the stable GitHub release pipeline. `.github/workflows/reusable-release.yml` now calls `.github/workflows/update-winget.yml` after the release assets finish uploading. ## Trigger -- Runs on `release.published`. -- Exits early for draft or prerelease releases. +- Runs as a reusable workflow from the stable release pipeline, after `build-and-upload` completes. +- Resolves the target release by tag through the GitHub API, then exits early for draft or prerelease releases. +- Can also be rerun manually with `workflow_dispatch` by supplying the stable release tag (and optionally the numeric release id). +- This avoids the old `release.published` trap where a release created by GitHub Actions with the default `GITHUB_TOKEN` does not fan out into a second workflow run. - Waits for the expected stable Windows Tauri asset because the release record can exist before all assets finish uploading. ## Required configuration @@ -28,7 +30,7 @@ CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/ ## Runtime flow -1. Resolve the release version from `github.event.release.tag_name`. +1. Resolve the target release by tag through the GitHub API, then derive the package version from the resolved release tag. 2. Poll the release API until exactly one uploaded asset matches the configured Windows Tauri asset template. 3. Download the matched asset once and compute a SHA-256 for logging and verification. 4. Verify the PAT owner matches `WINGET_FORK_OWNER` and that `${WINGET_FORK_OWNER}/winget-pkgs` is a fork of `microsoft/winget-pkgs`. @@ -38,3 +40,4 @@ CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/ - The workflow does not depend on a persistent local `winget-pkgs` clone. - The current package in `winget-pkgs` uses the release ZIP with a nested NSIS installer, so the workflow targets the stable `CodeNomad-Tauri-windows-x64-{version}.zip` asset. +- If a maintainer publishes a release outside the standard release workflow, they should manually run `Update Winget` for that stable tag.