Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
# ============================================================
build-macos:
runs-on: macos-26
env:
KEYSTATS_SYNC_SERVICE_URL: ${{ vars.KEYSTATS_SYNC_PRODUCTION_URL }}
outputs:
version: ${{ steps.version.outputs.VERSION }}
sha256: ${{ steps.sha256.outputs.SHA256 }}
Expand Down Expand Up @@ -46,7 +48,12 @@ jobs:
run: ./scripts/check_vendored_helper.sh

- name: Build DMG
run: ./scripts/build_dmg.sh
run: |
if [[ ! "$KEYSTATS_SYNC_SERVICE_URL" =~ ^https://[A-Za-z0-9.-]+\.workers\.dev/?$ ]]; then
echo "KEYSTATS_SYNC_PRODUCTION_URL must be a production workers.dev URL."
exit 1
fi
./scripts/build_dmg.sh

- name: Validate Sparkle private key
env:
Expand Down Expand Up @@ -93,7 +100,8 @@ jobs:
archive \
CODE_SIGN_IDENTITY="-" \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO
ONLY_ACTIVE_ARCH=NO \
KEYSTATS_SYNC_SERVICE_URL="$KEYSTATS_SYNC_SERVICE_URL"

APP_PATH="$ARCHIVE_PATH/Products/Applications/$APP_NAME.app"
cp -R "$APP_PATH" "$STAGING_DIR/"
Expand Down Expand Up @@ -139,6 +147,8 @@ jobs:
# ============================================================
build-windows:
runs-on: windows-latest
env:
KEYSTATS_SYNC_SERVICE_URL: ${{ vars.KEYSTATS_SYNC_PRODUCTION_URL }}
outputs:
version: ${{ steps.version.outputs.VERSION }}

Expand Down Expand Up @@ -168,7 +178,10 @@ jobs:
shell: pwsh
working-directory: KeyStats.Windows
run: |
.\build.ps1 -Configuration Release
if ($env:KEYSTATS_SYNC_SERVICE_URL -notmatch '^https://[A-Za-z0-9.-]+\.workers\.dev/?$') {
throw 'KEYSTATS_SYNC_PRODUCTION_URL must be a production workers.dev URL.'
}
.\build.ps1 -Configuration Release -SyncServiceBaseUrl $env:KEYSTATS_SYNC_SERVICE_URL

- name: Upload Windows artifact
uses: actions/upload-artifact@v4
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/sync-worker-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Sync Worker CI

on:
pull_request:
paths:
- 'contracts/sync/v1/**'
- 'services/sync-worker/**'
- '.github/workflows/sync-worker-*.yml'
push:
branches-ignore: [main]
paths:
- 'contracts/sync/v1/**'
- 'services/sync-worker/**'
- '.github/workflows/sync-worker-*.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: sync-worker-ci-${{ github.ref }}
cancel-in-progress: true

defaults:
run:
working-directory: services/sync-worker

jobs:
validate:
name: Contract, migration, type and Worker tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: services/sync-worker/package-lock.json
- run: npm ci
- name: Validate schemas, OpenAPI and golden vectors
run: npm run contracts:check
- name: Apply D1 migrations to an isolated local database
run: npx wrangler d1 migrations apply DB --local
- name: Type-check Worker
run: npm run types:check && npm run typecheck
- name: Run Miniflare and unit tests
run: npm test
68 changes: 68 additions & 0 deletions .github/workflows/sync-worker-deploy-production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Deploy Sync Worker Production

on:
workflow_dispatch:
inputs:
confirmation:
description: 'Type deploy-production after staging verification'
required: true
type: string

permissions:
contents: read

concurrency:
group: sync-worker-production
cancel-in-progress: false

defaults:
run:
working-directory: services/sync-worker

jobs:
authorize:
name: Validate explicit confirmation
runs-on: ubuntu-latest
steps:
- name: Require exact production confirmation
if: ${{ inputs.confirmation != 'deploy-production' }}
run: exit 1

deploy:
name: Validate and deploy production
needs: authorize
runs-on: ubuntu-latest
timeout-minutes: 20
environment: sync-production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }}
TOKEN_HASH_KEY: ${{ secrets.TOKEN_HASH_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: services/sync-worker/package-lock.json
- run: npm ci
- run: npm run check
- name: Validate deployment secrets
run: |
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
echo "Cloudflare deployment credentials are not configured."
exit 1
fi
if [ "${#TOKEN_HASH_KEY}" -lt 32 ]; then
echo "TOKEN_HASH_KEY must contain at least 32 characters."
exit 1
fi
- name: Prepare bound production configuration
run: node scripts/write-deploy-config.mjs production wrangler.deploy.json
- name: Apply production D1 migrations
run: npx wrangler d1 migrations apply DB --env production --remote --config wrangler.deploy.json
- name: Configure token hashing secret
run: printf '%s' "$TOKEN_HASH_KEY" | npx wrangler secret put TOKEN_HASH_KEY --env production --config wrangler.deploy.json
- name: Deploy production Worker
run: npx wrangler deploy --env production --config wrangler.deploy.json
59 changes: 59 additions & 0 deletions .github/workflows/sync-worker-deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Deploy Sync Worker Staging

on:
push:
branches: [main]
paths:
- 'contracts/sync/v1/**'
- 'services/sync-worker/**'
- '.github/workflows/sync-worker-*.yml'

permissions:
contents: read

concurrency:
group: sync-worker-staging
cancel-in-progress: false

defaults:
run:
working-directory: services/sync-worker

jobs:
deploy:
name: Validate and deploy staging
runs-on: ubuntu-latest
timeout-minutes: 20
environment: sync-staging
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }}
TOKEN_HASH_KEY: ${{ secrets.TOKEN_HASH_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: services/sync-worker/package-lock.json
- run: npm ci
- run: npm run check
- name: Validate deployment secrets
run: |
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
echo "Cloudflare deployment credentials are not configured."
exit 1
fi
if [ "${#TOKEN_HASH_KEY}" -lt 32 ]; then
echo "TOKEN_HASH_KEY must contain at least 32 characters."
exit 1
fi
- name: Prepare bound staging configuration
run: node scripts/write-deploy-config.mjs staging wrangler.deploy.json
- name: Apply staging D1 migrations
run: npx wrangler d1 migrations apply DB --env staging --remote --config wrangler.deploy.json
- name: Configure token hashing secret
run: printf '%s' "$TOKEN_HASH_KEY" | npx wrangler secret put TOKEN_HASH_KEY --env staging --config wrangler.deploy.json
- name: Deploy staging Worker
run: npx wrangler deploy --env staging --config wrangler.deploy.json
35 changes: 35 additions & 0 deletions .github/workflows/windows-sync-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Windows Sync Tests

on:
pull_request:
paths:
- 'KeyStats.Windows/**'
- 'contracts/sync/v1/**'
- '.github/workflows/windows-sync-tests.yml'
push:
branches: [main]
paths:
- 'KeyStats.Windows/**'
- 'contracts/sync/v1/**'
- '.github/workflows/windows-sync-tests.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: windows-sync-tests-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: .NET Framework sync tests
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Run Windows sync tests
run: dotnet test KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj --configuration Release --verbosity normal
3 changes: 3 additions & 0 deletions KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;

[assembly: DoNotParallelize]
28 changes: 28 additions & 0 deletions KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="MSTest.TestAdapter" Version="4.3.0" />
<PackageReference Include="MSTest.TestFramework" Version="4.3.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\KeyStats\KeyStats.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="..\..\contracts\sync\v1\fixtures\crypto-vectors.json"
Link="Fixtures\crypto-vectors.json"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
97 changes: 97 additions & 0 deletions KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using KeyStats.Models;
using KeyStats.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace KeyStats.Sync.Tests;

[TestClass]
public sealed class RemoteShardCacheTests
{
[TestMethod]
public void SameRevision_IsIdempotentAndNewRevisionReplacesAggregate()
{
TestPlatform.RequireWindows();
using var directory = new TestDirectory();
var cache = new RemoteShardCache(directory.Path);
var aggregator = new DisplayStatsAggregator(cache, () => "local-device");
var first = CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5);

Assert.IsTrue(cache.Apply(first));
Assert.IsFalse(cache.Apply(CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5)));

var local = new DailyStats(new DateTime(2026, 7, 13))
{
KeyPresses = 2,
LeftClicks = 1,
KeyPressCounts = new Dictionary<string, int>(StringComparer.Ordinal) { ["A"] = 2 }
};
var firstAggregate = aggregator.Aggregate(local.Date, local);
Assert.AreEqual(7, firstAggregate.KeyPresses);
Assert.AreEqual(4, firstAggregate.LeftClicks);
Assert.AreEqual(7, firstAggregate.KeyPressCounts["A"]);

Assert.IsTrue(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8)));
Assert.IsFalse(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8)));
var secondAggregate = aggregator.Aggregate(local.Date, local);
Assert.AreEqual(10, secondAggregate.KeyPresses);
Assert.AreEqual(7, secondAggregate.LeftClicks);
Assert.AreEqual(10, secondAggregate.KeyPressCounts["A"]);
Assert.AreEqual(1, cache.GetAll().Count);

Assert.ThrowsExactly<RemoteShardConflictException>(() =>
cache.Apply(CreateRecord(revision: 2, ciphertextHash: "conflict", keyPresses: 8)));
}

[TestMethod]
public void Save_IsDurableAndReloadsLatestProtectedCache()
{
TestPlatform.RequireWindows();
using var directory = new TestDirectory();
var path = Path.Combine(directory.Path, "sync_cache.json");
var cache = new RemoteShardCache(directory.Path);

Assert.IsTrue(cache.Apply(CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5)));
Assert.IsTrue(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8)));

Assert.IsTrue(File.Exists(path));
Assert.IsTrue(File.Exists(path + ".bak"));
Assert.IsFalse(File.Exists(path + ".tmp"));

var reloaded = new RemoteShardCache(directory.Path);
Assert.IsFalse(reloaded.NeedsRepair);
var record = reloaded.GetAll().Single();
Assert.AreEqual(2L, record.Revision);
Assert.AreEqual("hash-2", record.CiphertextHash);
Assert.AreEqual(8L, record.Plaintext.KeyPresses);

File.WriteAllText(path, "damaged");
var recovered = new RemoteShardCache(directory.Path);
Assert.IsFalse(recovered.NeedsRepair);
Assert.AreEqual(1L, recovered.GetAll().Single().Revision);
}

private static CachedRemoteRecord CreateRecord(long revision, string ciphertextHash, long keyPresses)
{
return new CachedRemoteRecord
{
RecordId = "record-1",
DeviceId = "remote-device",
Revision = revision,
CiphertextHash = ciphertextHash,
IsCurrent = true,
Plaintext = new CoreDaySnapshotV1
{
DeviceId = "remote-device",
LocalDay = "2026-07-13",
Revision = revision,
KeyPresses = keyPresses,
KeyPressCounts = new Dictionary<string, long>(StringComparer.Ordinal) { ["A"] = keyPresses },
Clicks = new CoreClickSnapshotV1 { Left = keyPresses - 2 }
}
};
}
}
Loading
Loading