Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,36 @@ test that pins the format:
When changing wire format intentionally, update the fixture in a dedicated
commit so the diff is reviewable in isolation.

## Upstream gating (this repo's CI)

`.github/workflows/cross-repo-verify.yml` runs on every PR. On PRs that touch
the allowlisted wire-format paths (`src/modules/`, `src/__interop__/published/`,
`tools/generate-fixtures.ts`, and the workflow itself), it checks out app-main
and octi-desktop at their default branches and runs their consumer suites
against this PR's HEAD using the `INTEROP_FIXTURE_OVERRIDES` env var the
consumer sync code already accepts (sister gates: app-main's
`.github/workflows/cross-repo-verify.yml`, A3 / B-phase). A wire-incompatible
serializer change is blocked at the octi-web PR, not discovered later when a
consumer happens to bump its pin.

PRs that don't touch the allowlist still run the workflow but echo "no
wire-format-relevant paths changed; consumer verify will be skipped." and
exit 0 — required-check status reports green without leaving the check pending.

The override path drops the locked `manifest_sha256` as a trust anchor (no
committed sha can pin against an arbitrary head SHA) — per-file sha256s in the
consumer's freshly-fetched manifest stay as the anchor for that run.

### Fork PR limitation

Cross-repo `actions/checkout` of `${{ github.event.pull_request.head.sha }}`
works for same-repo branch PRs but NOT for fork PRs: the consumer's
`raw.githubusercontent.com` fetch of a fork-only SHA returns 404 against the
upstream's path. Fork contributors get a clear failure in the verify job.
Same constraint exists for any GitHub Actions secret access, so this is not
a new restriction — same gate behaviour as app-main and octi-desktop's
sister workflows.

## Smoke Suite

`src/__smoke__/smoke.test.ts` is excluded from the default `pnpm test` (the
Expand Down
236 changes: 236 additions & 0 deletions .github/workflows/cross-repo-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
name: Cross-repo wire-format verify

# Runs on every PR (no path filter at trigger level — branch-protection rules can require
# these checks without GitHub leaving them pending on path-skipped PRs). The relevance
# check happens inside the job, and irrelevant PRs early-exit with success.
#
# Sister workflow: app-main's .github/workflows/cross-repo-verify.yml (A3) fires the same
# shape from its direction. Each producer's PRs trigger the matching consumer suites
# against this PR's HEAD via INTEROP_FIXTURE_OVERRIDES.

on:
pull_request:
branches: [ main ]

permissions:
contents: read

concurrency:
group: cross-repo-verify-${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

env:
# Path prefixes that can affect the bytes octi-web puts on the wire AND that are
# actually exercised by the consumer fixtures in src/__interop__/published/. Entries
# ending in `/` match any file under that prefix; bare file entries must match exactly.
# Keep this allowlist tight — over-firing turns into "every PR pays the cross-repo CI
# cost for changes that can't actually break consumers".
ALLOWLIST: |
src/modules/
src/__interop__/published/
src/protocol/connector-id.ts
src/util/base64.ts
tools/generate-fixtures.ts
.github/workflows/cross-repo-verify.yml

jobs:
verify-octi:
name: Verify app-main decodes this PR's wire bytes
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- name: Checkout this repo at PR head
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
path: octi-web
# Full history so `git merge-base` finds the PR's branch point on main.
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}

- name: Detect wire-format-relevant changes
id: changed
working-directory: octi-web
shell: bash
run: |
set -euo pipefail
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
merge_base="$(git merge-base "$base" "$head")"

matches_allowlist() {
local path="$1" prefix
while IFS= read -r prefix; do
prefix="${prefix#"${prefix%%[![:space:]]*}"}"
[[ -z "$prefix" ]] && continue
if [[ "$prefix" == */ ]]; then
[[ "$path" == "$prefix"* ]] && return 0
else
[[ "$path" == "$prefix" ]] && return 0
fi
done <<< "$ALLOWLIST"
return 1
}

relevant=false
while IFS= read -r -d '' path; do
if matches_allowlist "$path"; then
echo "relevant: $path"
relevant=true
fi
done < <(git diff --name-only --no-renames -z "$merge_base" "$head" --)

echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
if [[ "$relevant" == "false" ]]; then
echo "no wire-format-relevant paths changed; consumer verify will be skipped."
fi

- name: Checkout app-main
if: steps.changed.outputs.relevant == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: d4rken-org/octi
persist-credentials: false
path: app-main

- name: Setup JDK 21
if: steps.changed.outputs.relevant == 'true'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: '21'
distribution: 'temurin'

- name: Cache Gradle wrapper
if: steps.changed.outputs.relevant == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
!~/.gradle/wrapper/dists/**/gradle*.zip
# Cache keys are namespaced per consumer repo so the two jobs don't restore each
# other's incompatible caches across runs.
key: ${{ runner.os }}-app-main-gradle-wrapper-${{ hashFiles('app-main/**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-app-main-gradle-wrapper-

- name: Cache Gradle dependencies
if: steps.changed.outputs.relevant == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
key: ${{ runner.os }}-app-main-gradle-caches-${{ hashFiles('app-main/**/*.gradle*', 'app-main/**/gradle-wrapper.properties', 'app-main/buildSrc/**/*.kt') }}
restore-keys: |
${{ runner.os }}-app-main-gradle-caches-

- name: Run app-main module tests with fixture override
if: steps.changed.outputs.relevant == 'true'
working-directory: app-main
env:
# Override app-main's pin of d4rken-org/octi-web with this PR's HEAD SHA. The
# multi-source resolver drops the committed manifest_sha256 trust anchor for this
# source; per-file sha256s in the freshly-fetched manifest stay as the anchor.
INTEROP_FIXTURE_OVERRIDES: '{"d4rken-org/octi-web":"${{ github.event.pull_request.head.sha }}"}'
run: |
echo "Running app-main module tests against this PR's octi-web HEAD (${{ github.event.pull_request.head.sha }})"
chmod +x ./gradlew
# Only the modules that consume octi-web's fixtures need to run — the full :test
# graph would build app + UI variants that don't exercise this contract and would
# waste ~10 minutes per PR.
./gradlew :modules-meta:testDebugUnitTest :modules-clipboard:testDebugUnitTest :modules-files:testDebugUnitTest

verify-desktop:
name: Verify octi-desktop decodes this PR's wire bytes
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- name: Checkout this repo at PR head
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
path: octi-web
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}

- name: Detect wire-format-relevant changes
id: changed
working-directory: octi-web
shell: bash
run: |
set -euo pipefail
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
merge_base="$(git merge-base "$base" "$head")"

matches_allowlist() {
local path="$1" prefix
while IFS= read -r prefix; do
prefix="${prefix#"${prefix%%[![:space:]]*}"}"
[[ -z "$prefix" ]] && continue
if [[ "$prefix" == */ ]]; then
[[ "$path" == "$prefix"* ]] && return 0
else
[[ "$path" == "$prefix" ]] && return 0
fi
done <<< "$ALLOWLIST"
return 1
}

relevant=false
while IFS= read -r -d '' path; do
if matches_allowlist "$path"; then
echo "relevant: $path"
relevant=true
fi
done < <(git diff --name-only --no-renames -z "$merge_base" "$head" --)

echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
if [[ "$relevant" == "false" ]]; then
echo "no wire-format-relevant paths changed; consumer verify will be skipped."
fi

- name: Checkout octi-desktop
if: steps.changed.outputs.relevant == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: d4rken-org/octi-desktop
persist-credentials: false
path: octi-desktop

- name: Setup JDK 21
if: steps.changed.outputs.relevant == 'true'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: '21'
distribution: 'temurin'

- name: Cache Gradle wrapper
if: steps.changed.outputs.relevant == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/wrapper
!~/.gradle/wrapper/dists/**/gradle*.zip
key: ${{ runner.os }}-octi-desktop-gradle-wrapper-${{ hashFiles('octi-desktop/**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-octi-desktop-gradle-wrapper-

- name: Cache Gradle dependencies
if: steps.changed.outputs.relevant == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
key: ${{ runner.os }}-octi-desktop-gradle-caches-${{ hashFiles('octi-desktop/**/*.gradle*', 'octi-desktop/**/gradle-wrapper.properties', 'octi-desktop/buildSrc/**/*.kt') }}
restore-keys: |
${{ runner.os }}-octi-desktop-gradle-caches-

- name: Run octi-desktop tests with fixture override
if: steps.changed.outputs.relevant == 'true'
working-directory: octi-desktop
env:
INTEROP_FIXTURE_OVERRIDES: '{"d4rken-org/octi-web":"${{ github.event.pull_request.head.sha }}"}'
run: |
echo "Running octi-desktop tests against this PR's octi-web HEAD (${{ github.event.pull_request.head.sha }})"
chmod +x ./gradlew
./gradlew test
23 changes: 13 additions & 10 deletions tools/generate-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
serializeMetaInfo,
type MetaInfo,
} from "../src/modules/meta";
import { octiServerConnectorId } from "../src/protocol/connector-id";
import { OFFICIAL_SERVERS } from "../src/protocol/models";

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, "..");
Expand Down Expand Up @@ -75,8 +77,14 @@ export interface GeneratedFixtures {
/* ─────────────────────── Canonical inputs ─────────────────────── */

const FAUX_DEVICE_ID = "11111111-2222-3333-4444-555555555555";
const FAUX_CONNECTOR =
"kserver-prod.kserver.octi.darken.eu-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const FAUX_ACCOUNT_ID_PROD = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const FAUX_ACCOUNT_ID_BETA = "ffffffff-1111-2222-3333-444444444444";
// Compute connector IDs via the production function so a future format change in
// `src/protocol/connector-id.ts` propagates into these fixtures (and the producer's
// published-self-check trips on regen). Hardcoding the literal would let the function
// drift while these vectors keep the old format, masking a wire-format break.
const FAUX_CONNECTOR = octiServerConnectorId(OFFICIAL_SERVERS.PROD, FAUX_ACCOUNT_ID_PROD);
const FAUX_CONNECTOR_BETA = octiServerConnectorId(OFFICIAL_SERVERS.BETA, FAUX_ACCOUNT_ID_BETA);

// Meta: 3 vectors covering full / minimal / unicode-label. These are the typed
// MetaInfo objects we'd actually publish; verify tests re-serialize and check bytes.
Expand Down Expand Up @@ -254,15 +262,10 @@ const FILES_VECTORS: Array<{ name: string; input: FileShareInfo }> = [
checksum: "0000000000000000000000000000000000000000000000000000000000000007",
sharedAt: "2026-05-01T12:00:00Z",
expiresAt: "2026-05-31T12:00:00Z",
availableOn: [
"kserver-prod.kserver.octi.darken.eu-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"kserver-beta.kserver.octi.darken.eu-ffffffff-1111-2222-3333-444444444444",
],
availableOn: [FAUX_CONNECTOR, FAUX_CONNECTOR_BETA],
connectorRefs: {
"kserver-prod.kserver.octi.darken.eu-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee":
"blob-id-prod-7777",
"kserver-beta.kserver.octi.darken.eu-ffffffff-1111-2222-3333-444444444444":
"blob-id-beta-7777",
[FAUX_CONNECTOR]: "blob-id-prod-7777",
[FAUX_CONNECTOR_BETA]: "blob-id-beta-7777",
},
},
],
Expand Down