diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7835a05..6795c93 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: @@ -9,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: @@ -23,6 +28,12 @@ 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: | + set -euo pipefail + for script in scripts/check-release-version.sh scripts/make-update-manifest.sh scripts/update-keys.sh; do + bash -n "$script" + done - name: Verify dependency pins stay aligned run: bash scripts/check-dependency-pins.sh - name: Verify Windows installer bitmap and checkbox surface @@ -136,14 +147,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/.github/workflows/release-version-client-sync.yml b/.github/workflows/release-version-client-sync.yml new file mode 100644 index 0000000..c025809 --- /dev/null +++ b/.github/workflows/release-version-client-sync.yml @@ -0,0 +1,253 @@ +name: Regenerate versioned clients + +on: + pull_request: + types: [opened, synchronize, reopened, 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 == '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.event.pull_request.head.ref }} + 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 + env: + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + 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:${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 "${RELEASE_BRANCH}" + gh workflow run acceptance.yml --ref "${RELEASE_BRANCH}" + + 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 && + 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 + 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-v$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 + # 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 $(git rev-parse HEAD)" + + - 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" + 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 + 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 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" diff --git a/AGENTS.md b/AGENTS.md index d034dee..f555042 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) + +- 轨道与授权:轨道 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 与产品站不做价格预告。 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/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" 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", 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)