Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
ba18689
fix(ipc): restrict interactive pipe access to client operations
Divaaaan Sep 11, 2026
b52b090
fix(proxy): apply in user session and retain verified rollback ownership
Divaaaan Sep 11, 2026
76e49e3
fix(routing): honor disabled split and permitted bypass rules
Divaaaan Sep 11, 2026
36008ae
fix(singbox): reject invalid or unsupported multihop chains
Divaaaan Sep 11, 2026
d4d482d
fix(control): validate effective topology and bound automatic recovery
Divaaaan Sep 11, 2026
94c4819
fix(control): reject multihop incompatible with the live profile
Divaaaan Sep 11, 2026
b3cecc1
fix: bound IPC requests and validate installed Windows service
Divaaaan Sep 11, 2026
9e233f8
fix: publish beta atomically after complete release delivery
Divaaaan Sep 11, 2026
a1d6088
fix: preserve protection evidence through the Rust state relay
Divaaaan Sep 11, 2026
a66d03d
fix(windows): bind engine lifetime to core and surface local setup fa…
Divaaaan Sep 11, 2026
9c7f07c
fix(desktop): preserve user intent across asynchronous UI flows
Divaaaan Sep 11, 2026
135a676
fix(desktop): classify connection failures from specific evidence
Divaaaan Sep 11, 2026
6cb6e9a
fix(desktop): show confirmed native protection and service recovery
Divaaaan Sep 11, 2026
5842ff1
fix(proxy): recover retained leases after same-user relogon
Divaaaan Sep 11, 2026
150e86d
fix(proxy): preserve later external proxy selections
Divaaaan Sep 11, 2026
5bcb7f1
fix(proxy): isolate helper locking in a protected user directory
Divaaaan Sep 11, 2026
2b26061
fix(desktop): skip CSS-hidden controls in modal focus boundaries
Divaaaan Sep 11, 2026
b538f5c
fix(desktop): keep failed TCP checks readable and selectable
Divaaaan Sep 11, 2026
08ae1ea
fix(installer): verify and release owned protection on explicit unins…
Divaaaan Sep 11, 2026
436c3b1
fix(desktop): publish preference events outside render
Divaaaan Sep 11, 2026
fbe10d8
fix(desktop): count only confirmed current TCP reachability
Divaaaan Sep 11, 2026
46bb276
feat(protection): add persistent Windows host guard
Divaaaan Sep 11, 2026
d77c914
feat(control): integrate confirmed host protection lifecycle
Divaaaan Sep 11, 2026
7aff666
fix(protection): reuse the lazy WFP module handle
Divaaaan Sep 11, 2026
d734845
fix(control): serialize protected DNS settings with guard changes
Divaaaan Sep 11, 2026
470b2cd
fix(control): fence connection acceptance against teardown cancellation
Divaaaan Sep 11, 2026
bcd926b
fix(protection): preserve explicit non-Windows engine compatibility
Divaaaan Sep 11, 2026
0eed1ca
docs: define Windows host protection acceptance gates
Divaaaan Sep 11, 2026
ea74110
fix: gate platform-specific Rust helpers for strict CI
Divaaaan Sep 11, 2026
688eec3
fix(protection): read native union pointers without integer conversion
Divaaaan Sep 11, 2026
c09d19d
fix: limit wire test receiver import to test builds
Divaaaan Sep 11, 2026
c7f576d
fix: specify cancellation test channel payload type
Divaaaan Sep 11, 2026
f4c7e1c
fix(control): publish connecting before launching fallback
Divaaaan Sep 11, 2026
3f322c9
test(control): wait for real replacement connections in lifecycle fix…
Divaaaan Sep 11, 2026
ab157d1
test(control): align protection wire and reapply fixtures across hosts
Divaaaan Sep 11, 2026
76df0ef
test(control): verify proxy restoration and inert TUN watch with fakes
Divaaaan Sep 11, 2026
795b2d0
fix(desktop): place service probe before test module
Divaaaan Sep 11, 2026
cf894a0
fix(windows): admit fully elevated installer peers
Divaaaan Sep 11, 2026
ab0c0e9
ci(macos): retain bounded Go race diagnostics
Divaaaan Sep 11, 2026
8a38bda
fix(windows): allow interactive service image authentication
Divaaaan Sep 11, 2026
a6ae7d6
docs: align Windows pipe authentication contract
Divaaaan Sep 11, 2026
e24a5c1
ci(android): skip obsolete SDK tools download
Divaaaan Sep 11, 2026
d6ad83a
fix(windows): accept native token restriction BOOLEAN result
Divaaaan Sep 11, 2026
c69f7df
feat(desktop): refine simple connection view and subscription onboarding
Divaaaan Sep 11, 2026
3b4dacb
feat(desktop): redesign the main connection and server interface
Divaaaan Sep 11, 2026
4d6ebed
feat(desktop): unify settings and support panels
Divaaaan Sep 11, 2026
fbd5ccf
test(desktop): align modal and layout regression checks
Divaaaan Sep 11, 2026
41db666
test(control): isolate host proxy from fake-engine fixtures
Divaaaan Sep 12, 2026
897f7b4
test(control): preserve default host-protection fixture state
Divaaaan Sep 12, 2026
1c8e413
chore(release): synchronize desktop version 0.6.0 including npm lockfile
Divaaaan Sep 12, 2026
c83cd35
fix(installer): load service types in fresh Windows PowerShell
Divaaaan Sep 12, 2026
b0a762b
fix(release): hold verified drafts for native acceptance
Divaaaan Sep 12, 2026
c835a1e
ci(android): allow an explicit desktop-only release hold
Divaaaan Sep 12, 2026
5bdb676
fix(release): restrict Android delivery to asset uploads
Divaaaan Sep 12, 2026
4eb40dc
feat(release): prepare signed desktop candidates and promote accepted…
Divaaaan Sep 12, 2026
6531cee
fix(release): prevent stale promotion from rolling Latest backward
Divaaaan Sep 12, 2026
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
76 changes: 19 additions & 57 deletions .github/scripts/publish-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@
// release a draft and fails the run. A draft is one click away from being
// published by hand, which is the recoverable direction to fail in.
//
// node .github/scripts/publish-release.mjs <tag>
// node .github/scripts/publish-release.mjs <tag> [--prepare-only]
// prepare-only runs the same completeness and signature gates and stages the
// stable legacy manifest, leaving publication and both live channels untouched.
//
// Authenticates through gh via GITHUB_TOKEN and reads the repository from
// GITHUB_REPOSITORY. The Android APK is deliberately not in the expected set:
// it is built by a separate workflow (.github/workflows/android.yml) on its own
// schedule, and that workflow answers for itself when it cannot produce one.

import { execFileSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";

function escapeRegExp(s) {
Expand Down Expand Up @@ -80,70 +81,31 @@ export function missingAssets(expected, attached) {
);
}

/** The release for `tag`, looked up in a way that also finds it while a draft. */
function fetchRelease(repo, tag) {
// gh falls back to a list-and-match when the by-tag endpoint 404s, which is
// what it does for a draft: GitHub only exposes drafts by id.
const out = execFileSync(
"gh",
["release", "view", tag, "--repo", repo, "--json", "isDraft,isPrerelease,assets"],
{ encoding: "utf8" },
);
return JSON.parse(out);
export function parsePublishArgs(args) {
const [tag, mode] = args;
if (!tag?.startsWith('v') || args.length > 2 || (mode !== undefined && mode !== '--prepare-only')) {
throw new Error('usage: publish-release.mjs <tag> [--prepare-only]');
}
return { tag, prepareOnly: mode === '--prepare-only' };
}

function main() {
const [tag] = process.argv.slice(2);
if (!tag) {
console.error("usage: node .github/scripts/publish-release.mjs <tag>");
process.exit(1);
}
async function main() {
const { tag, prepareOnly } = parsePublishArgs(process.argv.slice(2));
const repo = process.env.GITHUB_REPOSITORY;
if (!repo) {
console.error("publish-release: GITHUB_REPOSITORY is not set");
process.exit(1);
}

const version = tag.replace(/^v/, "");
// Same rule the build jobs resolve the channel with: a SemVer prerelease
// suffix marks the release prerelease.
const prerelease = tag.includes("-");

const release = fetchRelease(repo, tag);
const attached = release.assets.map((a) => a.name).sort();
const missing = missingAssets(expectedAssets({ version, prerelease }), attached);

if (missing.length > 0) {
console.error(
`publish-release: ${tag} is missing ${missing.length} expected asset(s); leaving it a draft`,
);
for (const asset of missing) {
console.error(` missing: ${asset.label} (${asset.want})`);
}
console.error(` attached: ${attached.join(", ") || "(nothing)"}`);
process.exit(1);
}

if (!release.isDraft) {
// A re-run of a release that already went out: the set is complete, so
// there is nothing to publish and nothing to complain about.
console.log(
`publish-release: ${tag} is already published, with all ${attached.length} expected assets`,
);
if (!tag || !repo) throw new Error('usage: GITHUB_REPOSITORY=owner/repo node .github/scripts/publish-release.mjs <tag>');
const { publishCompleteRelease } = await import('../../scripts/release-lifecycle.mjs');
const { githubReleaseApi } = await import('../../scripts/release-api.mjs');
const result = await publishCompleteRelease({ tag, repo, api: githubReleaseApi(repo, tag), prepareOnly });
if (result.prepared) {
console.log(`publish-release: ${tag} verified and held as a draft; native acceptance required before publication`);
return;
}

execFileSync("gh", ["release", "edit", tag, "--repo", repo, "--draft=false"], {
stdio: "inherit",
});
console.log(
`publish-release: ${tag} published with ${attached.length} assets: ${attached.join(", ")}`,
);
console.log(`publish-release: ${tag} complete and public; beta ${result.switched ? 'updated atomically' : 'already at this or a newer version'}`);
}

// Run only when invoked as a script, so the pure helpers can be unit-tested.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
main().catch(error => { console.error(error.message); process.exitCode = 1; });
}

// Referenced by the test runner without triggering main().
Expand Down
10 changes: 9 additions & 1 deletion .github/scripts/publish-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { expectedAssets, missingAssets } from "./publish-release.mjs";
import { expectedAssets, missingAssets, parsePublishArgs } from "./publish-release.mjs";

test('publication arguments require the explicit prepare-only flag and reject typos', () => {
assert.deepEqual(parsePublishArgs(['v0.6.0']), { tag: 'v0.6.0', prepareOnly: false });
assert.deepEqual(parsePublishArgs(['v0.6.0', '--prepare-only']), { tag: 'v0.6.0', prepareOnly: true });
for (const args of [[], ['--prepare-only'], ['v0.6.0', '--prepare'], ['v0.6.0', '--prepare-only', '--extra']]) {
assert.throws(() => parsePublishArgs(args), /usage:/);
}
});

/** The eleven files v0.5.0 actually shipped with — the Arch package missing. */
const v050Assets = [
Expand Down
58 changes: 58 additions & 0 deletions .github/scripts/workflows.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,33 @@ function stepNamed(text, name) {
return found[0];
}

test("only the final release job can publish either updater channel", () => {
const releaseJobs = jobs(workflow("release.yml"));
const publishers = [...releaseJobs].filter(([, body]) => steps(body).some(s => /\bnode\s+\.github\/scripts\/publish-release\.mjs\b/.test(s)));
assert.deepEqual(publishers.map(([name]) => name), ["publish"]);
const required = /needs:\s*\[([^\]]+)\]/.exec(releaseJobs.get("publish"))?.[1].split(',').map(s => s.trim());
assert.deepEqual(new Set(required), new Set(['windows', 'macos', 'linux', 'arch-package']));
for (const [name, body] of releaseJobs) {
if (name === 'publish') continue;
for (const step of steps(body)) assert.doesNotMatch(step, /\bnode\s+(?:\.github\/)?scripts\/publish-(?:beta-manifest|release)\.mjs\b/, name);
}
});

test("every Go setup resolves one exact committed patch", () => {
const expected = readFileSync(new URL('../../.go-version', import.meta.url), 'utf8').trim();
assert.match(expected, /^\d+\.\d+\.\d+$/);
let checked = 0;
for (const { name, text } of allWorkflows()) {
for (const step of steps(text).filter(s => /uses: actions\/setup-go@/.test(s))) {
const file = /go-version-file:\s*['"]?([^'"\s]+)/.exec(step)?.[1];
assert.equal(file, '.go-version', name);
assert.doesNotMatch(step, /go-version:/, name);
checked++;
}
}
assert.ok(checked >= 3);
});

test("the Arch attach step names the repository instead of asking git", () => {
// The build step chowns the checkout to `builder` so makepkg can run, and this
// step runs as root: gh's own repository resolution shells out to git, git
Expand Down Expand Up @@ -136,6 +163,37 @@ test("no tauri job publishes the release before the assets are complete", () =>
}
});

test('Android can only upload its signed APK to an existing release', () => {
const attach = stepNamed(workflow('android.yml'), 'Attach the APK to the GitHub release');
assert.match(attach, /APK_PATH: \$\{\{ steps\.sign\.outputs\.apk \}\}/);
assert.match(attach, /node scripts\/attach-android-release\.mjs "\$GITHUB_REF_NAME" "\$APK_PATH"/);
assert.match(attach, /timeout-minutes: 33/);
assert.doesNotMatch(attach, /softprops|draft:|releaseDraft:|prerelease:|release (?:create|edit)/);
});

test('release hold runs the same final gate in explicit prepare-only mode', () => {
const publish = jobs(workflow('release.yml')).get('publish');
assert.match(publish, /TENEBRA_RELEASE_HOLD: \$\{\{ vars\.TENEBRA_RELEASE_HOLD \}\}/);
assert.match(publish, /if \[ "\$TENEBRA_RELEASE_HOLD" = "true" \]; then/);
assert.match(publish, /node \.github\/scripts\/publish-release\.mjs "\$GITHUB_REF_NAME" --prepare-only/);
assert.match(publish, /else\s+node \.github\/scripts\/publish-release\.mjs "\$GITHUB_REF_NAME"\s+fi/);
assert.doesNotMatch(publish, /^ {4}if:/m, 'hold must not skip asset verification');
});

test('explicit Android release hold stops both tag jobs and reports the missing APK without affecting debug', () => {
const android = jobs(workflow('android.yml'));
for (const job of ['release-gate', 'release']) {
assert.match(android.get(job), /if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/'\) && vars\.TENEBRA_ANDROID_RELEASE_HOLD != 'true' \}\}/);
}
const held = android.get('release-held');
assert.ok(held, 'an intentionally held release must be visible in the run');
assert.match(held, /if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/'\) && vars\.TENEBRA_ANDROID_RELEASE_HOLD == 'true' \}\}/);
assert.match(held, /GITHUB_STEP_SUMMARY/);
assert.match(held, /No APK was built, signed, or attached/);
assert.doesNotMatch(held, /secrets\.|contents: write|uses:|release upload|publish-release/);
assert.doesNotMatch(android.get('debug'), /TENEBRA_ANDROID_RELEASE_HOLD/);
});

test("a final job publishes the draft only after every build job", () => {
const release = jobs(workflow("release.yml"));
const publish = release.get("publish");
Expand Down
45 changes: 37 additions & 8 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ concurrency:
permissions:
contents: read

env:
GOTOOLCHAIN: local

jobs:
debug:
# Every Android-relevant push/PR (path-filtered above). Tag pushes skip this
Expand All @@ -48,14 +51,18 @@ jobs:
with:
# >= the sing-box v1.13.14 floor (go 1.24.7) and the same version the
# rest of CI pins, so every job builds the Go core with one toolchain.
go-version: '1.26'
go-version-file: '.go-version'
cache: false
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
# The SDK + build-tools Gradle needs to assemble the APK.
- uses: android-actions/setup-android@v4
with:
# Gradle installs the required platform/build-tools. Avoid the obsolete
# SDK Tools archive pulled in by the action's default "tools" package.
packages: platform-tools
# The NDK gomobile needs to bind BOTH .aars. Pinned to r28 (what the
# sing-box porting notes target); the generic SDK setup does not pin an NDK.
- uses: nttld/setup-ndk@v1
Expand Down Expand Up @@ -117,6 +124,22 @@ jobs:
path: ui-android/app/build/outputs/apk/debug/*.apk
if-no-files-found: error

release-held:
# A desktop-only release may deliberately omit Android. The explicit hold
# does not apply to debug builds and is reported instead of a fake APK pass.
if: ${{ startsWith(github.ref, 'refs/tags/') && vars.TENEBRA_ANDROID_RELEASE_HOLD == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Report the Android release hold
run: |
set -eu
echo "::notice::Android release is intentionally held for $GITHUB_REF_NAME."
{
echo "## Android release intentionally held: $GITHUB_REF_NAME"
echo "TENEBRA_ANDROID_RELEASE_HOLD=true. No APK was built, signed, or attached."
echo "Desktop release validation continues separately; Android debug builds are unaffected."
} >> "$GITHUB_STEP_SUMMARY"

release-gate:
# A v* tag drives the desktop release too, and Android can only join it if
# the release signing secrets exist. This job used to resolve them into a
Expand All @@ -127,7 +150,7 @@ jobs:
# signed is a failed Android release, so say so out loud. This is its own
# workflow run — a red job here does not touch the desktop release — and the
# day the secrets are added it arms itself.
if: ${{ startsWith(github.ref, 'refs/tags/') }}
if: ${{ startsWith(github.ref, 'refs/tags/') && vars.TENEBRA_ANDROID_RELEASE_HOLD != 'true' }}
runs-on: ubuntu-latest
steps:
- name: Require the release signing key
Expand All @@ -153,21 +176,26 @@ jobs:
# release. The desktop release.yml publishes to this same tag's release; this
# job only adds the APK asset and never rewrites the release body.
needs: release-gate
if: ${{ startsWith(github.ref, 'refs/tags/') }}
if: ${{ startsWith(github.ref, 'refs/tags/') && vars.TENEBRA_ANDROID_RELEASE_HOLD != 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: actions/setup-go@v6
with:
go-version: '1.26'
go-version-file: '.go-version'
cache: false
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v4
with:
packages: platform-tools
- uses: nttld/setup-ndk@v1
id: ndk
with:
Expand Down Expand Up @@ -233,9 +261,10 @@ jobs:
"${build_tools}apksigner" verify --verbose "$signed"
echo "apk=$signed" >> "$GITHUB_OUTPUT"
- name: Attach the APK to the GitHub release
uses: softprops/action-gh-release@v2
with:
files: ${{ steps.sign.outputs.apk }}
fail_on_unmatched_files: true
# Wait at most 30 minutes for desktop to create the release, then only
# upload an asset. No metadata update can race desktop publication.
timeout-minutes: 33
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APK_PATH: ${{ steps.sign.outputs.apk }}
run: node scripts/attach-android-release.mjs "$GITHUB_REF_NAME" "$APK_PATH"
38 changes: 31 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
workflow_call:

env:
GOTOOLCHAIN: local

jobs:
workflows:
# The pipeline's own checks. Every other job here tests the product; this one
Expand All @@ -24,14 +27,14 @@ jobs:
# Quoted so node expands the pattern itself: its test-file discovery
# walks past directories whose name begins with a dot, so handing it
# .github/scripts as a directory finds nothing.
run: node --test ".github/scripts/*.test.mjs"
run: node --test ".github/scripts/*.test.mjs" "scripts/*.test.mjs"
core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.26'
go-version-file: '.go-version'
cache: false
- run: go vet ./...
# gofmt reports rather than rewrites here, and the diff is printed: a
Expand Down Expand Up @@ -80,14 +83,16 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.26'
go-version-file: '.go-version'
cache: false
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Check uninstall trust policy without native side effects
run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/test-uninstall-policy.ps1
- name: Check Rust formatting
working-directory: ui-desktop/src-tauri
run: cargo fmt --check
Expand Down Expand Up @@ -133,7 +138,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.26'
go-version-file: '.go-version'
cache: false
- uses: actions/setup-node@v6
with:
Expand Down Expand Up @@ -212,15 +217,34 @@ jobs:
# files are gated on `//go:build darwin`, so this is the only job that
# exercises them. The hosted macOS runners are all arm64.
runs-on: macos-14
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.26'
go-version-file: '.go-version'
cache: false
- run: go vet ./...
- run: go build ./...
- run: go test ./... -race -count=1
- name: Run Go race tests with retained diagnostics
id: race
timeout-minutes: 12
shell: bash
# A package timeout emits goroutine stacks; JSON streams the last active
# test even if the outer step limit is reached during build or execution.
# pipefail keeps a failing test red when tee successfully saves its log.
run: |
set -o pipefail
go test ./... -race -count=1 -timeout=4m -json 2>&1 | tee "$RUNNER_TEMP/tenebra-macos-go-race.log"
- name: Retain macOS Go test progress and failure stacks
if: ${{ always() && steps.race.outcome != 'skipped' }}
timeout-minutes: 2
uses: actions/upload-artifact@v6
with:
name: tenebra-macos-go-race-log
path: ${{ runner.temp }}/tenebra-macos-go-race.log
retention-days: 7
if-no-files-found: error
# TODO(macos): add the Tauri universal-DMG bundle build here once the
# darwin resource fetch (scripts/fetch-resources.sh: sing-box darwin +
# lipo) and the externalBin/notarization caveat are resolved. See
Expand All @@ -241,7 +265,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.26'
go-version-file: '.go-version'
cache: false
- run: go vet ./...
- run: go build ./...
Expand Down
Loading
Loading