From f89efa99f804fb102192c5c9a05245f3f0d874e2 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 08:58:01 +0800 Subject: [PATCH 01/22] ci(release): regenerate versioned clients on release branches --- .../workflows/release-version-client-sync.yml | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/release-version-client-sync.yml diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml new file mode 100644 index 0000000..714f5db --- /dev/null +++ b/.github/workflows/release-version-client-sync.yml @@ -0,0 +1,82 @@ +name: Regenerate versioned clients + +on: + push: + branches: + - 'release/version-*' + +permissions: + contents: write + actions: write + +concurrency: + group: release-version-client-sync-${{ github.ref }} + cancel-in-progress: false + +env: + RIVET_PIN: 1ebb3ac09ed2e23eeb0a91cbec6e689aac01ab1a + KEEL_PIN: 0740fdfbdab7bf5dab32232c82f07dd98aa6a196 + +jobs: + regenerate: + name: Regenerate and validate versioned clients + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Set up Racket CS 9.3 + uses: Bogdanp/setup-racket@v1.15 + with: + architecture: x64 + distribution: full + variant: CS + version: '9.3' + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes cmake ninja-build pkg-config \ + libgtk-4-dev libadwaita-1-dev libjson-glib-dev \ + libsecret-1-dev libglib2.0-dev + + - name: Set up embeddable Racket CS + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@1ebb3ac09ed2e23eeb0a91cbec6e689aac01ab1a + + - name: Install pinned Racket dependencies + run: | + raco pkg install --auto --no-docs "https://github.com/turinglambdaai/keel.git#${KEEL_PIN}" + raco pkg install --auto --no-docs "https://github.com/turinglambdaai/rivet.git#${RIVET_PIN}" + raco pkg install --auto --no-docs db crypto + + - name: Regenerate clients from the canonical RPC contract + run: | + set -euo pipefail + raco rivet build + bash scripts/check-release-version.sh + + - name: Commit generated client version identities + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + macos-host/Sources/RivetHost/GeneratedBackend.swift \ + windows/GeneratedBackend.hpp \ + linux/GeneratedBackend.hpp + if git diff --cached --quiet; then + echo "Generated client files are already aligned." + else + git commit -m "chore(release): regenerate versioned clients" + git push origin "HEAD:${GITHUB_REF_NAME}" + fi + + - name: Dispatch full CI and product acceptance on generated commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh workflow run ci.yml --ref "${GITHUB_REF_NAME}" + gh workflow run acceptance.yml --ref "${GITHUB_REF_NAME}" From 278d999e4fb16e15fa160dd7af847f9bbb33647d Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 08:58:04 +0800 Subject: [PATCH 02/22] ci: allow dispatching full CI for generated release branches --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7835a05..cfa068e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: [main] pull_request: From 03d5bad5550b2983eed324e9d5e2bd2bd9ba216b Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 08:59:53 +0800 Subject: [PATCH 03/22] ci(release): tag and publish only after verified version merge --- .../workflows/release-version-client-sync.yml | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index 714f5db..5a67936 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -4,6 +4,9 @@ on: push: branches: - 'release/version-*' + pull_request: + types: [closed] + branches: [main] permissions: contents: write @@ -80,3 +83,134 @@ jobs: set -euo pipefail gh workflow run ci.yml --ref "${GITHUB_REF_NAME}" gh workflow run acceptance.yml --ref "${GITHUB_REF_NAME}" + + publish-verified-release: + name: Tag and publish after merged version PR + if: >- + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/version-') + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + + - name: Verify version, release notes, and signing secret + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + UPDATE_ED25519_PRIVATE_KEY: ${{ secrets.UPDATE_ED25519_PRIVATE_KEY }} + run: | + set -euo pipefail + VERSION="$(tr -d '[:space:]' < VERSION)" + TAG="v${VERSION}" + [[ "$RELEASE_BRANCH" == "release/version-$VERSION" ]] || { + echo "::error::Release branch $RELEASE_BRANCH does not match VERSION $VERSION" + exit 1 + } + bash scripts/check-release-version.sh + NOTES="$(awk -v header="## [${VERSION}]" ' + index($0, header) == 1 {on=1; next} + on && /^## / {exit} + on {print} + ' CHANGELOG.md)" + [[ -n "$(printf '%s' "$NOTES" | tr -d '[:space:]')" ]] || { + echo "::error::Missing release notes for $VERSION" + exit 1 + } + [[ -n "${UPDATE_ED25519_PRIVATE_KEY:-}" ]] || { + echo "::error::UPDATE_ED25519_PRIVATE_KEY is not configured; no tag will be created" + exit 1 + } + if [[ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]]; then + echo "::error::Tag $TAG already exists; refusing to move or replace it" + exit 1 + fi + echo "Verified release candidate $TAG at $GITHUB_SHA" + + - name: Wait for CI and product acceptance on the merge commit + env: + GH_TOKEN: ${{ github.token }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + ci_ok=false + acceptance_ok=false + for attempt in $(seq 1 180); do + runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${MERGE_SHA}&per_page=100")" + ci="$(jq -c --arg sha "$MERGE_SHA" '[.workflow_runs[] | select(.name == "CI" and .event == "push" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" + acceptance="$(jq -c --arg sha "$MERGE_SHA" '[.workflow_runs[] | select(.name == "Product Acceptance" and .event == "push" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" + if [[ -n "$ci" ]]; then + status="$(jq -r '.status' <<< "$ci")" + conclusion="$(jq -r '.conclusion // ""' <<< "$ci")" + if [[ "$status" == "completed" && "$conclusion" != "success" ]]; then + echo "::error::CI did not pass for merge commit $MERGE_SHA: $conclusion" + exit 1 + fi + [[ "$status" == "completed" && "$conclusion" == "success" ]] && ci_ok=true + fi + if [[ -n "$acceptance" ]]; then + status="$(jq -r '.status' <<< "$acceptance")" + conclusion="$(jq -r '.conclusion // ""' <<< "$acceptance")" + if [[ "$status" == "completed" && "$conclusion" != "success" ]]; then + echo "::error::Product Acceptance did not pass for merge commit $MERGE_SHA: $conclusion" + exit 1 + fi + [[ "$status" == "completed" && "$conclusion" == "success" ]] && acceptance_ok=true + fi + if [[ "$ci_ok" == true && "$acceptance_ok" == true ]]; then + echo "CI and Product Acceptance passed for $MERGE_SHA" + break + fi + if [[ "$attempt" -eq 180 ]]; then + echo "::error::Timed out waiting for CI and Product Acceptance on $MERGE_SHA" + exit 1 + fi + sleep 15 + done + + - name: Create immutable annotated tag and dispatch release build + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + VERSION="$(tr -d '[:space:]' < VERSION)" + TAG="v${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Taskly $TAG" + git push origin "$TAG" + gh workflow run release.yml --ref "$TAG" + + - name: Wait for the release workflow + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + for attempt in $(seq 1 360); do + runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${RELEASE_SHA}&per_page=100")" + release="$(jq -c --arg sha "$RELEASE_SHA" '[.workflow_runs[] | select(.name == "Release" and .event == "workflow_dispatch" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" + if [[ -n "$release" ]]; then + status="$(jq -r '.status' <<< "$release")" + conclusion="$(jq -r '.conclusion // ""' <<< "$release")" + url="$(jq -r '.html_url' <<< "$release")" + if [[ "$status" == "completed" ]]; then + if [[ "$conclusion" != "success" ]]; then + echo "::error::Release workflow failed: $url" + exit 1 + fi + echo "Release completed successfully: $url" + exit 0 + fi + fi + if [[ "$attempt" -eq 360 ]]; then + echo "::error::Timed out waiting for release workflow" + exit 1 + fi + sleep 15 + done From aef5486aa4f80bc7001c7051859076e68a516d75 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:00:20 +0800 Subject: [PATCH 04/22] ci(release): limit client regeneration to release branch pushes --- .github/workflows/release-version-client-sync.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index 5a67936..66e4933 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -22,6 +22,7 @@ env: jobs: regenerate: + if: github.event_name == 'push' name: Regenerate and validate versioned clients runs-on: ubuntu-latest steps: From 1d4e100d372a70d90e8081a7aa1c926dc2b1bf7b Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:00:54 +0800 Subject: [PATCH 05/22] ci(release): verify signing key matches embedded public keys --- .../workflows/release-version-client-sync.yml | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 .github/workflows/release-version-client-sync.yml diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml new file mode 100644 index 0000000..377d489 --- /dev/null +++ b/.github/workflows/release-version-client-sync.yml @@ -0,0 +1,234 @@ +name: Regenerate versioned clients + +on: + push: + branches: + - 'release/version-*' + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + actions: write + +concurrency: + group: release-version-client-sync-${{ github.ref }} + cancel-in-progress: false + +env: + RIVET_PIN: 1ebb3ac09ed2e23eeb0a91cbec6e689aac01ab1a + KEEL_PIN: 0740fdfbdab7bf5dab32232c82f07dd98aa6a196 + +jobs: + regenerate: + if: github.event_name == 'push' + name: Regenerate and validate versioned clients + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Set up Racket CS 9.3 + uses: Bogdanp/setup-racket@v1.15 + with: + architecture: x64 + distribution: full + variant: CS + version: '9.3' + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes cmake ninja-build pkg-config \ + libgtk-4-dev libadwaita-1-dev libjson-glib-dev \ + libsecret-1-dev libglib2.0-dev + + - name: Set up embeddable Racket CS + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@1ebb3ac09ed2e23eeb0a91cbec6e689aac01ab1a + + - name: Install pinned Racket dependencies + run: | + raco pkg install --auto --no-docs "https://github.com/turinglambdaai/keel.git#${KEEL_PIN}" + raco pkg install --auto --no-docs "https://github.com/turinglambdaai/rivet.git#${RIVET_PIN}" + raco pkg install --auto --no-docs db crypto + + - name: Regenerate clients from the canonical RPC contract + run: | + set -euo pipefail + raco rivet build + bash scripts/check-release-version.sh + + - name: Commit generated client version identities + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + macos-host/Sources/RivetHost/GeneratedBackend.swift \ + windows/GeneratedBackend.hpp \ + linux/GeneratedBackend.hpp + if git diff --cached --quiet; then + echo "Generated client files are already aligned." + else + git commit -m "chore(release): regenerate versioned clients" + git push origin "HEAD:${GITHUB_REF_NAME}" + fi + + - name: Dispatch full CI and product acceptance on generated commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh workflow run ci.yml --ref "${GITHUB_REF_NAME}" + gh workflow run acceptance.yml --ref "${GITHUB_REF_NAME}" + + publish-verified-release: + name: Tag and publish after merged version PR + if: >- + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/version-') + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + + - name: Verify version, release notes, and signing secret + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + UPDATE_ED25519_PRIVATE_KEY: ${{ secrets.UPDATE_ED25519_PRIVATE_KEY }} + run: | + set -euo pipefail + VERSION="$(tr -d '[:space:]' < VERSION)" + TAG="v${VERSION}" + [[ "$RELEASE_BRANCH" == "release/version-$VERSION" ]] || { + echo "::error::Release branch $RELEASE_BRANCH does not match VERSION $VERSION" + exit 1 + } + bash scripts/check-release-version.sh + NOTES="$(awk -v header="## [${VERSION}]" ' + index($0, header) == 1 {on=1; next} + on && /^## / {exit} + on {print} + ' CHANGELOG.md)" + [[ -n "$(printf '%s' "$NOTES" | tr -d '[:space:]')" ]] || { + echo "::error::Missing release notes for $VERSION" + exit 1 + } + [[ -n "${UPDATE_ED25519_PRIVATE_KEY:-}" ]] || { + echo "::error::UPDATE_ED25519_PRIVATE_KEY is not configured; no tag will be created" + exit 1 + } + # Confirm the configured private key matches both embedded public + # keys before creating an immutable tag. Never print the private key. + KEY_PEM="$(mktemp)" + trap 'rm -f "$KEY_PEM"' EXIT + printf '%s\n' "$UPDATE_ED25519_PRIVATE_KEY" > "$KEY_PEM" + DER_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | base64 -w0)" + RAW_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | tail -c 32 | base64 -w0)" + EXPECTED_DER="$(sed -n '/(define update-public-key-b64/{n;s/^[[:space:]]*"\([^"]*\)".*/\1/p;}' racket/taskly/updater.rkt)" + EXPECTED_RAW="$(sed -n 's/.*public static let publicKeyBase64 = "\([^"]*\)".*/\1/p' macos-host/Sources/RivetHost/UpdateService.swift)" + [[ -n "$EXPECTED_DER" && "$DER_PUBLIC" == "$EXPECTED_DER" ]] || { + echo "::error::Signing secret does not match the backend Ed25519 public key" + exit 1 + } + [[ -n "$EXPECTED_RAW" && "$RAW_PUBLIC" == "$EXPECTED_RAW" ]] || { + echo "::error::Signing secret does not match the macOS Ed25519 public key" + exit 1 + } + if [[ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]]; then + echo "::error::Tag $TAG already exists; refusing to move or replace it" + exit 1 + fi + echo "Verified release candidate $TAG at $GITHUB_SHA" + + - name: Wait for CI and product acceptance on the merge commit + env: + GH_TOKEN: ${{ github.token }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + ci_ok=false + acceptance_ok=false + for attempt in $(seq 1 180); do + runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${MERGE_SHA}&per_page=100")" + ci="$(jq -c --arg sha "$MERGE_SHA" '[.workflow_runs[] | select(.name == "CI" and .event == "push" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" + acceptance="$(jq -c --arg sha "$MERGE_SHA" '[.workflow_runs[] | select(.name == "Product Acceptance" and .event == "push" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" + if [[ -n "$ci" ]]; then + status="$(jq -r '.status' <<< "$ci")" + conclusion="$(jq -r '.conclusion // ""' <<< "$ci")" + if [[ "$status" == "completed" && "$conclusion" != "success" ]]; then + echo "::error::CI did not pass for merge commit $MERGE_SHA: $conclusion" + exit 1 + fi + [[ "$status" == "completed" && "$conclusion" == "success" ]] && ci_ok=true + fi + if [[ -n "$acceptance" ]]; then + status="$(jq -r '.status' <<< "$acceptance")" + conclusion="$(jq -r '.conclusion // ""' <<< "$acceptance")" + if [[ "$status" == "completed" && "$conclusion" != "success" ]]; then + echo "::error::Product Acceptance did not pass for merge commit $MERGE_SHA: $conclusion" + exit 1 + fi + [[ "$status" == "completed" && "$conclusion" == "success" ]] && acceptance_ok=true + fi + if [[ "$ci_ok" == true && "$acceptance_ok" == true ]]; then + echo "CI and Product Acceptance passed for $MERGE_SHA" + break + fi + if [[ "$attempt" -eq 180 ]]; then + echo "::error::Timed out waiting for CI and Product Acceptance on $MERGE_SHA" + exit 1 + fi + sleep 15 + done + + - name: Create immutable annotated tag and dispatch release build + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + VERSION="$(tr -d '[:space:]' < VERSION)" + TAG="v${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Taskly $TAG" + git push origin "$TAG" + gh workflow run release.yml --ref "$TAG" + + - name: Wait for the release workflow + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + for attempt in $(seq 1 360); do + runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${RELEASE_SHA}&per_page=100")" + release="$(jq -c --arg sha "$RELEASE_SHA" '[.workflow_runs[] | select(.name == "Release" and .event == "workflow_dispatch" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" + if [[ -n "$release" ]]; then + status="$(jq -r '.status' <<< "$release")" + conclusion="$(jq -r '.conclusion // ""' <<< "$release")" + url="$(jq -r '.html_url' <<< "$release")" + if [[ "$status" == "completed" ]]; then + if [[ "$conclusion" != "success" ]]; then + echo "::error::Release workflow failed: $url" + exit 1 + fi + echo "Release completed successfully: $url" + exit 0 + fi + fi + if [[ "$attempt" -eq 360 ]]; then + echo "::error::Timed out waiting for release workflow" + exit 1 + fi + sleep 15 + done From 2f1bbc4eef53120b168a29c81672a2a2ad876ed6 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:01:55 +0800 Subject: [PATCH 06/22] revert: keep release automation in review until PR merge --- .../workflows/release-version-client-sync.yml | 234 ------------------ 1 file changed, 234 deletions(-) delete mode 100644 .github/workflows/release-version-client-sync.yml diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml deleted file mode 100644 index 377d489..0000000 --- a/.github/workflows/release-version-client-sync.yml +++ /dev/null @@ -1,234 +0,0 @@ -name: Regenerate versioned clients - -on: - push: - branches: - - 'release/version-*' - pull_request: - types: [closed] - branches: [main] - -permissions: - contents: write - actions: write - -concurrency: - group: release-version-client-sync-${{ github.ref }} - cancel-in-progress: false - -env: - RIVET_PIN: 1ebb3ac09ed2e23eeb0a91cbec6e689aac01ab1a - KEEL_PIN: 0740fdfbdab7bf5dab32232c82f07dd98aa6a196 - -jobs: - regenerate: - if: github.event_name == 'push' - name: Regenerate and validate versioned clients - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - - name: Set up Racket CS 9.3 - uses: Bogdanp/setup-racket@v1.15 - with: - architecture: x64 - distribution: full - variant: CS - version: '9.3' - - - name: Install native build dependencies - run: | - sudo apt-get update - sudo apt-get install --yes cmake ninja-build pkg-config \ - libgtk-4-dev libadwaita-1-dev libjson-glib-dev \ - libsecret-1-dev libglib2.0-dev - - - name: Set up embeddable Racket CS - uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@1ebb3ac09ed2e23eeb0a91cbec6e689aac01ab1a - - - name: Install pinned Racket dependencies - run: | - raco pkg install --auto --no-docs "https://github.com/turinglambdaai/keel.git#${KEEL_PIN}" - raco pkg install --auto --no-docs "https://github.com/turinglambdaai/rivet.git#${RIVET_PIN}" - raco pkg install --auto --no-docs db crypto - - - name: Regenerate clients from the canonical RPC contract - run: | - set -euo pipefail - raco rivet build - bash scripts/check-release-version.sh - - - name: Commit generated client version identities - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - macos-host/Sources/RivetHost/GeneratedBackend.swift \ - windows/GeneratedBackend.hpp \ - linux/GeneratedBackend.hpp - if git diff --cached --quiet; then - echo "Generated client files are already aligned." - else - git commit -m "chore(release): regenerate versioned clients" - git push origin "HEAD:${GITHUB_REF_NAME}" - fi - - - name: Dispatch full CI and product acceptance on generated commit - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh workflow run ci.yml --ref "${GITHUB_REF_NAME}" - gh workflow run acceptance.yml --ref "${GITHUB_REF_NAME}" - - publish-verified-release: - name: Tag and publish after merged version PR - if: >- - github.event_name == 'pull_request' && - github.event.action == 'closed' && - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release/version-') - runs-on: ubuntu-latest - timeout-minutes: 180 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.merge_commit_sha }} - fetch-depth: 0 - - - name: Verify version, release notes, and signing secret - env: - GH_TOKEN: ${{ github.token }} - RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} - UPDATE_ED25519_PRIVATE_KEY: ${{ secrets.UPDATE_ED25519_PRIVATE_KEY }} - run: | - set -euo pipefail - VERSION="$(tr -d '[:space:]' < VERSION)" - TAG="v${VERSION}" - [[ "$RELEASE_BRANCH" == "release/version-$VERSION" ]] || { - echo "::error::Release branch $RELEASE_BRANCH does not match VERSION $VERSION" - exit 1 - } - bash scripts/check-release-version.sh - NOTES="$(awk -v header="## [${VERSION}]" ' - index($0, header) == 1 {on=1; next} - on && /^## / {exit} - on {print} - ' CHANGELOG.md)" - [[ -n "$(printf '%s' "$NOTES" | tr -d '[:space:]')" ]] || { - echo "::error::Missing release notes for $VERSION" - exit 1 - } - [[ -n "${UPDATE_ED25519_PRIVATE_KEY:-}" ]] || { - echo "::error::UPDATE_ED25519_PRIVATE_KEY is not configured; no tag will be created" - exit 1 - } - # Confirm the configured private key matches both embedded public - # keys before creating an immutable tag. Never print the private key. - KEY_PEM="$(mktemp)" - trap 'rm -f "$KEY_PEM"' EXIT - printf '%s\n' "$UPDATE_ED25519_PRIVATE_KEY" > "$KEY_PEM" - DER_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | base64 -w0)" - RAW_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | tail -c 32 | base64 -w0)" - EXPECTED_DER="$(sed -n '/(define update-public-key-b64/{n;s/^[[:space:]]*"\([^"]*\)".*/\1/p;}' racket/taskly/updater.rkt)" - EXPECTED_RAW="$(sed -n 's/.*public static let publicKeyBase64 = "\([^"]*\)".*/\1/p' macos-host/Sources/RivetHost/UpdateService.swift)" - [[ -n "$EXPECTED_DER" && "$DER_PUBLIC" == "$EXPECTED_DER" ]] || { - echo "::error::Signing secret does not match the backend Ed25519 public key" - exit 1 - } - [[ -n "$EXPECTED_RAW" && "$RAW_PUBLIC" == "$EXPECTED_RAW" ]] || { - echo "::error::Signing secret does not match the macOS Ed25519 public key" - exit 1 - } - if [[ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]]; then - echo "::error::Tag $TAG already exists; refusing to move or replace it" - exit 1 - fi - echo "Verified release candidate $TAG at $GITHUB_SHA" - - - name: Wait for CI and product acceptance on the merge commit - env: - GH_TOKEN: ${{ github.token }} - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - run: | - set -euo pipefail - ci_ok=false - acceptance_ok=false - for attempt in $(seq 1 180); do - runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${MERGE_SHA}&per_page=100")" - ci="$(jq -c --arg sha "$MERGE_SHA" '[.workflow_runs[] | select(.name == "CI" and .event == "push" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" - acceptance="$(jq -c --arg sha "$MERGE_SHA" '[.workflow_runs[] | select(.name == "Product Acceptance" and .event == "push" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" - if [[ -n "$ci" ]]; then - status="$(jq -r '.status' <<< "$ci")" - conclusion="$(jq -r '.conclusion // ""' <<< "$ci")" - if [[ "$status" == "completed" && "$conclusion" != "success" ]]; then - echo "::error::CI did not pass for merge commit $MERGE_SHA: $conclusion" - exit 1 - fi - [[ "$status" == "completed" && "$conclusion" == "success" ]] && ci_ok=true - fi - if [[ -n "$acceptance" ]]; then - status="$(jq -r '.status' <<< "$acceptance")" - conclusion="$(jq -r '.conclusion // ""' <<< "$acceptance")" - if [[ "$status" == "completed" && "$conclusion" != "success" ]]; then - echo "::error::Product Acceptance did not pass for merge commit $MERGE_SHA: $conclusion" - exit 1 - fi - [[ "$status" == "completed" && "$conclusion" == "success" ]] && acceptance_ok=true - fi - if [[ "$ci_ok" == true && "$acceptance_ok" == true ]]; then - echo "CI and Product Acceptance passed for $MERGE_SHA" - break - fi - if [[ "$attempt" -eq 180 ]]; then - echo "::error::Timed out waiting for CI and Product Acceptance on $MERGE_SHA" - exit 1 - fi - sleep 15 - done - - - name: Create immutable annotated tag and dispatch release build - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - VERSION="$(tr -d '[:space:]' < VERSION)" - TAG="v${VERSION}" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "Taskly $TAG" - git push origin "$TAG" - gh workflow run release.yml --ref "$TAG" - - - name: Wait for the release workflow - env: - GH_TOKEN: ${{ github.token }} - RELEASE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - run: | - set -euo pipefail - for attempt in $(seq 1 360); do - runs="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${RELEASE_SHA}&per_page=100")" - release="$(jq -c --arg sha "$RELEASE_SHA" '[.workflow_runs[] | select(.name == "Release" and .event == "workflow_dispatch" and .head_sha == $sha)] | sort_by(.created_at) | last // empty' <<< "$runs")" - if [[ -n "$release" ]]; then - status="$(jq -r '.status' <<< "$release")" - conclusion="$(jq -r '.conclusion // ""' <<< "$release")" - url="$(jq -r '.html_url' <<< "$release")" - if [[ "$status" == "completed" ]]; then - if [[ "$conclusion" != "success" ]]; then - echo "::error::Release workflow failed: $url" - exit 1 - fi - echo "Release completed successfully: $url" - exit 0 - fi - fi - if [[ "$attempt" -eq 360 ]]; then - echo "::error::Timed out waiting for release workflow" - exit 1 - fi - sleep 15 - done From 35a4a4e532c45512420421dd32d365403c10aa41 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:02:05 +0800 Subject: [PATCH 07/22] ci(release): verify signing key matches embedded public keys --- .../workflows/release-version-client-sync.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index 66e4933..377d489 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -127,6 +127,23 @@ jobs: echo "::error::UPDATE_ED25519_PRIVATE_KEY is not configured; no tag will be created" exit 1 } + # Confirm the configured private key matches both embedded public + # keys before creating an immutable tag. Never print the private key. + KEY_PEM="$(mktemp)" + trap 'rm -f "$KEY_PEM"' EXIT + printf '%s\n' "$UPDATE_ED25519_PRIVATE_KEY" > "$KEY_PEM" + DER_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | base64 -w0)" + RAW_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | tail -c 32 | base64 -w0)" + EXPECTED_DER="$(sed -n '/(define update-public-key-b64/{n;s/^[[:space:]]*"\([^"]*\)".*/\1/p;}' racket/taskly/updater.rkt)" + EXPECTED_RAW="$(sed -n 's/.*public static let publicKeyBase64 = "\([^"]*\)".*/\1/p' macos-host/Sources/RivetHost/UpdateService.swift)" + [[ -n "$EXPECTED_DER" && "$DER_PUBLIC" == "$EXPECTED_DER" ]] || { + echo "::error::Signing secret does not match the backend Ed25519 public key" + exit 1 + } + [[ -n "$EXPECTED_RAW" && "$RAW_PUBLIC" == "$EXPECTED_RAW" ]] || { + echo "::error::Signing secret does not match the macOS Ed25519 public key" + exit 1 + } if [[ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]]; then echo "::error::Tag $TAG already exists; refusing to move or replace it" exit 1 From 3c4b8e4c16c9b862db37d40f4816db1457533259 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:04:18 +0800 Subject: [PATCH 08/22] ci(release): allow safe retries without moving annotated tags --- .../workflows/release-version-client-sync.yml | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index 377d489..4ab98e7 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -145,10 +145,20 @@ jobs: exit 1 } if [[ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]]; then - echo "::error::Tag $TAG already exists; refusing to move or replace it" - exit 1 + # A failed publish can be retried without moving the immutable tag, + # but only if it is annotated and points at this exact merge commit. + git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" + [[ "$(git cat-file -t "$TAG")" == "tag" ]] || { + echo "::error::Existing tag $TAG is lightweight; refusing to use it" + exit 1 + } + [[ "$(git rev-parse "$TAG^{commit}")" == "$(git rev-parse HEAD)" ]] || { + echo "::error::Existing tag $TAG points at a different commit" + exit 1 + } + echo "Reusing existing annotated tag $TAG for a safe release retry" fi - echo "Verified release candidate $TAG at $GITHUB_SHA" + echo "Verified release candidate $TAG at $(git rev-parse HEAD)" - name: Wait for CI and product acceptance on the merge commit env: @@ -200,8 +210,12 @@ jobs: TAG="v${VERSION}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "Taskly $TAG" - git push origin "$TAG" + if git show-ref --verify --quiet "refs/tags/$TAG"; then + echo "Reusing immutable tag $TAG" + else + git tag -a "$TAG" -m "Taskly $TAG" + git push origin "$TAG" + fi gh workflow run release.yml --ref "$TAG" - name: Wait for the release workflow From d0ca3785a1a354073c4026d93969643d222a9dd2 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:05:04 +0800 Subject: [PATCH 09/22] ci(release): generate clients only after release PR is opened --- .../workflows/release-version-client-sync.yml | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index 4ab98e7..9fbe0ac 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -1,11 +1,8 @@ name: Regenerate versioned clients on: - push: - branches: - - 'release/version-*' pull_request: - types: [closed] + types: [opened, synchronize, reopened, closed] branches: [main] permissions: @@ -22,13 +19,17 @@ env: jobs: regenerate: - if: github.event_name == 'push' + if: >- + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/version-') name: Regenerate and validate versioned clients runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - ref: ${{ github.ref_name }} + ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 - name: Set up Racket CS 9.3 @@ -74,16 +75,17 @@ jobs: echo "Generated client files are already aligned." else git commit -m "chore(release): regenerate versioned clients" - git push origin "HEAD:${GITHUB_REF_NAME}" + git push origin "HEAD:${RELEASE_BRANCH}" fi - name: Dispatch full CI and product acceptance on generated commit env: GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} run: | set -euo pipefail - gh workflow run ci.yml --ref "${GITHUB_REF_NAME}" - gh workflow run acceptance.yml --ref "${GITHUB_REF_NAME}" + gh workflow run ci.yml --ref "${RELEASE_BRANCH}" + gh workflow run acceptance.yml --ref "${RELEASE_BRANCH}" publish-verified-release: name: Tag and publish after merged version PR @@ -91,6 +93,7 @@ jobs: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.event.pull_request.head.ref, 'release/version-') runs-on: ubuntu-latest timeout-minutes: 180 From aecdaaba77dd30cb068478b7187506905b9bb8b7 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:05:11 +0800 Subject: [PATCH 10/22] ci(release): pass release branch to generated client commit --- .github/workflows/release-version-client-sync.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index 9fbe0ac..6648d19 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -63,6 +63,8 @@ jobs: bash scripts/check-release-version.sh - name: Commit generated client version identities + env: + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} run: | set -euo pipefail git config user.name "github-actions[bot]" From 3468f6ea8647294bc3e2b62c3ddbe34c561c1971 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 09:36:32 +0800 Subject: [PATCH 11/22] ci(release): match the v-prefixed release branch name --- .github/workflows/release-version-client-sync.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index 6648d19..c025809 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -114,7 +114,7 @@ jobs: set -euo pipefail VERSION="$(tr -d '[:space:]' < VERSION)" TAG="v${VERSION}" - [[ "$RELEASE_BRANCH" == "release/version-$VERSION" ]] || { + [[ "$RELEASE_BRANCH" == "release/version-v$VERSION" ]] || { echo "::error::Release branch $RELEASE_BRANCH does not match VERSION $VERSION" exit 1 } From 595b846d9752589db7bc23adcabd7392d01b1bce Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 10:06:59 +0800 Subject: [PATCH 12/22] fix(release): derive manifest key ID from updater contract --- scripts/make-update-manifest.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/make-update-manifest.sh b/scripts/make-update-manifest.sh index b11dce2..07e0355 100755 --- a/scripts/make-update-manifest.sh +++ b/scripts/make-update-manifest.sh @@ -30,6 +30,14 @@ VERSION="${TAG#v}" [[ "$VERSION" == "$(tr -d '[:space:]' < "$ROOT/VERSION")" ]] || { echo "error: tag $TAG does not match VERSION '$(cat "$ROOT/VERSION")'" >&2; exit 1; } +# The backend updater owns the pinned key id. Derive it here rather than +# duplicating a literal that can drift during key rotation. +KEY_ID="$(sed -n 's/^(define update-key-id "\([^"]*\)".*/\1/p' "$ROOT/racket/taskly/updater.rkt")" +[[ "$KEY_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "error: could not read a valid update-key-id from racket/taskly/updater.rkt" >&2 + exit 1 +} + for artifact in "$DIST/taskly-$VERSION-macos-arm64.zip" \ "$DIST/taskly-$VERSION-macos-x64.zip" \ "$DIST/taskly-$VERSION-windows-x64.zip" \ @@ -79,7 +87,7 @@ cat > "$SCRIPT" <complete-path "$DIST")) (define key-path (path->complete-path "$KEY_DER")) -(define key-id "taskly-2026-10") +(define key-id "$KEY_ID") (define build (hash-ref (file->value (build-path (path->complete-path "$ROOT") "rivet.rktd")) 'build)) (define (artifact platform architecture file installer) From 89519c4f1932dd728b445b52a60bb485f4a9c6bc Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 10:07:02 +0800 Subject: [PATCH 13/22] ci(release): validate pinned update key IDs --- .github/workflows/release-version-client-sync.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index c025809..a78c894 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -149,6 +149,13 @@ jobs: echo "::error::Signing secret does not match the macOS Ed25519 public key" exit 1 } + EXPECTED_BACKEND_KEY_ID="$(sed -n 's/^(define update-key-id "\([^"]*\)".*/\1/p' racket/taskly/updater.rkt)" + EXPECTED_MACOS_KEY_ID="$(sed -n 's/.*public static let expectedKeyID = "\([^"]*\)".*/\1/p' macos-host/Sources/RivetHost/UpdateService.swift)" + EXPECTED_SPEC_KEY_ID="$(grep -oE '"key_id": "[^"]+"' shared/spec/UPDATE.md | head -n1 | cut -d '"' -f4)" + [[ -n "$EXPECTED_BACKEND_KEY_ID" && "$EXPECTED_BACKEND_KEY_ID" == "$EXPECTED_MACOS_KEY_ID" && "$EXPECTED_BACKEND_KEY_ID" == "$EXPECTED_SPEC_KEY_ID" ]] || { + echo "::error::Update key IDs differ across backend, macOS host, and shared contract" + exit 1 + } if [[ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]]; then # A failed publish can be retried without moving the immutable tag, # but only if it is annotated and points at this exact merge commit. From 59b7febd63f73fb34651d11c8badb2612c5cbf17 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 10:09:12 +0800 Subject: [PATCH 14/22] test(release): enforce update key ID alignment in preflight --- scripts/check-release-version.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/check-release-version.sh b/scripts/check-release-version.sh index c72db5a..2896cd3 100755 --- a/scripts/check-release-version.sh +++ b/scripts/check-release-version.sh @@ -63,4 +63,13 @@ check_generated_version \ "linux/GeneratedBackend.hpp" \ 'kVersion\[\] = "[^"]+"' -echo "release preflight: version $VERSION is aligned (VERSION == rivet.rktd == version.rkt == generated clients)" +# Keep the signing key identity aligned across the backend, macOS verifier, +# and documented manifest contract. The manifest generator reads the backend +# key id, so a mismatch here would make a correctly signed release unusable. +BACKEND_KEY_ID="$(sed -n 's/^(define update-key-id "\([^"]*\)".*/\1/p' "$ROOT/racket/taskly/updater.rkt")" +MACOS_KEY_ID="$(sed -n 's/.*public static let expectedKeyID = "\([^"]*\)".*/\1/p' "$ROOT/macos-host/Sources/RivetHost/UpdateService.swift")" +SPEC_KEY_ID="$(grep -oE '"key_id": "[^"]+"' "$ROOT/shared/spec/UPDATE.md" | head -n1 | cut -d '"' -f4 || true)" +[[ -n "$BACKEND_KEY_ID" && "$BACKEND_KEY_ID" == "$MACOS_KEY_ID" && "$BACKEND_KEY_ID" == "$SPEC_KEY_ID" ]] || \ + fail "update key id differs across backend, macOS host, and shared UPDATE contract" + +echo "release preflight: version $VERSION and update key identity are aligned" From f9a2045688e2cc6cc3af59102fc52eeef7d0905b Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 10:09:15 +0800 Subject: [PATCH 15/22] fix(release): reject signing keys not trusted by clients --- .github/workflows/release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1e82fb..bc12bd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -234,6 +234,7 @@ jobs: run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" + bash scripts/check-release-version.sh "$GITHUB_REF_NAME" mkdir -p dist # cp -R merges: rivet releases leave a dist/taskly-linux-/ # staging bundle in the artifacts, and a plain mv dies on the @@ -251,6 +252,19 @@ jobs: KEY_PEM="$RUNNER_TEMP/update-signing-key.pem" printf '%s\n' "$UPDATE_ED25519_PRIVATE_KEY" > "$KEY_PEM" chmod 600 "$KEY_PEM" + # Refuse to publish a feed signed by a key the shipped clients do not trust. + DER_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | base64 -w0)" + RAW_PUBLIC="$(openssl pkey -in "$KEY_PEM" -pubout -outform DER 2>/dev/null | tail -c 32 | base64 -w0)" + EXPECTED_DER="$(sed -n '/(define update-public-key-b64/{n;s/^[[:space:]]*"\([^"]*\)".*/\1/p;}' racket/taskly/updater.rkt)" + EXPECTED_RAW="$(sed -n 's/.*public static let publicKeyBase64 = "\([^"]*\)".*/\1/p' macos-host/Sources/RivetHost/UpdateService.swift)" + [[ -n "$EXPECTED_DER" && "$DER_PUBLIC" == "$EXPECTED_DER" ]] || { + echo "::error::Signing secret does not match the backend Ed25519 public key" + exit 1 + } + [[ -n "$EXPECTED_RAW" && "$RAW_PUBLIC" == "$EXPECTED_RAW" ]] || { + echo "::error::Signing secret does not match the macOS Ed25519 public key" + exit 1 + } scripts/make-update-manifest.sh "$GITHUB_REF_NAME" dist "$KEY_PEM" test -s dist/update-manifest.json || { echo "::error::Signed update manifest was not generated" From b534f86da7c106b607a4770b71952cf81b9ca315 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 10:09:17 +0800 Subject: [PATCH 16/22] ci(release): centralize key ID preflight --- .github/workflows/release-version-client-sync.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml index a78c894..c025809 100644 --- a/.github/workflows/release-version-client-sync.yml +++ b/.github/workflows/release-version-client-sync.yml @@ -149,13 +149,6 @@ jobs: echo "::error::Signing secret does not match the macOS Ed25519 public key" exit 1 } - EXPECTED_BACKEND_KEY_ID="$(sed -n 's/^(define update-key-id "\([^"]*\)".*/\1/p' racket/taskly/updater.rkt)" - EXPECTED_MACOS_KEY_ID="$(sed -n 's/.*public static let expectedKeyID = "\([^"]*\)".*/\1/p' macos-host/Sources/RivetHost/UpdateService.swift)" - EXPECTED_SPEC_KEY_ID="$(grep -oE '"key_id": "[^"]+"' shared/spec/UPDATE.md | head -n1 | cut -d '"' -f4)" - [[ -n "$EXPECTED_BACKEND_KEY_ID" && "$EXPECTED_BACKEND_KEY_ID" == "$EXPECTED_MACOS_KEY_ID" && "$EXPECTED_BACKEND_KEY_ID" == "$EXPECTED_SPEC_KEY_ID" ]] || { - echo "::error::Update key IDs differ across backend, macOS host, and shared contract" - exit 1 - } if [[ -n "$(git ls-remote --tags origin "refs/tags/$TAG")" ]]; then # A failed publish can be retried without moving the immutable tag, # but only if it is annotated and points at this exact merge commit. From 7a56735667d126cbadcb41c4f8b736ed9895757c Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 10:10:03 +0800 Subject: [PATCH 17/22] test(release): lint update signing scripts in CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfa068e..0f8cd3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: run: scripts/sync-i18n.sh --check - name: Verify release version alignment run: bash scripts/check-release-version.sh + - name: Check release script shell syntax + run: bash -n scripts/check-release-version.sh scripts/make-update-manifest.sh scripts/update-keys.sh - name: Verify dependency pins stay aligned run: bash scripts/check-dependency-pins.sh - name: Verify Windows installer bitmap and checkbox surface From ecc344651ace294b21c07c7ad0944bb1069b8753 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 10:14:15 +0800 Subject: [PATCH 18/22] ci: cancel obsolete runs for the same ref --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8cd3e..c147f75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + # Single source of truth for the rivet pin; every install step below # references this so a bump is one edit, not a grep-replace across jobs. env: From 2898201b30fef0a137776350044b74c7b3ecb9bf Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 13:47:49 +0800 Subject: [PATCH 19/22] AGENTS.md: add family portfolio placement section Points this repo's development sessions at the family PORTFOLIO.md (turinglambdaai.github.io) as the single source of truth for track, licensing form and monetization classification. --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d034dee..1e1200f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,3 +95,9 @@ taskly/ - **macOS 宿主 UI**:`macos-host/Sources/RivetHost/`(AppModel @Observable + SwiftUI 视图),交互基准见 PRODUCT-SPEC §5b - **改文案**:改 `shared/i18n/`,跑 `scripts/sync-i18n.sh`;改清单图标:改 `shared/emoji.json` 同样同步 - **发版**:见「快速命令」发布链;rivet pin(ci.yml 与 release.yml 的 `RIVET_PIN` 保持一致)只能有意 bump 并重新跑全套 CI + +## 组合定位(Portfolio placement) + +- 轨道与授权:轨道 4 社区引流;授权形态:AGPL-3.0;商业归属:永久免费(Pro 功能观望)。 +- 组合归属唯一权威:[PORTFOLIO.md](https://github.com/turinglambdaai/turinglambdaai.github.io/blob/master/PORTFOLIO.md)(turinglambdaai.github.io 仓库)。改归属/授权先改权威档,再同步本节。 +- 纪律:价格只进本仓 `PRICING.md`,且收费开始时才发布;README 与产品站不做价格预告。 From b981a57352ffe9ce0b0b30a0bf5a35f7c852e3e1 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 14:09:32 +0800 Subject: [PATCH 20/22] AGENTS.md: sync portfolio placement with PORTFOLIO.md revision --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1e1200f..0fff9d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,6 @@ taskly/ ## 组合定位(Portfolio placement) -- 轨道与授权:轨道 4 社区引流;授权形态:AGPL-3.0;商业归属:永久免费(Pro 功能观望)。 +- 轨道与授权:轨道 2 消费买断(第三梯队候选);授权形态:AGPL-3.0;商业归属:现阶段永久免费;产品成熟并划出 Pro 边界后按 Things 式买断收费。 - 组合归属唯一权威:[PORTFOLIO.md](https://github.com/turinglambdaai/turinglambdaai.github.io/blob/master/PORTFOLIO.md)(turinglambdaai.github.io 仓库)。改归属/授权先改权威档,再同步本节。 - 纪律:价格只进本仓 `PRICING.md`,且收费开始时才发布;README 与产品站不做价格预告。 From e7c6049113a7f177815e053ca53413e8d8c17e3b Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 14:27:39 +0800 Subject: [PATCH 21/22] AGENTS.md: sync portfolio placement with PORTFOLIO.md (paid-track expansion) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0fff9d0..f555042 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,6 @@ taskly/ ## 组合定位(Portfolio placement) -- 轨道与授权:轨道 2 消费买断(第三梯队候选);授权形态:AGPL-3.0;商业归属:现阶段永久免费;产品成熟并划出 Pro 边界后按 Things 式买断收费。 +- 轨道与授权:轨道 2 消费买断(消费主打);授权形态:AGPL-3.0;商业归属:收费产品——本地 SQLite 单文件 + 三平台原生 + CLI 是真实差异,按 Things 式买断 + 更新窗收费;v0.2 还年轻,先补成熟度并按未来 Pro 边界(候选:自然语言输入、提醒/日历集成、主题)做每个功能决策。 - 组合归属唯一权威:[PORTFOLIO.md](https://github.com/turinglambdaai/turinglambdaai.github.io/blob/master/PORTFOLIO.md)(turinglambdaai.github.io 仓库)。改归属/授权先改权威档,再同步本节。 - 纪律:价格只进本仓 `PRICING.md`,且收费开始时才发布;README 与产品站不做价格预告。 From c7ae32d487b1bf438d3da226bac37586430f5ca3 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 16:53:55 +0800 Subject: [PATCH 22/22] fix(linux): prevent undo-banner crash in native acceptance (#54) * fix(linux): preserve undo banner container lifetime * test(linux): keep undo acceptance inside six-second window * test(linux): guard accessible names during row rebuilds * fix(linux): use GtkSearchEntry placeholder API * test(linux): tolerate transient AT-SPI container removal * ci(macos): ensure boot smoke always reaps host process --- .github/workflows/ci.yml | 26 +++++++++++++-- linux/src/main.cpp | 24 ++++++++++---- scripts/e2e/linux-atspi.py | 67 ++++++++++++++++++++++++++++++-------- 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7835a05..406369b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,14 +136,34 @@ jobs: - name: Build host run: raco rivet build - name: Boot smoke + shell: bash run: | + set -euo pipefail smoke_home="$RUNNER_TEMP/taskly-smoke-home" + smoke_log="$RUNNER_TEMP/taskly-smoke.log" mkdir -p "$smoke_home" - HOME="$smoke_home" ./.rivet/stage/RivetHost & + HOME="$smoke_home" ./.rivet/stage/RivetHost >"$smoke_log" 2>&1 & APP_PID=$! + cleanup() { + if kill -0 "$APP_PID" 2>/dev/null; then + kill -TERM "$APP_PID" 2>/dev/null || true + for _ in 1 2 3 4 5; do + kill -0 "$APP_PID" 2>/dev/null || break + sleep 1 + done + kill -KILL "$APP_PID" 2>/dev/null || true + fi + wait "$APP_PID" 2>/dev/null || true + } + trap cleanup EXIT sleep 8 - kill -0 $APP_PID - kill $APP_PID + if ! kill -0 "$APP_PID" 2>/dev/null; then + cat "$smoke_log" || true + echo "::error::RivetHost exited before the 8-second smoke window" + exit 1 + fi + cleanup + trap - EXIT windows-host: name: Windows host (WinRT build) diff --git a/linux/src/main.cpp b/linux/src/main.cpp index c864335..e298069 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -2666,18 +2666,29 @@ void rebuild_task_list() { // --------------------------------------------------------------------------- guint banner_timeout() { + if (g_state.banner_box == nullptr) return 0; return GPOINTER_TO_UINT( g_object_get_data(G_OBJECT(g_state.banner_box), "banner-timeout")); } void hide_delete_banner() { + // banner_box is the persistent child of the revealer. Remove only the + // transient card; replacing the revealer's child would destroy banner_box + // while g_state.banner_box still points at it. + if (g_state.banner_box == nullptr) return; auto const timeout = banner_timeout(); if (timeout != 0) g_source_remove(timeout); g_object_set_data(G_OBJECT(g_state.banner_box), "banner-timeout", GUINT_TO_POINTER(0)); - gtk_revealer_set_reveal_child( - GTK_REVEALER(g_object_get_data(G_OBJECT(g_state.banner_box), - "banner-revealer")), FALSE); + auto* revealer = GTK_REVEALER( + g_object_get_data(G_OBJECT(g_state.banner_box), "banner-revealer")); + if (revealer != nullptr) { + gtk_revealer_set_reveal_child(revealer, FALSE); + } + while (auto* child = + gtk_widget_get_first_child(GTK_WIDGET(g_state.banner_box))) { + gtk_box_remove(g_state.banner_box, child); + } } void refresh_after_staged_delete() { @@ -2763,7 +2774,8 @@ void stage_delete_tasks(std::vector const& ids) { commit_staged_delete(); }), nullptr); gtk_box_append(GTK_BOX(card), close); - gtk_revealer_set_child(revealer, card); + // Keep the persistent banner_box alive; only the card is transient. + gtk_box_append(GTK_BOX(g_state.banner_box), card); gtk_revealer_set_reveal_child(revealer, TRUE); auto const timeout = g_timeout_add_seconds(6, on_undo_timeout, nullptr); g_object_set_data(G_OBJECT(g_state.banner_box), "banner-timeout", @@ -3015,8 +3027,8 @@ void apply_language() { build_menubar(); gtk_label_set_text(g_state.my_lists_caption, tr("sectionMyLists").c_str()); - gtk_entry_set_placeholder_text(GTK_ENTRY(g_state.search_box), - tr("searchHint").c_str()); + gtk_search_entry_set_placeholder_text(g_state.search_box, + tr("searchHint").c_str()); gtk_entry_set_placeholder_text(g_state.quick_add, tr("taskListInputHint").c_str()); gtk_accessible_update_property( diff --git a/scripts/e2e/linux-atspi.py b/scripts/e2e/linux-atspi.py index fd0b4f6..46490f0 100644 --- a/scripts/e2e/linux-atspi.py +++ b/scripts/e2e/linux-atspi.py @@ -38,6 +38,14 @@ def role(node): return "unknown" +def name(node): + try: + return node.name or "" + except Exception: + # A row can disappear between two AT-SPI calls while GTK rebuilds it. + return "" + + def text(node): try: value = node.queryText() @@ -60,21 +68,21 @@ def find_application(): desktop = pyatspi.Registry.getDesktop(0) for application in children(desktop): for node in children(application): - if role(node) == "frame" and "taskly" in (node.name or "").casefold(): + if role(node) == "frame" and "taskly" in name(node).casefold(): return application return None -def find_named(root, name, expected_role=None): +def find_named(root, expected_name, expected_role=None): for node in walk(root): - if (node.name or "") == name and (expected_role is None or role(node) == expected_role): + if name(node) == expected_name and (expected_role is None or role(node) == expected_role): return node return None -def find_editable_named(root, name): +def find_editable_named(root, expected_name): for node in walk(root): - if (node.name or "") != name: + if name(node) != expected_name: continue try: node.queryEditableText() @@ -85,12 +93,44 @@ def find_editable_named(root, name): def contains_text(root, expected): - return any((node.name or "") == expected or text(node) == expected for node in walk(root)) + return any(name(node) == expected or text(node) == expected for node in walk(root)) + + +def find_staged_delete_ready(root): + """Find the Undo button and verify both staged task rows are already hidden in one tree walk.""" + undo = None + found_a = False + found_b = False + for node in walk(root): + node_name = name(node) + node_text = "" + if node_name == "Undo" and role(node) == "push button": + undo = node + if node_name == "64-bit task A": + found_a = True + elif not found_a: + node_text = text(node) + if node_text == "64-bit task A": + found_a = True + if node_name == "64-bit task B": + found_b = True + elif not found_b: + if not node_text: + node_text = text(node) + if node_text == "64-bit task B": + found_b = True + if undo is not None and found_a and found_b: + return None + return undo if undo is not None and not found_a and not found_b else None def matching_child_indexes(container, expected): result = [] - for index in range(container.childCount): + try: + count = container.childCount + except Exception: + return result + for index in range(count): try: child = container.getChildAtIndex(index) except Exception: @@ -142,7 +182,7 @@ def select_named_children(root, expected_names): def snapshot(node, depth=0): - result = {"name": node.name or "", "role": role(node)} + result = {"name": name(node), "role": role(node)} value = text(node) if value: result["text"] = value @@ -210,12 +250,13 @@ def main(): lambda: select_named_children(window, ["64-bit task A", "64-bit task B"]), ) press(delete) - wait_for( - "staged tasks hidden", - lambda: not contains_text(window, "64-bit task A") - and not contains_text(window, "64-bit task B"), + # The product's undo window is six seconds. Find the banner and confirm + # both rows are hidden in one AT-SPI traversal, then undo immediately. + undo = wait_for( + "staged deletion hidden with undo banner", + lambda: find_staged_delete_ready(window), + timeout=4.0, ) - undo = wait_for("undo banner", lambda: find_named(window, "Undo", "push button")) press(undo) wait_for( "identity-preserving undo",