From 892c75044f13a2acf222852f2aed66f972e1fc10 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 22:48:41 -0300 Subject: [PATCH 01/18] ci: main keeps one generation of each cache, not every one it ever wrote --- .github/workflows/caches.yml | 23 ++++++++++++ .gitignore | 2 ++ scripts/generations.py | 69 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 scripts/generations.py diff --git a/.github/workflows/caches.yml b/.github/workflows/caches.yml index 59f2f80c..1840f614 100644 --- a/.github/workflows/caches.yml +++ b/.github/workflows/caches.yml @@ -3,6 +3,10 @@ name: Caches on: pull_request_target: types: [closed] + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] permissions: actions: write @@ -10,6 +14,7 @@ permissions: jobs: sweep: name: a closed pull request takes its caches with it + if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -19,3 +24,21 @@ jobs: GH_REPO: ${{ github.repository }} REF: refs/pull/${{ github.event.pull_request.number }}/merge run: gh cache delete --all --ref "$REF" --succeed-on-no-caches + + generations: + name: main keeps one generation of each cache + if: >- + github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: cache-generations + cancel-in-progress: false + steps: + - uses: actions/checkout@v7 + - name: What a newer lockfile left behind + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: python3 scripts/generations.py diff --git a/.gitignore b/.gitignore index f1627111..2b25d4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ app/reports app/stryker-setup-*.js mutants.json window.json + +__pycache__/ diff --git a/scripts/generations.py b/scripts/generations.py new file mode 100644 index 00000000..8150c869 --- /dev/null +++ b/scripts/generations.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +import json +import re +import subprocess +import sys + +REF = "refs/heads/main" +# A rust-cache key ends in the hash of the lockfiles, and a setup-node one in the hash of +# the lockfile itself. Everything before that names the same cache across versions. +FAMILY = [ + re.compile(r"^(.*)-[0-9a-f]{8}$"), + re.compile(r"^(node-cache-.*-npm)-[0-9a-f]{64}$"), +] + + +def gh(*args): + done = subprocess.run(["gh", *args], capture_output=True, text=True) + if done.returncode != 0: + sys.exit(f"gh {' '.join(args)} failed: {done.stderr.strip()}") + return done.stdout + + +def family(key): + for shape in FAMILY: + found = shape.match(key) + if found: + return found.group(1) + return key + + +def main(): + kept = json.loads( + gh("cache", "list", "--ref", REF, "--limit", "100", "--json", "key,sizeInBytes,createdAt") + ) + if not kept: + print("main holds no caches") + return + + households = {} + for one in kept: + households.setdefault(family(one["key"]), []).append(one) + + stale = [] + for household in households.values(): + household.sort(key=lambda one: one["createdAt"], reverse=True) + stale.extend(household[1:]) + + if not stale: + print(f"{len(kept)} cache(s), one generation each: nothing to sweep") + return + + freed = 0 + for one in stale: + print(f" {one['sizeInBytes'] // 1048576:>5} MB {one['key']}") + done = subprocess.run( + ["gh", "cache", "delete", one["key"], "--ref", REF], + capture_output=True, + text=True, + ) + if done.returncode == 0: + freed += one["sizeInBytes"] + else: + print(f" gone already: {one['key']}") + + print(f"::notice::swept {len(stale)} superseded cache(s), {freed // 1048576} MB") + + +if __name__ == "__main__": + main() From 7c463aea85b535e0f11576e716e553cfad792a74 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:01:53 -0300 Subject: [PATCH 02/18] fix: the sweep only touches what a newer key superseded, and the registry is told once --- .github/workflows/caches.yml | 23 ++++++++++++-- .github/workflows/release.yml | 60 +++++++++++++++++++++++++++-------- scripts/generations.py | 45 +++++++++++++------------- scripts/mcpb.py | 6 ++-- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/.github/workflows/caches.yml b/.github/workflows/caches.yml index 1840f614..1f9a9275 100644 --- a/.github/workflows/caches.yml +++ b/.github/workflows/caches.yml @@ -6,7 +6,12 @@ on: workflow_run: workflows: ["CI"] types: [completed] - branches: [main] + workflow_dispatch: + inputs: + dry_run: + description: "List what would go, delete nothing." + type: boolean + default: true permissions: actions: write @@ -27,18 +32,30 @@ jobs: generations: name: main keeps one generation of each cache + # The branch filter on workflow_run reads the branch of the run that triggered it, and a + # fork's pull request can name its branch main. Only a push to this repository counts. if: >- - github.event_name == 'workflow_run' - && github.event.workflow_run.conclusion == 'success' + github.event_name == 'workflow_dispatch' + || (github.event_name == 'workflow_run' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_branch == 'main' + && github.event.workflow_run.head_repository.full_name == github.repository + && github.event.workflow_run.conclusion != 'cancelled') runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + actions: write + contents: read concurrency: group: cache-generations cancel-in-progress: false steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: What a newer lockfile left behind env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} run: python3 scripts/generations.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3161f259..f73d8894 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,8 @@ concurrency: env: CARGO_TERM_COLOR: always BUILD_MACOS: "true" + PUBLISHER: v1.8.1 + PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc # The command line ships inside the app as the assistant's door; the loose binary and its # formula are built only when a release has a reason to (a fix for whoever installed # only that). Set the repository variable SHIP_CLI to "true" for that tag. @@ -45,6 +47,15 @@ jobs: app/package-lock.json - run: npm ci - run: npm run lint:md + + # The registry has rules of its own — a description over 100 characters is the one + # that bites — and finding out after the release is announced is too late. + - name: Would the registry take this listing + run: | + curl -sSL --fail -o publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/$PUBLISHER/mcp-publisher_linux_amd64.tar.gz" + echo "$PUBLISHER_SHA256 publisher.tar.gz" | sha256sum --check --strict + tar xzf publisher.tar.gz mcp-publisher + ./mcp-publisher validate - name: Frontend style working-directory: app run: | @@ -711,6 +722,7 @@ jobs: needs: [version, build-macos, build-windows] if: >- !cancelled() && needs.version.outputs.cli == 'true' + && needs.version.outputs.prerelease == 'false' && needs.build-macos.result == 'success' && needs.build-windows.result == 'success' runs-on: ubuntu-latest timeout-minutes: 15 @@ -742,6 +754,7 @@ jobs: with: name: cli-mcpb path: dist/*.mcpb + if-no-files-found: error retention-days: 5 publish: @@ -814,7 +827,10 @@ jobs: if [ "${SHIP_CLI}" = "true" ]; then want="$want tisty-cli-$v-windows-x86_64.zip"; fi if [ "${BUILD_MACOS}" = "true" ]; then if [ "${SHIP_CLI}" = "true" ]; then - want="$want tisty-cli-$v-macos-universal.tar.gz tisty-mcp-$v.mcpb" + want="$want tisty-cli-$v-macos-universal.tar.gz" + if [ "${{ needs.version.outputs.prerelease }}" = "false" ]; then + want="$want tisty-mcp-$v.mcpb" + fi fi for arch in aarch64 x86_64; do want="$want tisty-installer-$v-macos-$arch.dmg"; done fi @@ -1235,29 +1251,45 @@ jobs: - uses: actions/checkout@v7 if: steps.gate.outputs.go == 'yes' - - name: Point the listing at what this tag published + # A version is immutable once listed, so republishing a tag is a permanent red job. + - name: Is this version already listed + id: listed if: steps.gate.outputs.go == 'yes' env: VERSION: ${{ needs.version.outputs.version }} - BUNDLE: bundle.mcpb run: | - curl -sSL --fail -o bundle.mcpb "https://github.com/rgdevment/Tisty/releases/download/v$VERSION/tisty-mcp-$VERSION.mcpb" - python3 scripts/listing.py + at="https://registry.modelcontextprotocol.io/v0.1/servers/io.github.rgdevment%2Ftisty/versions/$VERSION" + code=$(curl -sS -o /dev/null -w '%{http_code}' "$at") + if [ "$code" = "200" ]; then + echo "::notice::the registry already carries $VERSION; nothing to send" + echo "go=no" >> "$GITHUB_OUTPUT" + else + echo "go=yes" >> "$GITHUB_OUTPUT" + fi + + - name: Point the listing at what this tag published + if: steps.listed.outputs.go == 'yes' + env: + VERSION: ${{ needs.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release download "v$VERSION" --pattern "tisty-mcp-$VERSION.mcpb" --pattern SHA256SUMS + # A wrong hash publishes without complaint and leaves that version uninstallable + # for ever, because the registry never revisits a version it already took. + sha256sum --check --strict --ignore-missing SHA256SUMS + BUNDLE="tisty-mcp-$VERSION.mcpb" python3 scripts/listing.py - name: Install mcp-publisher - if: steps.gate.outputs.go == 'yes' + if: steps.listed.outputs.go == 'yes' run: | - curl -sSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher - - # The registry refuses on its own rules, and a refusal here is cheaper than one after - # the tag has been announced: description length is the one that bites. - - name: Would the registry take it - if: steps.gate.outputs.go == 'yes' - run: ./mcp-publisher validate + curl -sSL --fail -o publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/$PUBLISHER/mcp-publisher_linux_amd64.tar.gz" + echo "$PUBLISHER_SHA256 publisher.tar.gz" | sha256sum --check --strict + tar xzf publisher.tar.gz mcp-publisher - name: Publish - if: steps.gate.outputs.go == 'yes' + if: steps.listed.outputs.go == 'yes' run: | + ./mcp-publisher validate ./mcp-publisher login github-oidc ./mcp-publisher publish diff --git a/scripts/generations.py b/scripts/generations.py index 8150c869..ce192ab8 100644 --- a/scripts/generations.py +++ b/scripts/generations.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 import json +import os import re import subprocess import sys +from datetime import datetime REF = "refs/heads/main" -# A rust-cache key ends in the hash of the lockfiles, and a setup-node one in the hash of -# the lockfile itself. Everything before that names the same cache across versions. -FAMILY = [ - re.compile(r"^(.*)-[0-9a-f]{8}$"), - re.compile(r"^(node-cache-.*-npm)-[0-9a-f]{64}$"), -] +DRY = os.environ.get("DRY_RUN", "").lower() == "true" +# Only rust: a setup-node key carries the hash of its lockfile and nothing else, so two live +# caches for one platform look alike and the sweep would take one of them for a leftover. +RUST = re.compile(r"^(v0-rust-.+)-[0-9a-f]{8}-[0-9a-f]{8}$") +GONE = "Could not find a cache matching" def gh(*args): @@ -20,38 +21,33 @@ def gh(*args): return done.stdout -def family(key): - for shape in FAMILY: - found = shape.match(key) - if found: - return found.group(1) - return key - - def main(): kept = json.loads( gh("cache", "list", "--ref", REF, "--limit", "100", "--json", "key,sizeInBytes,createdAt") ) - if not kept: - print("main holds no caches") - return households = {} for one in kept: - households.setdefault(family(one["key"]), []).append(one) + found = RUST.match(one["key"]) + if found: + households.setdefault(found.group(1), []).append(one) stale = [] for household in households.values(): - household.sort(key=lambda one: one["createdAt"], reverse=True) + household.sort(key=lambda one: datetime.fromisoformat(one["createdAt"]), reverse=True) stale.extend(household[1:]) if not stale: - print(f"{len(kept)} cache(s), one generation each: nothing to sweep") + print(f"{len(kept)} cache(s) on main, one generation each: nothing to sweep") return freed = 0 + failed = [] for one in stale: print(f" {one['sizeInBytes'] // 1048576:>5} MB {one['key']}") + if DRY: + freed += one["sizeInBytes"] + continue done = subprocess.run( ["gh", "cache", "delete", one["key"], "--ref", REF], capture_output=True, @@ -59,10 +55,15 @@ def main(): ) if done.returncode == 0: freed += one["sizeInBytes"] + elif GONE in done.stderr: + print(f" evicted before we got to it: {one['key']}") else: - print(f" gone already: {one['key']}") + failed.append(f"{one['key']}: {done.stderr.strip()}") - print(f"::notice::swept {len(stale)} superseded cache(s), {freed // 1048576} MB") + said = "would sweep" if DRY else "swept" + print(f"::notice::{said} {len(stale) - len(failed)} superseded cache(s), {freed // 1048576} MB") + if failed: + sys.exit("::error::" + "; ".join(failed)) if __name__ == "__main__": diff --git a/scripts/mcpb.py b/scripts/mcpb.py index a36c9085..1f3508b0 100644 --- a/scripts/mcpb.py +++ b/scripts/mcpb.py @@ -23,9 +23,11 @@ "type": "binary", "entry_point": "server/tisty", "mcp_config": { - "command": "server/tisty", + # The reference host only substitutes ${...}; nothing there resolves a bare + # relative path against the extension directory, so a plain one finds nothing. + "command": "${__dirname}/server/tisty", "args": ["mcp"], - "platform_overrides": {"win32": {"command": "server/tisty.exe"}}, + "platform_overrides": {"win32": {"command": "${__dirname}/server/tisty.exe"}}, }, }, "compatibility": {"platforms": ["darwin", "win32"]}, From 606d0021542fd8f3eb0d96ed5ab61255c8275576 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:05:55 -0300 Subject: [PATCH 03/18] fix: one description, one repository name, and a tag stops leaving caches behind --- .github/workflows/caches.yml | 8 +++++--- .github/workflows/release.yml | 3 ++- scripts/generations.py | 36 +++++++++++++++++++++++------------ scripts/listing.py | 3 ++- scripts/mcpb.py | 9 ++++++--- 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/.github/workflows/caches.yml b/.github/workflows/caches.yml index 1f9a9275..f54b38d5 100644 --- a/.github/workflows/caches.yml +++ b/.github/workflows/caches.yml @@ -6,6 +6,8 @@ on: workflow_run: workflows: ["CI"] types: [completed] + schedule: + - cron: "17 6 * * *" workflow_dispatch: inputs: dry_run: @@ -31,11 +33,11 @@ jobs: run: gh cache delete --all --ref "$REF" --succeed-on-no-caches generations: - name: main keeps one generation of each cache + name: one generation of each cache, and nothing a tag left behind # The branch filter on workflow_run reads the branch of the run that triggered it, and a # fork's pull request can name its branch main. Only a push to this repository counts. if: >- - github.event_name == 'workflow_dispatch' + github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' @@ -53,7 +55,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - name: What a newer lockfile left behind + - name: What no build will ask for again env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f73d8894..788e621a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -748,6 +748,7 @@ jobs: VERSION: ${{ needs.version.outputs.version }} STAGED: staged DIST: dist + REPO: ${{ github.repository }} run: python3 scripts/mcpb.py - uses: actions/upload-artifact@v7 @@ -1277,7 +1278,7 @@ jobs: # A wrong hash publishes without complaint and leaves that version uninstallable # for ever, because the registry never revisits a version it already took. sha256sum --check --strict --ignore-missing SHA256SUMS - BUNDLE="tisty-mcp-$VERSION.mcpb" python3 scripts/listing.py + BUNDLE="tisty-mcp-$VERSION.mcpb" REPO="$GITHUB_REPOSITORY" python3 scripts/listing.py - name: Install mcp-publisher if: steps.listed.outputs.go == 'yes' diff --git a/scripts/generations.py b/scripts/generations.py index ce192ab8..cea82764 100644 --- a/scripts/generations.py +++ b/scripts/generations.py @@ -6,7 +6,8 @@ import sys from datetime import datetime -REF = "refs/heads/main" +MAIN = "refs/heads/main" +TAGS = "refs/heads/refs/tags/" DRY = os.environ.get("DRY_RUN", "").lower() == "true" # Only rust: a setup-node key carries the hash of its lockfile and nothing else, so two live # caches for one platform look alike and the sweep would take one of them for a leftover. @@ -21,35 +22,46 @@ def gh(*args): return done.stdout -def main(): - kept = json.loads( - gh("cache", "list", "--ref", REF, "--limit", "100", "--json", "key,sizeInBytes,createdAt") - ) +def listed(ref=None): + args = ["cache", "list", "--limit", "100", "--json", "key,ref,sizeInBytes,createdAt"] + if ref: + args += ["--ref", ref] + return json.loads(gh(*args)) + +def superseded(): households = {} - for one in kept: + for one in listed(MAIN): found = RUST.match(one["key"]) if found: - households.setdefault(found.group(1), []).append(one) - + households.setdefault(found.group(1), []).append({**one, "ref": MAIN}) stale = [] for household in households.values(): household.sort(key=lambda one: datetime.fromisoformat(one["createdAt"]), reverse=True) stale.extend(household[1:]) + return stale + +# A tag is written once and never built again, so nothing will ever ask for these by key. +def petrified(): + return [one for one in listed() if one["ref"].startswith(TAGS)] + + +def main(): + stale = superseded() + petrified() if not stale: - print(f"{len(kept)} cache(s) on main, one generation each: nothing to sweep") + print("nothing superseded and no tag left anything behind") return freed = 0 failed = [] for one in stale: - print(f" {one['sizeInBytes'] // 1048576:>5} MB {one['key']}") + print(f" {one['sizeInBytes'] // 1048576:>5} MB {one['ref']} {one['key']}") if DRY: freed += one["sizeInBytes"] continue done = subprocess.run( - ["gh", "cache", "delete", one["key"], "--ref", REF], + ["gh", "cache", "delete", one["key"], "--ref", one["ref"]], capture_output=True, text=True, ) @@ -61,7 +73,7 @@ def main(): failed.append(f"{one['key']}: {done.stderr.strip()}") said = "would sweep" if DRY else "swept" - print(f"::notice::{said} {len(stale) - len(failed)} superseded cache(s), {freed // 1048576} MB") + print(f"::notice::{said} {len(stale) - len(failed)} cache(s), {freed // 1048576} MB") if failed: sys.exit("::error::" + "; ".join(failed)) diff --git a/scripts/listing.py b/scripts/listing.py index efeb5b19..a6f473df 100644 --- a/scripts/listing.py +++ b/scripts/listing.py @@ -8,7 +8,8 @@ VERSION = os.environ["VERSION"] BUNDLE = os.environ["BUNDLE"] AT = os.environ.get("LISTING", "server.json") -URL = f"https://github.com/rgdevment/Tisty/releases/download/v{VERSION}/tisty-mcp-{VERSION}.mcpb" +REPO = os.environ.get("REPO", "rgdevment/Tisty") +URL = f"https://github.com/{REPO}/releases/download/v{VERSION}/tisty-mcp-{VERSION}.mcpb" listing = json.load(io.open(AT, encoding="utf-8")) listing["version"] = VERSION diff --git a/scripts/mcpb.py b/scripts/mcpb.py index 1f3508b0..8baa66e3 100644 --- a/scripts/mcpb.py +++ b/scripts/mcpb.py @@ -9,16 +9,19 @@ VERSION = os.environ["VERSION"] DIST = os.environ.get("DIST", "dist") +LISTING = os.environ.get("LISTING", "server.json") OUT = os.path.join(DIST, f"tisty-mcp-{VERSION}.mcpb") +REPO = os.environ.get("REPO", "rgdevment/Tisty") +SAID = json.load(io.open(LISTING, encoding="utf-8"))["description"] MANIFEST = { "manifest_version": "0.3", "name": "tisty", "display_name": "Tisty", "version": VERSION, - "description": "Notes, documents and tasks as plain Markdown on your own disk.", - "author": {"name": "rgdevment", "url": "https://github.com/rgdevment"}, - "homepage": "https://github.com/rgdevment/Tisty", + "description": SAID, + "author": {"name": REPO.split("/")[0], "url": f"https://github.com/{REPO.split('/')[0]}"}, + "homepage": f"https://github.com/{REPO}", "server": { "type": "binary", "entry_point": "server/tisty", From 17f2430a770c08fb9d5bc317946063666b7ea0cc Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:26:12 -0300 Subject: [PATCH 04/18] perf: the suite runs once, and clippy runs where the cache is written --- .github/workflows/ci.yml | 22 ++++++---------------- .github/workflows/rules.yml | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5814865..28970940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,32 +36,22 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - uses: ./.github/actions/sidecar - uses: taiki-e/install-action@nextest - - if: runner.os == 'macOS' - run: cargo clippy --workspace --all-targets - - run: cargo nextest run --workspace --no-tests=pass + # Clippy runs where the cache is written, or the next job pays to rebuild what it needs. + - run: cargo clippy --workspace --all-targets - run: cargo nextest run --workspace --no-tests=pass - env: - LC_ALL: es_ES.UTF-8 - LANG: es_ES.UTF-8 - if: runner.os == 'Windows' run: cargo test --doc --workspace lint: - name: fmt + clippy - runs-on: windows-latest - timeout-minutes: 25 + name: fmt + runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - with: - shared-key: workspace - save-if: false - - uses: ./.github/actions/sidecar + components: rustfmt - run: cargo fmt --all --check - - run: cargo clippy --workspace --all-targets window: name: frontend diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index e2810525..5d015444 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -90,6 +90,21 @@ jobs: exit 1 fi + # The suite runs once, in one locale, because no test reads the ambient one: every + # spawn of the binary pins it. A spawn that forgot would test the runner, not the code. + - name: A spawned binary is never left the runner's language + run: | + status=0 + for at in $(grep -ral 'CARGO_BIN_EXE_tisty' crates/tisty-cli/tests); do + spawns=$(grep -ac 'Command::new(env!("CARGO_BIN_EXE_tisty"))' "$at") + pinned=$(grep -aA9 'Command::new(env!("CARGO_BIN_EXE_tisty"))' "$at" | grep -cE 'env_clear\(\)|env_remove\("LC_ALL"\)') + if [ "$spawns" != "$pinned" ]; then + echo "::error::$at spawns the binary $spawns time(s) and pins the language $pinned" + status=1 + fi + done + exit $status + - name: Both languages carry the same documents run: | same() { From 45669d8a6461bc519927bb9e640257aa85f422e9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:32:36 -0300 Subject: [PATCH 05/18] fix: the version job stops holding the signing keys, and four channels wait for verify --- .github/actions/package/action.yml | 1 + .github/actions/prepare/action.yml | 4 +- .github/workflows/ci.yml | 10 ++-- .github/workflows/mutants-sweep.yml | 19 +++---- .github/workflows/mutants.yml | 4 +- .github/workflows/release.yml | 84 ++++++++++++++++++----------- .github/workflows/rules.yml | 4 +- 7 files changed, 75 insertions(+), 51 deletions(-) diff --git a/.github/actions/package/action.yml b/.github/actions/package/action.yml index 8d8fb35f..4276c170 100644 --- a/.github/actions/package/action.yml +++ b/.github/actions/package/action.yml @@ -43,4 +43,5 @@ runs: with: name: cli-${{ inputs.name }} path: dist/* + if-no-files-found: error retention-days: 5 diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 33029ced..e38613d8 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -16,11 +16,11 @@ inputs: runs: using: composite steps: - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: targets: ${{ inputs.targets }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: ${{ inputs.cache-key }} save-if: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28970940..1d05dcf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,10 @@ jobs: os: [windows-latest, macos-latest] steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: ${{ github.ref == 'refs/heads/main' }} @@ -48,7 +48,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: rustfmt - run: cargo fmt --all --check @@ -101,10 +101,10 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - uses: taiki-e/install-action@cargo-llvm-cov diff --git a/.github/workflows/mutants-sweep.yml b/.github/workflows/mutants-sweep.yml index 9e78d717..420fc04a 100644 --- a/.github/workflows/mutants-sweep.yml +++ b/.github/workflows/mutants-sweep.yml @@ -22,8 +22,8 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: false @@ -57,8 +57,8 @@ jobs: timeout-minutes: 180 steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: false @@ -96,8 +96,8 @@ jobs: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: false @@ -135,8 +135,8 @@ jobs: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: false @@ -247,13 +247,14 @@ jobs: - name: The report where a survivor can be read one by one env: KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} + BRANCH: ${{ github.ref_name }} run: | if [ -z "$KEY" ]; then echo "::warning::no dashboard key: the report goes no further than the artifact" exit 0 fi curl -sS --fail-with-body -X PUT \ - "https://dashboard.stryker-mutator.io/api/reports/github.com/${{ github.repository }}/${{ github.ref_name }}" \ + "https://dashboard.stryker-mutator.io/api/reports/github.com/$GITHUB_REPOSITORY/$BRANCH" \ -H 'Content-Type: application/json' \ -H "X-Api-Key: $KEY" \ -d @window.json diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index f74f49f5..5fba3c61 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -38,9 +38,9 @@ jobs: git diff "$from" -- crates > branch.diff if [ -s branch.diff ]; then echo "any=yes" >> "$GITHUB_OUTPUT"; fi - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable if: steps.touched.outputs.any == 'yes' - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 788e621a..d17cb85d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,10 +70,10 @@ jobs: RUSTFLAGS: -D warnings steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: false @@ -94,47 +94,50 @@ jobs: macos: ${{ steps.resolve.outputs.macos }} cli: ${{ steps.resolve.outputs.cli }} env: - MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - PFX_BASE64: ${{ secrets.PFX_BASE64 }} - PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} + HAS_P12: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + HAS_P12_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD != '' }} + HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }} + HAS_APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD != '' }} + HAS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID != '' }} + HAS_APPLE_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY != '' }} + HAS_UPDATER_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + HAS_UPDATER_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD != '' }} + HAS_PFX: ${{ secrets.PFX_BASE64 != '' }} + HAS_PFX_PASSWORD: ${{ secrets.PFX_PASSWORD != '' }} steps: - name: Signing secrets come in pairs run: | fail() { echo "::error::$1"; exit 1; } - if [ -n "$MACOS_CERTIFICATE_P12" ] && [ -z "$MACOS_CERTIFICATE_PASSWORD" ]; then + if [ "$HAS_P12" = "true" ] && [ "$HAS_P12_PASSWORD" != "true" ]; then fail "MACOS_CERTIFICATE_P12 is set but MACOS_CERTIFICATE_PASSWORD is empty" fi - if [ -n "$PFX_BASE64" ] && [ -z "$PFX_PASSWORD" ]; then + if [ "$HAS_PFX" = "true" ] && [ "$HAS_PFX_PASSWORD" != "true" ]; then fail "PFX_BASE64 is set but PFX_PASSWORD is empty" fi - if [ -n "$APPLE_ID" ] && { [ -z "$APPLE_APP_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; }; then + if [ "$HAS_APPLE_ID" = "true" ] && + { [ "$HAS_APPLE_APP_PASSWORD" != "true" ] || [ "$HAS_APPLE_TEAM_ID" != "true" ]; }; then fail "APPLE_ID is set but APPLE_APP_PASSWORD or APPLE_TEAM_ID is empty" fi - if [ -n "$MACOS_CERTIFICATE_P12" ] && [ -z "$APPLE_SIGNING_IDENTITY" ]; then + if [ "$HAS_P12" = "true" ] && [ "$HAS_APPLE_IDENTITY" != "true" ]; then fail "MACOS_CERTIFICATE_P12 is set but APPLE_SIGNING_IDENTITY is empty: the app would be bundled unsigned" fi - if [ -n "$TAURI_SIGNING_PRIVATE_KEY" ] && [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then + if [ "$HAS_UPDATER_KEY" = "true" ] && [ "$HAS_UPDATER_PASSWORD" != "true" ]; then fail "TAURI_SIGNING_PRIVATE_KEY is set but its password is empty" fi - if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then + if [ "$HAS_UPDATER_KEY" != "true" ]; then echo "::warning::no updater key; this release cannot be installed by an existing copy" fi echo "signing secrets are coherent" - id: resolve + env: + ASKED_FOR: ${{ inputs.version }} run: | if [[ "$GITHUB_REF" =~ ^refs/tags/v(.+)$ ]]; then version="${BASH_REMATCH[1]}" else - version="${{ inputs.version }}" + version="$ASKED_FOR" fi # Semver's own grammar, not an approximation of it: a version this lets through and @@ -191,8 +194,8 @@ jobs: - name: Build run: | - cargo build --release --bin tisty --target aarch64-apple-darwin - cargo build --release --bin tisty --target x86_64-apple-darwin + cargo build --release --locked --bin tisty --target aarch64-apple-darwin + cargo build --release --locked --bin tisty --target x86_64-apple-darwin mkdir -p "target/$TARGET/release" lipo -create -output "target/$TARGET/release/tisty" \ @@ -285,7 +288,7 @@ jobs: - name: Build shell: bash - run: cargo build --release --bin tisty --target "$TARGET" + run: cargo build --release --locked --bin tisty --target "$TARGET" - name: Sign executable if: env.PFX_BASE64 != '' @@ -367,7 +370,7 @@ jobs: - name: The CLI rides along shell: bash run: | - cargo build --release --bin tisty + cargo build --release --locked --bin tisty mkdir -p app/src-tauri/binaries cp target/release/tisty.exe "app/src-tauri/binaries/tisty-$TARGET.exe" @@ -856,7 +859,7 @@ jobs: - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: - subject-path: dist/tisty-* + subject-path: dist/tisty-*,dist/SHA256SUMS - name: Notes from the tag id: notes @@ -1017,14 +1020,27 @@ jobs: fi latest=$(jq -r '.latest // ""' release-manifest.json 2>/dev/null || echo "") + ahead=$(jq -r '.latestPrerelease // ""' release-manifest.json 2>/dev/null || echo "") if [ "${{ needs.version.outputs.prerelease }}" = "true" ]; then jq -n --arg l "${latest:-0.0.0}" --arg c "$v" \ '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json [ -s "$RUNNER_TEMP/channel.json" ] && cp "$RUNNER_TEMP/channel.json" candidate.json else - jq -n --arg l "$v" '{schema: 1, latest: $l}' > release-manifest.json + # A candidate of a later series is not retired by a fix to an earlier one: whoever + # follows that channel chose it, and nothing here decided to take them off it. + keep="" + if [ -n "$ahead" ] \ + && [ "$(printf '%s\n%s\n' "${ahead%%-*}" "$v" | sort -V | tail -1)" != "$v" ]; then + keep="$ahead" + fi + if [ -n "$keep" ]; then + jq -n --arg l "$v" --arg c "$keep" \ + '{schema: 1, latest: $l, latestPrerelease: $c}' > release-manifest.json + else + jq -n --arg l "$v" '{schema: 1, latest: $l}' > release-manifest.json + rm -f candidate.json + fi [ -s "$RUNNER_TEMP/channel.json" ] && cp "$RUNNER_TEMP/channel.json" latest.json - rm -f candidate.json fi cat release-manifest.json @@ -1139,10 +1155,11 @@ jobs: msstore: name: Microsoft Store - needs: [version, publish, bundle-msix] + needs: [version, publish, verify, bundle-msix] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.bundle-msix.result == 'success' && needs.version.outputs.prerelease == 'false' runs-on: windows-latest @@ -1221,10 +1238,11 @@ jobs: registry: name: MCP Registry - needs: [version, publish] + needs: [version, publish, verify] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.version.outputs.cli == 'true' && needs.version.outputs.prerelease == 'false' runs-on: ubuntu-latest @@ -1296,10 +1314,11 @@ jobs: homebrew: name: Homebrew tap - needs: [version, publish] + needs: [version, publish, verify] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.version.outputs.macos == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -1359,7 +1378,9 @@ jobs: echo "::warning::a disk image is missing for ${VERSION}; leaving the cask as it is" fi - git clone "https://x-access-token:${GIST_TOKEN}@github.com/rgdevment/homebrew-tap.git" /tmp/tap + git clone https://github.com/rgdevment/homebrew-tap.git /tmp/tap + git -C /tmp/tap config --local http.extraheader \ + "AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GIST_TOKEN" | base64 -w0)" cd /tmp/tap git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -1450,10 +1471,11 @@ jobs: winget: name: Windows Package Manager - needs: [version, publish] + needs: [version, publish, verify] if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.version.result == 'success' && needs.publish.result == 'success' + && needs.verify.result == 'success' && needs.version.outputs.prerelease == 'false' runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 5d015444..baefdc1a 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -120,8 +120,8 @@ jobs: same README.md README.es.md same app/src-tauri/resources/guide/en/guide.md app/src-tauri/resources/guide/es/guia.md - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: shared-key: workspace save-if: ${{ github.ref == 'refs/heads/main' }} From 7dc7af5ec47eb6884189ce591e80c392b27f3627 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:37:07 -0300 Subject: [PATCH 06/18] fix: the Store carries signed binaries, and the tap retires a channel the feed already did --- .github/workflows/release.yml | 46 ++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d17cb85d..2b12d991 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -386,7 +386,6 @@ jobs: # The MSIX packs these same bytes; building them a second time over there bought nothing. - name: Set the binaries aside for the MSIX - id: stage shell: bash run: | mkdir -p stage/cli @@ -395,6 +394,34 @@ jobs: if [ -d target/release/resources ]; then cp -R target/release/resources stage/resources fi + + # Microsoft signs the package it ingests, not what is inside it: unsigned here is unsigned + # on the machine that installs it. + - name: Sign what the MSIX will carry + if: env.PFX_BASE64 != '' + shell: pwsh + run: | + $bytes = [Convert]::FromBase64String($env:PFX_BASE64) + Set-Content -Path signingCert.pfx -Value $bytes -AsByteStream + try { + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -like "*x64*" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + & $signtool.FullName sign /f signingCert.pfx /p $env:PFX_PASSWORD ` + /tr http://timestamp.digicert.com /td sha256 /fd sha256 ` + stage/Tisty.exe stage/cli/tisty.exe + if ($LASTEXITCODE -ne 0) { Write-Error "signing failed"; exit 1 } + } finally { + Remove-Item signingCert.pfx -ErrorAction SilentlyContinue + } + + - name: What the MSIX job must find unchanged + id: stage + shell: bash + run: | echo "sha=$(cat stage/Tisty.exe stage/cli/tisty.exe | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v7 @@ -1451,6 +1478,23 @@ jobs: fi done + # The feed retires the candidate when a stable passes it; the tap has to agree, or a + # channel nobody maintains goes on offering a build from months ago. + if [ "${{ needs.version.outputs.prerelease }}" = "false" ]; then + beta=$(sed -n 's/^[[:space:]]*version "\(.*\)"/\1/p' \ + "Casks/${CASK_OTHER}.rb" 2>/dev/null | head -1) + if [ -n "$beta" ] \ + && [ "$(printf '%s\n%s\n' "${beta%%-*}" "$VERSION" | sort -V | tail -1)" \ + = "$VERSION" ]; then + for gone in "Casks/${CASK_OTHER}.rb" "Formula/${FORMULA_OTHER}.rb"; do + if [ -f "$gone" ]; then + git rm -q "$gone" + echo "retired $gone: it still offered $beta, and $VERSION passes it" + fi + done + fi + fi + git add Casks Formula if git diff --cached --quiet; then echo "tap already at ${VERSION}" From 919b9433aa8afef774b5a80f8c784125b645d461 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:39:36 -0300 Subject: [PATCH 07/18] fix: a release without its MSIX stops instead of leaving the Store behind --- .github/workflows/feed.yml | 2 +- .github/workflows/release.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml index 3d68d63d..6dffd37d 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -123,7 +123,7 @@ jobs: if [ -n "$ahead" ]; then echo "the candidates' channel, announcing $ahead:" takes candidate.json "$ahead" - elif curl -fsSL -o /dev/null "$feed/candidate.json" 2>/dev/null; then + elif curl -fsSL -H "Cache-Control: no-cache" -o /dev/null \n "$feed/candidate.json" 2>/dev/null; then echo "::error::candidate.json is still being served and the feed announces no \ candidate. A copy that remembers one would be sent to a version nobody is \ publishing any more." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b12d991..eba529c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -855,6 +855,9 @@ jobs: v="${{ needs.version.outputs.version }}" status=0 want="tisty-installer-$v-windows-x86_64.exe" + if [ "${{ needs.version.outputs.prerelease }}" = "false" ]; then + want="$want tisty-$v-windows-x86_64.msix" + fi if [ "${SHIP_CLI}" = "true" ]; then want="$want tisty-cli-$v-windows-x86_64.zip"; fi if [ "${BUILD_MACOS}" = "true" ]; then if [ "${SHIP_CLI}" = "true" ]; then From 5ac9cd3d3533d3faf8770b19b5b3eeace5870536 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:43:55 -0300 Subject: [PATCH 08/18] fix: the smaller edges the audits found, from the cask caveat to a guessable delimiter --- .github/workflows/commits.yml | 6 ++++-- .github/workflows/release.yml | 16 +++++++++++++--- .github/workflows/rules.yml | 9 ++++++++- package-lock.json | 32 ++++++++++++++++++++++++++++---- package.json | 1 + 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index ad863c3f..b0cb9904 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -34,13 +34,15 @@ jobs: subject=$(git log -1 --format=%s "$sha" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if ! printf '%s' "$subject" | grep -qE "$pattern"; then - echo "::error::${sha:0:8} does not follow conventional commits: $subject" + echo "::error::${sha:0:8} does not follow conventional commits" + printf ' %s\n' "$subject" failed=1 continue fi if [ "${#subject}" -gt 90 ]; then - echo "::error::${sha:0:8} subject is ${#subject} characters, keep it under 90: $subject" + echo "::error::${sha:0:8} subject is ${#subject} characters, keep it under 90" + printf ' %s\n' "$subject" failed=1 fi done < <(git rev-list --no-merges "$BASE".."$HEAD") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eba529c5..49e623cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -700,7 +700,7 @@ jobs: = "${{ needs.bundle-windows.outputs.stage }}" test "$(./msix/cli/tisty.exe --version)" = "tisty $v" mkdir -p msix/Assets - for logo in StoreLogo Square44x44Logo Square71x71Logo Square150x150Logo Square310x310Logo; do + for logo in StoreLogo Square44x44Logo Square71x71Logo Square150x150Logo; do cp "app/src-tauri/icons/$logo.png" "msix/Assets/$logo.png" done cp app/src-tauri/icons/Square44x44Logo.targetsize-*_altform-unplated.png msix/Assets/ @@ -897,10 +897,11 @@ jobs: body=$(git tag -l --format='%(contents:subject)%0a%0a%(contents:body)' "${GITHUB_REF_NAME}" \ | sed '/-----BEGIN SSH SIGNATURE-----/,$d' \ | sed -e :a -e '/^\n*$/{$d;N;ba}') + edge="TISTY_EOF_$(openssl rand -hex 8)" { - echo "body<> "$GITHUB_OUTPUT" # Asked before anything is out. Found afterwards, the release would already be published @@ -909,6 +910,10 @@ jobs: run: | v="${{ needs.version.outputs.version }}" if ! git fetch origin manifest --depth 1 2>/dev/null; then + if git ls-remote --exit-code --heads origin manifest >/dev/null 2>&1; then + echo "::error::the feed exists and could not be read, so it cannot be checked" + exit 1 + fi echo "no feed yet" exit 0 fi @@ -1435,6 +1440,11 @@ jobs: conflicts_with cask: "${CASK_OTHER}" depends_on macos: :ventura + caveats <<~TEXT + Tisty needs macOS 13.3 or newer. Homebrew can only check for 13, + so on 13.0 to 13.2 it installs and then will not open. + TEXT + app "Tisty.app" # The command line travels inside the app, as the assistant's door. diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index baefdc1a..e1c59e54 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -25,11 +25,18 @@ jobs: # A workflow that will not parse fails in no time at all, with no job and no line number, and # the one that matters is the release: it is only ever run when a tag is already pushed. + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - name: The workflows parse run: | set -euo pipefail for one in .github/workflows/*.yml; do - if ! npx --yes js-yaml@4.1.0 "$one" > /dev/null; then + if ! npx --no-install js-yaml "$one" > /dev/null; then echo "::error::$one is not valid YAML, so whatever it runs will not run" exit 1 fi diff --git a/package-lock.json b/package-lock.json index eafd5d33..4ded07f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "tisty", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.23.2" } }, @@ -428,9 +429,9 @@ } }, "node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -447,7 +448,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.mjs" + "js-yaml": "bin/js-yaml.js" } }, "node_modules/jsonc-parser": { @@ -596,6 +597,29 @@ "markdownlint-cli2": ">=0.0.4" } }, + "node_modules/markdownlint-cli2/node_modules/js-yaml": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/mdurl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", diff --git a/package.json b/package.json index 965605bc..eb953cbd 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "repository": "github:rgdevment/Tisty", "devDependencies": { + "js-yaml": "^4.1.0", "markdownlint-cli2": "^0.23.2" }, "scripts": { From d10f235697dba6f9cccebad56543769dc9f6246b Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:47:12 -0300 Subject: [PATCH 09/18] fix: a tag on a commit CI never saw is not a release --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49e623cb..bdad0291 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,41 @@ jobs: app/package-lock.json - run: npm ci - run: npm run lint:md + # Nothing here runs the suite, and a tag can be put on any commit. The one that reached + # main was already tested there; a tag on anything else has no such claim behind it. + - name: This commit was tested when it landed + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for attempt in $(seq 1 30); do + said=$(gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push \ + --limit 1 --json status,conclusion \ + --jq 'if length == 0 then "" else .[0] | "\(.status) \(.conclusion)" end' 2>/dev/null || echo "") + case "$said" in + "completed success") + echo "the commit this tag names passed CI on main" + exit 0 + ;; + "completed "*) + echo "::error::CI on ${GITHUB_SHA:0:8} ended ${said#completed }, so this tag is \ + not a release" + exit 1 + ;; + "") + echo "::error::no CI run for ${GITHUB_SHA:0:8}: a tag belongs on a commit that \ + reached main and was tested there" + exit 1 + ;; + *) + echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/30)" + sleep 30 + ;; + esac + done + echo "::error::CI on ${GITHUB_SHA:0:8} did not finish in fifteen minutes" + exit 1 + # The registry has rules of its own — a description over 100 characters is the one # that bites — and finding out after the release is announced is too late. From 98697b3200beaef983963791d8d2a8cd7a179548 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:49:33 -0300 Subject: [PATCH 10/18] fix: the listing in the repository stops claiming to be a release it is not --- scripts/listing.py | 5 +++++ server.json | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/listing.py b/scripts/listing.py index a6f473df..c04b5c74 100644 --- a/scripts/listing.py +++ b/scripts/listing.py @@ -19,5 +19,10 @@ if len(listing["description"]) > 100: sys.exit(f"the registry takes 100 characters, and this is {len(listing['description'])}") +# The file in the repository is a template on purpose; publishing it unstamped would list a +# release that does not exist, at a version nobody can ever correct. +if listing["version"] == "0.0.0" or set(listing["packages"][0]["fileSha256"]) == {"0"}: + sys.exit("the listing was not stamped: it still carries the template's version or hash") + io.open(AT, "w", encoding="utf-8", newline="\n").write(json.dumps(listing, indent=2) + "\n") print(json.dumps(listing, indent=2)) diff --git a/server.json b/server.json index 0a7b7987..15e9dcf6 100644 --- a/server.json +++ b/server.json @@ -8,12 +8,12 @@ "url": "https://github.com/rgdevment/Tisty", "source": "github" }, - "version": "1.20.0", + "version": "0.0.0", "packages": [ { "registryType": "mcpb", - "identifier": "https://github.com/rgdevment/Tisty/releases/download/v1.20.0/tisty-mcp-1.20.0.mcpb", - "fileSha256": "45243a87bd8b33073ce04381a73c6162bd4d456771d68b8a80022e9f041637ee", + "identifier": "https://github.com/rgdevment/Tisty/releases/download/v0.0.0/tisty-mcp-0.0.0.mcpb", + "fileSha256": "0000000000000000000000000000000000000000000000000000000000000000", "transport": { "type": "stdio" } } ] From 60fd49e765efd5c6ebef610e76b571a1ec43a1ce Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 22 Sep 2026 23:51:05 -0300 Subject: [PATCH 11/18] fix: a third tag no longer cancels the second one's release in silence --- .github/workflows/release.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdad0291..fbca13a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -823,6 +823,8 @@ jobs: if-no-files-found: error retention-days: 5 + # One group per tag, not one for all of them: a queued job is cancelled when a third + # arrives, and a release that silently never happened is worse than one that fails loudly. publish: name: GitHub Release needs: @@ -848,7 +850,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 concurrency: - group: release-publish + group: release-publish-${{ github.ref }} cancel-in-progress: false permissions: contents: write @@ -1235,7 +1237,7 @@ jobs: runs-on: windows-latest timeout-minutes: 20 concurrency: - group: release-msstore + group: release-msstore-${{ github.ref }} cancel-in-progress: false env: STORE_PUBLISH: ${{ vars.STORE_PUBLISH }} @@ -1321,7 +1323,7 @@ jobs: id-token: write contents: read concurrency: - group: release-registry + group: release-registry-${{ github.ref }} cancel-in-progress: false env: MCP_PUBLISH: ${{ vars.MCP_PUBLISH }} @@ -1393,7 +1395,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 concurrency: - group: release-homebrew + group: release-homebrew-${{ github.ref }} cancel-in-progress: false env: GIST_TOKEN: ${{ secrets.GIST_TOKEN }} @@ -1572,7 +1574,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 concurrency: - group: release-winget + group: release-winget-${{ github.ref }} cancel-in-progress: false env: WINGET_PUBLISH: ${{ vars.WINGET_PUBLISH }} From f308e7f6473c827408188fb0a5141544b3dfcb34 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 00:11:23 -0300 Subject: [PATCH 12/18] fix: a tag with an empty signing secret is stopped, not shipped unsigned The preflight only checked that the secrets came in pairs, so all of them empty passed with a warning and the release went out unsigned and uninstallable by any copy already out there. On a tag they are now required, named when missing, and checked before anything is built. A dispatch may still go unsigned: it publishes nothing. The feed guard only compared a candidate against the stable it belonged to, so rc.1 stayed on offer after rc.2 shipped. It now also loses to a tag that sorts above it and shares no base with it. Gatekeeper's verdict on the loose CLI binary is printed instead of discarded: a bare executable cannot be stapled, so the refusal is expected and worth saying. --- .github/workflows/release.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbca13a3..e46a0583 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -163,6 +163,27 @@ jobs: if [ "$HAS_UPDATER_KEY" != "true" ]; then echo "::warning::no updater key; this release cannot be installed by an existing copy" fi + + # A dispatch may go unsigned, it publishes nothing. A tag is what people install, and + # an empty secret there ships a build nobody can install and nobody can check. + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + missing="" + want() { [ "$2" = "true" ] || missing="$missing $1"; } + want TAURI_SIGNING_PRIVATE_KEY "$HAS_UPDATER_KEY" + want TAURI_SIGNING_PRIVATE_KEY_PASSWORD "$HAS_UPDATER_PASSWORD" + want PFX_BASE64 "$HAS_PFX" + want PFX_PASSWORD "$HAS_PFX_PASSWORD" + if [ "$BUILD_MACOS" = "true" ]; then + want MACOS_CERTIFICATE_P12 "$HAS_P12" + want MACOS_CERTIFICATE_PASSWORD "$HAS_P12_PASSWORD" + want APPLE_SIGNING_IDENTITY "$HAS_APPLE_IDENTITY" + want APPLE_ID "$HAS_APPLE_ID" + want APPLE_APP_PASSWORD "$HAS_APPLE_APP_PASSWORD" + want APPLE_TEAM_ID "$HAS_APPLE_TEAM_ID" + fi + [ -z "$missing" ] || fail "this tag would be released without:$missing" + fi + echo "signing secrets are coherent" - id: resolve @@ -291,7 +312,12 @@ jobs: xcrun notarytool submit "$RUNNER_TEMP/tisty.zip" \ --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" \ --team-id "$APPLE_TEAM_ID" --wait --timeout 600 - spctl --assess --type exec -vv "$BIN" || true + # A bare executable cannot be stapled, so a refusal here is expected, not a break. + if spctl --assess --type exec -vv "$BIN" 2>&1; then + echo "Gatekeeper accepts it" + else + echo "::notice::Gatekeeper would not assess the loose binary: notarised but not stapled, which is what an executable that is not an app gets" + fi - name: Cleanup keychain if: always() && env.KEYCHAIN_PATH != '' @@ -1198,6 +1224,9 @@ jobs: fi elif [ "$v" != "$mine" ] && [ "$ahead" = "$theirs" ]; then overtaken=yes + elif [ "$v" != "$mine" ] && [ "$ahead" != "$theirs" ] \ + && [ "$(printf "%s\n%s\n" "$v" "$ahead" | sort -V | tail -1)" = "$ahead" ]; then + overtaken=yes fi fi From 78d36ccadd11be2d64cca2dff5cb7c4755d9c36b Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 00:17:56 -0300 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20a=20Store=20that=20never=20answere?= =?UTF-8?q?d=20stops=20reading=20as=20=C2=AByou=20are=20up=20to=20date?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty answer left no trace at all. Whether the Store named nothing, named other packages, or answered from what it last knew, the log said the same: nothing. It now records what came back and which family name was looked for, so the difference is on disk the next time this happens. And pressing the button when the Store cannot be asked no longer draws the green «nothing newer» line. That reading is what hid this for whole versions: a copy the Store never signed, a call that timed out and a refusal all landed on the same sentence as a real answer. --- app/src-tauri/src/lib.rs | 3 +++ app/src-tauri/src/shop.rs | 23 ++++++++++++++++++++++- app/src/locales.ts | 4 ++++ app/src/refusal.ts | 1 + app/src/tests/about.test.tsx | 17 +++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 87dcff38..16f6298e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2576,6 +2576,9 @@ async fn update_ready( // and a copy the Store never signed is not asked again every few hours. shop::Shelf::Silent => { held(&session).keep(|c| c.checked_at = Some(now))?; + if asked { + return Err(Refusal::of("updateUnanswered")); + } Ok(last_said()) } }; diff --git a/app/src-tauri/src/shop.rs b/app/src-tauri/src/shop.rs index 069d6c90..0e503b9c 100644 --- a/app/src-tauri/src/shop.rs +++ b/app/src-tauri/src/shop.rs @@ -116,12 +116,33 @@ mod there { .GetAppAndOptionalStorePackageUpdatesAsync()? .join()?; let ours = Package::Current()?.Id()?.FamilyName()?; + let mut offered: Vec = Vec::new(); for one in 0..updates.Size()? { let id = updates.GetAt(one)?.Package()?.Id()?; - if id.FamilyName()? == ours { + let name = id.FamilyName()?; + if name == ours { return Ok(Some(numbered(&id.Version()?))); } + offered.push(name.to_string()); } + // The Store answers from what it last knew unless it is due to ask again, so an empty + // answer here is either «nothing for you» or «I did not look», and they read the same. + witness::note( + channel::WINDOW, + "the Store was asked what it has and named nothing for this package", + &[ + ("ours", Fact::Id(ours.to_string())), + ("offered", Fact::Count(offered.len())), + ( + "names", + Fact::Why(if offered.is_empty() { + "nothing at all".to_string() + } else { + offered.join(", ") + }), + ), + ], + ); Ok(None) } diff --git a/app/src/locales.ts b/app/src/locales.ts index fda104f4..6925d415 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -605,6 +605,8 @@ const en = { betaWarns: "Candidates come out before a release is finished, so they are less tested and some break. Turning this off leaves any candidate already installed where it is: the next stable release replaces it.", updateFailed: "The update could not be installed — {name}", + updateUnanswered: + "The Microsoft Store did not answer, so nothing is known about updates right now. What it last said still stands.", aboutStore: "Store", aboutRepo: "Open the repository", aboutNotices: "Third-party notices", @@ -1909,6 +1911,8 @@ const es: Catalog = { betaWarns: "Las candidatas salen antes de que una versión esté terminada, así que están menos probadas y algunas fallan. Desactivarlo no deshace la candidata que ya tengas: la reemplaza la siguiente versión estable.", updateFailed: "No se pudo instalar la actualización — {name}", + updateUnanswered: + "La Microsoft Store no respondió, así que ahora mismo no se sabe nada de actualizaciones. Sigue valiendo lo último que dijo.", aboutStore: "Almacén", aboutRepo: "Abrir el repositorio", aboutNotices: "Avisos de terceros", diff --git a/app/src/refusal.ts b/app/src/refusal.ts index b4e9ba0a..ba1db439 100644 --- a/app/src/refusal.ts +++ b/app/src/refusal.ts @@ -14,6 +14,7 @@ const KNOWN = [ "updateMoved", "updateStopped", "updateFailed", + "updateUnanswered", "untitled", "noSuchList", "ambiguousList", diff --git a/app/src/tests/about.test.tsx b/app/src/tests/about.test.tsx index 58af3751..229fb1b9 100644 --- a/app/src/tests/about.test.tsx +++ b/app/src/tests/about.test.tsx @@ -156,6 +156,23 @@ describe("a newer version the Store itself offers", () => { expect(screen.queryByText(/newest version/i)).toBeNull(); }); + // «Could not ask» and «nothing for you» read the same on screen, and that is what hid a Store + // that had not answered for whole versions. + it("reports a Store that did not answer instead of leaving «nothing newer» standing", async () => { + const problems: unknown[] = []; + ipc.answer = (cmd) => + cmd === "update_ready" + ? Promise.reject({ code: "updateUnanswered" }) + : Promise.resolve({ ...build, keptByTheStore: true }); + render( problems.push(problem)} />); + + await screen.findByText("0.1.0"); + await userEvent.click(screen.getByRole("button", { name: /check for updates/i })); + + await waitFor(() => expect(problems.length).toBe(1)); + expect(saidPlainly(problems[0])).toMatch(/did not answer/i); + }); + const waiting = { version: "1.15.0", route: "store" as const, package: null, installs: true }; // A copy left closed for weeks remembers an offer the feed has moved past; the button must not From 0f8c42e4ca1bb4294ef3be219d2e5f7dd038b90a Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 00:29:34 -0300 Subject: [PATCH 14/18] feat: the installer stops carrying binaries nobody signed Only the setup was signed. What it put on disk was not, so running the command line from a terminal raised SmartScreen on a release that was signed all along. tauri build compiles and packs in one step, so the bundler has to do it: the certificate goes to the store and the sign command picks it by thumbprint, with no password on any command line. It is merged in with --config at build time and never written to the config file. Left there, every local build would try to run a signing command that is not on that machine and fail. --- .github/workflows/release.yml | 35 ++++++++++++++++++++++++++++++++++- .gitignore | 1 + 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e46a0583..32bf07e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -437,13 +437,46 @@ jobs: # A public key in the config makes the private one a build requirement, so a run without the # secret has to say so rather than fail on a line about an environment variable. + # `tauri build` compiles and packs in one step, so what ends up inside the installer can + # only be signed by the bundler. The certificate goes to the store and the command picks it + # by thumbprint: no password on a command line. Merged in, never in the config file — a + # build without the certificate must not fail on a command it cannot run. + - name: Teach the bundler to sign what it packs + if: env.PFX_BASE64 != '' + shell: pwsh + run: | + $pfx = Join-Path $env:RUNNER_TEMP "bundler.pfx" + Set-Content -Path $pfx -Value ([Convert]::FromBase64String($env:PFX_BASE64)) -AsByteStream + try { + $said = ConvertTo-SecureString $env:PFX_PASSWORD -AsPlainText -Force + $cert = @(Import-PfxCertificate -FilePath $pfx ` + -CertStoreLocation Cert:\CurrentUser\My -Password $said)[0] + } finally { + Remove-Item $pfx -ErrorAction SilentlyContinue + } + + $signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" ` + -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -match "x64" } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { Write-Error "signtool not found"; exit 1 } + + @{ bundle = @{ windows = @{ signCommand = @{ + cmd = $signtool.FullName + args = @("sign", "/sha1", $cert.Thumbprint, "/fd", "sha256", + "/tr", "http://timestamp.digicert.com", "/td", "sha256", "%1") + } } } } | ConvertTo-Json -Depth 8 | Set-Content -Path app/sign.json -Encoding utf8 + Write-Host "the bundler signs with $($cert.Thumbprint)" + - name: Bundle working-directory: app shell: bash run: | sign="" if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then sign="--no-sign"; fi - npm run tauri build -- --bundles nsis $sign + packs="" + if [ -f sign.json ]; then packs="--config sign.json"; fi + npm run tauri build -- --bundles nsis $sign $packs # The MSIX packs these same bytes; building them a second time over there bought nothing. - name: Set the binaries aside for the MSIX diff --git a/.gitignore b/.gitignore index 2b25d4d7..6cb625a0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,6 @@ app/reports app/stryker-setup-*.js mutants.json window.json +app/sign.json __pycache__/ From ce75829284849ca2b827f704a98423d518aed850 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 00:45:03 -0300 Subject: [PATCH 15/18] fix: three guards that were switched off, and the mutants that stopped running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A line continuation in the feed check had become a literal backslash-n, so curl was handed «n» as a second URL and failed every time. The guard that catches a candidate.json still being served while the feed announces none has therefore never once fired. The release gate waits fifteen minutes for CI to finish and the job it lives in was given ten, so a tag pushed while main was still building would have died on a runner timeout rather than on the message that explains why. It also read «no run yet» as «never ran», which is exactly what a tag pushed right behind its commit looks like. Stryker asks TypeScript for parseConfigFileTextToJson, which TypeScript 7 no longer exposes, and mutation testing has been failing since that upgrade landed. It only needs that call to rewrite a tsconfig for the sandbox, and this one has nothing to rewrite: every path in it is already inside. --- .github/workflows/feed.yml | 3 ++- .github/workflows/release.yml | 15 +++++++-------- app/stryker.config.json | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/feed.yml b/.github/workflows/feed.yml index 6dffd37d..65d7b37b 100644 --- a/.github/workflows/feed.yml +++ b/.github/workflows/feed.yml @@ -123,7 +123,8 @@ jobs: if [ -n "$ahead" ]; then echo "the candidates' channel, announcing $ahead:" takes candidate.json "$ahead" - elif curl -fsSL -H "Cache-Control: no-cache" -o /dev/null \n "$feed/candidate.json" 2>/dev/null; then + elif curl -fsSL -H "Cache-Control: no-cache" -o /dev/null \ + "$feed/candidate.json" 2>/dev/null; then echo "::error::candidate.json is still being served and the feed announces no \ candidate. A copy that remembers one would be sent to a version nobody is \ publishing any more." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32bf07e9..025f6d62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: gate: name: Style and prose runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -69,9 +69,8 @@ jobs: exit 1 ;; "") - echo "::error::no CI run for ${GITHUB_SHA:0:8}: a tag belongs on a commit that \ - reached main and was tested there" - exit 1 + echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/30)" + sleep 30 ;; *) echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/30)" @@ -79,10 +78,10 @@ jobs: ;; esac done - echo "::error::CI on ${GITHUB_SHA:0:8} did not finish in fifteen minutes" + echo "::error::fifteen minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ + ran: a tag belongs on a commit that reached main and was tested there" exit 1 - # The registry has rules of its own — a description over 100 characters is the one # that bites — and finding out after the release is announced is too late. - name: Would the registry take this listing @@ -435,8 +434,6 @@ jobs: mkdir -p app/src-tauri/binaries cp target/release/tisty.exe "app/src-tauri/binaries/tisty-$TARGET.exe" - # A public key in the config makes the private one a build requirement, so a run without the - # secret has to say so rather than fail on a line about an environment variable. # `tauri build` compiles and packs in one step, so what ends up inside the installer can # only be signed by the bundler. The certificate goes to the store and the command picks it # by thumbprint: no password on a command line. Merged in, never in the config file — a @@ -468,6 +465,8 @@ jobs: } } } } | ConvertTo-Json -Depth 8 | Set-Content -Path app/sign.json -Encoding utf8 Write-Host "the bundler signs with $($cert.Thumbprint)" + # A public key in the config makes the private one a build requirement, so a run without + # the secret has to say so rather than fail on a line about an environment variable. - name: Bundle working-directory: app shell: bash diff --git a/app/stryker.config.json b/app/stryker.config.json index dca74926..52c72574 100644 --- a/app/stryker.config.json +++ b/app/stryker.config.json @@ -2,6 +2,7 @@ "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", "packageManager": "npm", "testRunner": "vitest", + "tsconfigFile": "tsconfig.none.json", "vitest": { "configFile": "vite.config.ts" }, From f636c8310c524d914b5198e0b61e675ad2b01bf2 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 01:01:08 -0300 Subject: [PATCH 16/18] test: the installer is opened and what it carries is read, not assumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundler is handed a signing command and a green job only says it did not crash. The installer is now unpacked and every binary it will put on disk has its signature read, so «what goes inside is signed» is a thing this pipeline proves rather than a thing it intends. --- .github/workflows/release.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 025f6d62..44b39fba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -511,6 +511,36 @@ jobs: Remove-Item signingCert.pfx -ErrorAction SilentlyContinue } + # The bundler is handed a signing command; whether it used it is only readable in the + # installer it produced, so the installer is opened and what it carries is checked. + - name: What the installer will put on disk is signed + if: env.PFX_BASE64 != '' + shell: pwsh + run: | + $setup = Get-ChildItem -Path "target/release/bundle/nsis" -Filter "*-setup.exe" | + Select-Object -First 1 + if (-not $setup) { Write-Error "no installer to open"; exit 1 } + + $peek = Join-Path $env:RUNNER_TEMP "peek" + & "C:\Program Files\7-Zip\7z.exe" x $setup.FullName "-o$peek" -y | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "the installer could not be opened"; exit 1 } + + $status = 0 + foreach ($name in "Tisty.exe", "tisty.exe") { + $found = Get-ChildItem -Path $peek -Recurse -Filter $name -ErrorAction SilentlyContinue + if (-not $found) { Write-Error "$name is not in the installer"; $status = 1; continue } + foreach ($one in $found) { + $sig = Get-AuthenticodeSignature $one.FullName + if ($sig.SignerCertificate) { + Write-Host "$($one.Name) carries $($sig.SignerCertificate.Subject)" + } else { + Write-Error "$($one.FullName) goes out unsigned" + $status = 1 + } + } + } + exit $status + - name: What the MSIX job must find unchanged id: stage shell: bash From 296330af309e572d90b1c86089af2a989cd0d692 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 01:06:04 -0300 Subject: [PATCH 17/18] fix: the wait for CI is wider than the time CI actually takes The gate gave main's run fifteen minutes and the last three took eleven, twelve and fourteen. A cold cache or a queued runner was all it needed to fail a release for having been a minute slow. Twenty minutes now, inside a job of thirty, and the message says twenty. --- .github/workflows/release.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44b39fba..d885b7cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: gate: name: Style and prose runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 30 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -54,7 +54,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - for attempt in $(seq 1 30); do + for attempt in $(seq 1 40); do said=$(gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push \ --limit 1 --json status,conclusion \ --jq 'if length == 0 then "" else .[0] | "\(.status) \(.conclusion)" end' 2>/dev/null || echo "") @@ -69,16 +69,16 @@ jobs: exit 1 ;; "") - echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/30)" + echo "no CI run for ${GITHUB_SHA:0:8} yet (${attempt}/40)" sleep 30 ;; *) - echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/30)" + echo "CI on ${GITHUB_SHA:0:8} is still running (${attempt}/40)" sleep 30 ;; esac done - echo "::error::fifteen minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ + echo "::error::twenty minutes and CI on ${GITHUB_SHA:0:8} has not finished, or never \ ran: a tag belongs on a commit that reached main and was tested there" exit 1 From ad93d65b16912695cf5d8d7ecfef9e93aec4b854 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 23 Sep 2026 01:12:53 -0300 Subject: [PATCH 18/18] fix: the installer check looks for 7-Zip instead of assuming where it lives A hardcoded path is one image change away from failing a release for a reason that has nothing to do with the release. It now looks in both program folders and on the PATH, and says what is missing when none of them has it. --- .github/workflows/release.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d885b7cd..f0b2437a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -521,8 +521,18 @@ jobs: Select-Object -First 1 if (-not $setup) { Write-Error "no installer to open"; exit 1 } + $seven = @( + "C:\Program Files\7-Zip\7z.exe", + "C:\Program Files (x86)\7-Zip\7z.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $seven) { + $seven = (Get-Command 7z, 7za -ErrorAction SilentlyContinue | + Select-Object -First 1).Source + } + if (-not $seven) { Write-Error "no 7-Zip to open the installer with"; exit 1 } + $peek = Join-Path $env:RUNNER_TEMP "peek" - & "C:\Program Files\7-Zip\7z.exe" x $setup.FullName "-o$peek" -y | Out-Null + & $seven x $setup.FullName "-o$peek" -y | Out-Null if ($LASTEXITCODE -ne 0) { Write-Error "the installer could not be opened"; exit 1 } $status = 0