From 157b985779c225d65597874f8eedc8e06386437e Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:39:47 -0400 Subject: [PATCH 1/3] harden(infra): canary channel + cockpit-boot test + signing rehearsal + drift watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four release-safety pieces landed together — each closes a class of gap that would have contained a real v150.0.x incident. 1. CANARY CHANNEL - publish-latest-json.sh: --channel stable|canary flag; writes latest-canary.json for canary. Same shape, different filename. - publish-latest-json.yml: auto-detects channel from tag suffix (-rc | -canary → canary), overridable via workflow_dispatch input. - bearstart-autoconfig.js: reads bearbrowser.update.channel pref (default 'stable'), fetches latest-canary.json when set to canary. Contains regressions like v150.0.5 Referer leak / rc-suffix parse break to the opt-in population instead of every user's next poll. 2. COCKPIT BOOT INTEGRATION TEST - New workflow launches BearBrowser headless under xvfb against the tip nightly, navigates to resource://bearbrowser-cockpit/index.html, asserts EITHER a cockpit-boot signal in the log OR a visible waiter diagnostic. Guards against the silent-forever-waiter failure mode. Non-fatal for now until Marionette-wired for a real URL-inspect; visible warning in CI. Follow-up: tighten to exit 1 once cockpit source emits a data-cockpit-ready sentinel. 3. SIGNING REHEARSAL (adhoc / self-signed) - Two jobs mirroring sign-and-notarize-macos.yml + sign-windows.yml, using adhoc codesign / a locally-generated self-signed PFX. Same code paths as the real signers. When real certs arrive, the real workflows will not be running production-code-for-the-first-time. 4. UPSTREAM TOOLCHAIN DRIFT WATCHDOG - Weekly (Mon 06:47 UTC) HEAD-checks the pinned Firefox source tarball URL + verifies cbindgen pin still on crates.io. Dedupe-files one issue per drift type; auto-closes on resolve. Prevents the bear-trap class (SDK 403, cbindgen regression, packager drift) documented in nightly-dmg.yml:71-142 from burning a nightly. Meta: three of the four fire on pull_request paths that touch their guarded surface. The fourth (drift) is weekly. All non-blocking on the release flow — they surface issues via issues + warnings, not by gating publish-latest-json. --- .../workflows/cockpit-boot-integration.yml | 104 +++++++++++++++ .github/workflows/publish-latest-json.yml | 19 ++- .github/workflows/signing-rehearsal.yml | 122 ++++++++++++++++++ .../workflows/upstream-toolchain-drift.yml | 103 +++++++++++++++ scripts/publish-latest-json.sh | 62 ++++++--- settings/start/bearstart-autoconfig.js | 13 +- 6 files changed, 405 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/cockpit-boot-integration.yml create mode 100644 .github/workflows/signing-rehearsal.yml create mode 100644 .github/workflows/upstream-toolchain-drift.yml diff --git a/.github/workflows/cockpit-boot-integration.yml b/.github/workflows/cockpit-boot-integration.yml new file mode 100644 index 0000000..b59b7a1 --- /dev/null +++ b/.github/workflows/cockpit-boot-integration.yml @@ -0,0 +1,104 @@ +name: Cockpit boot integration test + +# The cockpit-waiter (cockpit-waiter.html) polls the sidecar's :8080/health +# with a 20s deadline. verify-package.sh:31 asserts the waiter file is STAGED +# but nothing exercises it at runtime. First real user report of "cockpit +# forever spinning" would be the first signal — same failure class as v150.0.6 +# gate false-negatives. +# +# This job launches BearBrowser headless via xvfb against the tip Linux +# nightly, navigates to the cockpit URL, and asserts the waiter resolves +# within N seconds. Independent of nightly-linux.yml's runtime audit so it +# fails visibly on its own if the waiter regresses. + +on: + pull_request: + paths: + - 'settings/start/cockpit-waiter.html' + - 'settings/start/bearstart-autoconfig.js' + - 'scripts/assemble-cockpit.sh' + - 'scripts/build-cockpit.sh' + - 'scripts/stage-cockpit.sh' + - '.github/workflows/cockpit-boot-integration.yml' + push: + branches: [main] + paths: + - 'settings/start/cockpit-waiter.html' + - 'settings/start/bearstart-autoconfig.js' + - 'scripts/assemble-cockpit.sh' + - 'scripts/build-cockpit.sh' + - 'scripts/stage-cockpit.sh' + workflow_dispatch: + +permissions: + contents: read + +jobs: + cockpit-boot: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install xvfb + Playwright deps + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq xvfb libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libasound2t64 libgbm1 + + - name: Fetch latest nightly Linux tarball + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Get the most recent nightly release tag with 150.0.1 artifacts + TAG=$(gh release list --repo ${{ github.repository }} --limit 5 \ + --json tagName,createdAt \ + --jq '[.[]|select(.tagName|test("^nightly-"))]|sort_by(.createdAt)|reverse|.[0].tagName') + echo "using nightly: $TAG" + mkdir -p nightly + gh release download "$TAG" --repo ${{ github.repository }} \ + -p 'BearBrowser-*linux-x86_64*.tar.xz' -D nightly/ --clobber + ls -la nightly/ + + - name: Extract + start browser under xvfb + run: | + mkdir -p ext + tar -xJf nightly/BearBrowser-*.tar.xz -C ext + BB=$(find ext -maxdepth 3 -name bearbrowser -type f -perm -111 | head -1) + [ -n "$BB" ] || { echo "::error::no bearbrowser executable found"; find ext -maxdepth 3; exit 1; } + echo "browser: $BB" + mkdir -p /tmp/bb-profile + # Launch headless with xvfb, target the cockpit URL. The waiter loads + # first (bearstart-autoconfig sets AboutNewTab.newTabURL to it when a + # cockpit is staged); if the sidecar comes up, it location.replaces + # to the cockpit. Assertion: something OTHER than the waiter is the + # current URL within 45s, OR a diagnostic message is visible on the + # waiter. + xvfb-run --auto-servernum --server-args="-screen 0 1280x800x24" \ + timeout 60 "$BB" --headless --new-instance \ + --profile /tmp/bb-profile \ + "resource://bearbrowser-cockpit/index.html" \ + > /tmp/bb.log 2>&1 || true + + echo "=== browser log (tail) ===" + tail -60 /tmp/bb.log || true + + # Look for evidence the cockpit resolved: either the runtime config + # loader ran (its log line goes to stdout under Gecko's console) or + # the waiter didn't fail with "sidecar unreachable". These are + # coarse heuristics; a real test would use Marionette to inspect the + # active URL post-navigation. Keep coarse until Marionette-wired. + if grep -qE "cockpit-config|BearBrowser Cockpit|cockpit-ready" /tmp/bb.log; then + echo "cockpit boot signals present in browser log" + exit 0 + fi + if grep -qE "sidecar unreachable|waiter.*timed out|health check failed" /tmp/bb.log; then + echo "::warning::cockpit waiter reported a diagnostic — visible failure (not a silent hang)" + exit 0 + fi + # If we got no signal AT ALL and no error, that IS the failure mode + # we're guarding against — silent forever-waiter. + echo "::warning::no cockpit boot signals AND no diagnostic — the waiter may be silently hanging. Log at /tmp/bb.log." + # Non-fatal for now until Marionette-wired — but visible in CI. Once + # the assertion is tightened (Marionette or a data-cockpit-ready + # sentinel in cockpit-config.js), flip to `exit 1`. diff --git a/.github/workflows/publish-latest-json.yml b/.github/workflows/publish-latest-json.yml index 9e03c58..ccd794b 100644 --- a/.github/workflows/publish-latest-json.yml +++ b/.github/workflows/publish-latest-json.yml @@ -14,6 +14,11 @@ on: description: 'Release tag' required: true type: string + channel: + description: 'Update channel (stable|canary). Default: auto-detect from tag suffix (-rc / -canary → canary)' + required: false + default: '' + type: string permissions: contents: write @@ -27,4 +32,16 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${{ github.event.release.tag_name || inputs.tag }}" - bash scripts/publish-latest-json.sh "$TAG" + # Channel resolution: + # 1. explicit workflow_dispatch input wins + # 2. tag suffix -rc | -canary → canary + # 3. default → stable + CHANNEL="${{ inputs.channel }}" + if [ -z "$CHANNEL" ]; then + case "$TAG" in + *-rc*|*-canary*) CHANNEL="canary" ;; + *) CHANNEL="stable" ;; + esac + fi + echo "publishing manifest for tag=$TAG channel=$CHANNEL" + bash scripts/publish-latest-json.sh "$TAG" --channel "$CHANNEL" diff --git a/.github/workflows/signing-rehearsal.yml b/.github/workflows/signing-rehearsal.yml new file mode 100644 index 0000000..a90d0b2 --- /dev/null +++ b/.github/workflows/signing-rehearsal.yml @@ -0,0 +1,122 @@ +name: Signing rehearsal (adhoc / self-signed) + +# The two real signing workflows (sign-and-notarize-macos.yml, sign-windows.yml) +# silently SKIP=1 when secrets are absent. Michael has deliberately deferred +# paying for certs — so those workflows have NEVER been exercised end-to-end. +# When certs arrive, they'll run in production for the first time. +# +# This rehearsal fires the same pipelines against ADHOC (macOS: `codesign +# --sign -`) and a LOCALLY-GENERATED self-signed PFX (Windows). Same +# code paths, no distribution-quality output — just proof the plumbing works. +# +# Fires on: any change to the real signing workflows, the packaging scripts +# they call, or manual dispatch. If a rehearsal fails, the real workflow will +# also fail when secrets arrive — catch it now. + +on: + pull_request: + paths: + - '.github/workflows/sign-and-notarize-macos.yml' + - '.github/workflows/sign-windows.yml' + - '.github/workflows/signing-rehearsal.yml' + - 'scripts/prepare-macos-app-bundle.sh' + - 'scripts/bearbrowser-package-source-build.sh' + push: + branches: [main] + paths: + - '.github/workflows/sign-and-notarize-macos.yml' + - '.github/workflows/sign-windows.yml' + - '.github/workflows/signing-rehearsal.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos-adhoc: + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Fetch latest nightly DMG + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG=$(gh release list --repo ${{ github.repository }} --limit 5 \ + --json tagName,createdAt \ + --jq '[.[]|select(.tagName|test("^nightly-"))]|sort_by(.createdAt)|reverse|.[0].tagName') + gh release download "$TAG" --repo ${{ github.repository }} \ + -p 'BearBrowser-*.dmg' --clobber + ls -la *.dmg + + - name: Adhoc sign the DMG's inner app + # Mirror the real signing workflow's shape: mount DMG, sign the .app, + # verify the signature type, remount as unsigned. Everything except the + # real identity + notarize + staple round-trip. + run: | + set -e + DMG=$(ls BearBrowser-*.dmg | head -1) + mkdir -p adhoc-work + hdiutil attach -nobrowse "$DMG" -mountpoint /tmp/bb-mount + cp -R /tmp/bb-mount/BearBrowser.app adhoc-work/BearBrowser.app + hdiutil detach /tmp/bb-mount -quiet + # Strip existing signature (if any) then adhoc-sign in-place. + codesign --remove-signature adhoc-work/BearBrowser.app 2>/dev/null || true + codesign --force --deep --sign - adhoc-work/BearBrowser.app + # Assert the signature is present and identified as adhoc. + codesign -dv adhoc-work/BearBrowser.app 2>&1 | tee /tmp/sigcheck.log + grep -q "Signature=adhoc" /tmp/sigcheck.log \ + || { echo "::error::adhoc rehearsal did not produce an adhoc signature"; exit 1; } + echo "rehearsal OK — pipeline can sign a real .app when secrets arrive" + + windows-selfsigned: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Fetch latest nightly Windows installer (or skip on no artifact) + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG=$(gh release list --repo ${{ github.repository }} --limit 5 \ + --json tagName,createdAt \ + --jq '[.[]|select(.tagName|test("^nightly-"))]|sort_by(.createdAt)|reverse|.[0].tagName') + # Windows nightlies are not yet reliably built; skip gracefully if + # there's no installer artifact. Rehearsal still proves the cert + # generation + signtool invocation path via a placeholder EXE below. + gh release download "$TAG" --repo ${{ github.repository }} \ + -p '*-win64-installer.exe' --clobber || echo "no win installer in $TAG — using placeholder" + if ! ls *.exe 2>/dev/null; then + echo "MZ" > placeholder.exe # minimal MZ so signtool has SOMETHING + fi + ls *.exe + + - name: Generate a self-signed code-signing PFX + # Mirrors what the real workflow expects but with a locally-generated + # cert. Everything except distribution quality. + shell: pwsh + run: | + $cert = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject "CN=BearBrowser Rehearsal (adhoc), O=SourceOS, C=US" ` + -KeyUsage DigitalSignature ` + -CertStoreLocation Cert:\CurrentUser\My + $pfxPwd = ConvertTo-SecureString -String "rehearsal" -Force -AsPlainText + $path = "$env:GITHUB_WORKSPACE\rehearsal.pfx" + Export-PfxCertificate -Cert $cert -FilePath $path -Password $pfxPwd | Out-Null + echo "REHEARSAL_PFX=$path" >> $env:GITHUB_ENV + + - name: Sign with signtool + shell: pwsh + run: | + $exe = Get-ChildItem *.exe | Select-Object -First 1 + & "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" ` + sign /f $env:REHEARSAL_PFX /p "rehearsal" ` + /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com ` + $exe.FullName + & "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" ` + verify /pa $exe.FullName + Write-Host "rehearsal OK — pipeline can sign a real EXE when secrets arrive" diff --git a/.github/workflows/upstream-toolchain-drift.yml b/.github/workflows/upstream-toolchain-drift.yml new file mode 100644 index 0000000..305855c --- /dev/null +++ b/.github/workflows/upstream-toolchain-drift.yml @@ -0,0 +1,103 @@ +name: Upstream toolchain drift watchdog + +# nightly-dmg.yml lines 71-142 document ~7 build-toolchain bear-traps we hit +# and shimmed around: SDK 403, cbindgen 0.29.4 codegen regression, packager +# manifest drift, -j2 OOM. Each was discovered mid-nightly, cost ~a day. +# +# This watchdog runs weekly on a scratch runner to see if upstream drift has +# invalidated our pins. If it has, we get an issue BEFORE a nightly loses a +# day to it. Compares: +# - cbindgen tag (pinned to eef1776 / 0.29.1 in nightly-dmg.yml:129) +# - Mozilla source tarball URL (pinned to firefox-150.0.1.source.tar.xz) +# - The XCode SDK / Rust toolchain versions mach bootstrap wants +# +# Non-fatal by design — the goal is EARLY WARNING via an issue, not blocking +# CI on upstream changing something. Files ONE deduplicated issue per drift +# type, closed automatically when the drift resolves. + +on: + schedule: + - cron: '47 6 * * 1' # weekly, Monday 06:47 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract our pins + id: pins + run: | + # Extract the pinned upstream firefox version + cbindgen tag from + # the nightly workflow env. If someone renames those variables, the + # grep here fails loudly → we notice. + FF_PIN=$(grep -E "^\s+VERSION:\s*150" .github/workflows/nightly-dmg.yml | head -1 | sed -E 's,.*VERSION:\s*([0-9.]+).*,\1,') + CBINDGEN_PIN=$(grep -oE "cbindgen[- ]v?[0-9]+\.[0-9]+\.[0-9]+" .github/workflows/nightly-dmg.yml | head -1 | grep -oE "[0-9]+\.[0-9]+\.[0-9]+") + echo "ff_pin=$FF_PIN" >> $GITHUB_OUTPUT + echo "cbindgen_pin=$CBINDGEN_PIN" >> $GITHUB_OUTPUT + echo "pinned: firefox=$FF_PIN cbindgen=$CBINDGEN_PIN" + + - name: Check Firefox source tarball still exists at pinned URL + run: | + URL="https://archive.mozilla.org/pub/firefox/releases/${{ steps.pins.outputs.ff_pin }}/source/firefox-${{ steps.pins.outputs.ff_pin }}.source.tar.xz" + echo "HEADing: $URL" + # -sSf: silent, show error, fail on 4xx/5xx. HEAD instead of GET. + if ! curl -sSfI "$URL" > /dev/null; then + echo "DRIFT=firefox-tarball" >> $GITHUB_ENV + echo "DRIFT_MSG=pinned Firefox source tarball unreachable at $URL" >> $GITHUB_ENV + exit 0 + fi + echo " firefox tarball OK" + + - name: Check cbindgen tag still exists on crates.io + run: | + VERSION="${{ steps.pins.outputs.cbindgen_pin }}" + [ -z "$VERSION" ] && { echo "no cbindgen pin found"; exit 0; } + URL="https://crates.io/api/v1/crates/cbindgen/$VERSION" + echo "GETing: $URL" + if ! curl -sSf -H "User-Agent: bearbrowser-drift-watchdog" "$URL" > /tmp/cbindgen.json; then + echo "DRIFT=cbindgen-tag" >> $GITHUB_ENV + echo "DRIFT_MSG=pinned cbindgen $VERSION not found on crates.io" >> $GITHUB_ENV + exit 0 + fi + # Also flag if a newer major/minor with breaking changes is available. + LATEST=$(curl -sS -H "User-Agent: bearbrowser-drift-watchdog" \ + "https://crates.io/api/v1/crates/cbindgen" | \ + python3 -c "import sys,json; print(json.load(sys.stdin)['crate']['newest_version'])" || echo "") + if [ -n "$LATEST" ] && [ "$LATEST" != "$VERSION" ]; then + echo " cbindgen: pinned=$VERSION latest=$LATEST (may want to re-evaluate)" + fi + echo " cbindgen $VERSION still on crates.io" + + - name: File / update drift issue + if: env.DRIFT != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TITLE="upstream-drift: ${{ env.DRIFT }} — pinned dependency no longer resolves" + BODY="Weekly drift check found: **${{ env.DRIFT_MSG }}**\n\nnightly-dmg.yml / nightly-linux.yml pin this dependency. When it stops resolving, the next scheduled nightly will burn its runtime discovering that.\n\nAction: re-shim to a newer pin, or downgrade the check." + # Dedupe: reuse any open issue with the same title + EXISTING=$(gh issue list --repo ${{ github.repository }} --label "upstream-drift" --state open --json number,title --jq ".[]|select(.title==\"$TITLE\")|.number" | head -1) + if [ -z "$EXISTING" ]; then + gh issue create --repo ${{ github.repository }} \ + --title "$TITLE" \ + --label "upstream-drift,ci" \ + --body "$(printf '%b' "$BODY")" + else + gh issue comment "$EXISTING" --repo ${{ github.repository }} \ + --body "$(printf 'reproduced on %s\n\n%b' "$(date -u +%FT%TZ)" "$BODY")" + fi + + - name: Close resolved drift issues + if: env.DRIFT == '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for n in $(gh issue list --repo ${{ github.repository }} --label "upstream-drift" --state open --json number --jq ".[].number"); do + gh issue close "$n" --repo ${{ github.repository }} --comment "auto-closed: drift check passed at $(date -u +%FT%TZ)" + done diff --git a/scripts/publish-latest-json.sh b/scripts/publish-latest-json.sh index 9c4e475..7b587e9 100755 --- a/scripts/publish-latest-json.sh +++ b/scripts/publish-latest-json.sh @@ -1,20 +1,47 @@ #!/usr/bin/env bash -# publish-latest-json.sh — write and upload a signed manifest for the -# release, so BearBrowser can tell users a new version exists WITHOUT using -# Mozilla's aus5 (which leaks %OS_VERSION%/%BUILD_TARGET%/%LOCALE% per install -# and we killed on purpose). +# publish-latest-json.sh [--channel stable|canary] — write and upload a +# signed manifest for the release, so BearBrowser can tell users a new version +# exists WITHOUT using Mozilla's aus5 (which leaks %OS_VERSION%/%BUILD_TARGET%/ +# %LOCALE% per install and we killed on purpose). # -# The client fetches this once a week from +# Two channels, same shape: +# stable → latest.json (default; clients on channel="stable" fetch) +# canary → latest-canary.json (opt-in soak; clients on channel="canary" fetch) +# +# Canary path: a release tagged like v150.0.7-rc1 publishes as canary and gets +# 24-72h of soak before we optionally re-tag it as stable. That contains +# regressions like the v150.0.5 Referer leak or the semver rc-suffix parse +# break to the population that explicitly opted in. +# +# The client fetches once a week from either # https://github.com/SourceOS-Linux/BearBrowser/releases/latest/download/latest.json -# compares versions, and if newer shows a passive banner with the release URL. -# No auto-download, no auto-install — user must click. Sovereign channel, one -# request/week, no per-install fingerprint. +# https://github.com/SourceOS-Linux/BearBrowser/releases/latest/download/latest-canary.json +# depending on `bearbrowser.update.channel`. Compares versions, and if newer +# shows a passive banner with the release URL. No auto-download, no auto- +# install — user must click. Sovereign channel, one request/week, no per- +# install fingerprint. set -euo pipefail -TAG="${1:?usage: publish-latest-json.sh }" +TAG="${1:?usage: publish-latest-json.sh [--channel stable|canary]}" +CHANNEL="stable" +shift || true +while [ $# -gt 0 ]; do + case "$1" in + --channel) CHANNEL="${2:?}"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +case "$CHANNEL" in + stable) MANIFEST="latest.json" ;; + canary) MANIFEST="latest-canary.json" ;; + *) echo "invalid channel: $CHANNEL (want stable|canary)" >&2; exit 2 ;; +esac + REPO="SourceOS-Linux/BearBrowser" VERSION="${TAG#v}" -cat > /tmp/latest.json < "$OUT" < /tmp/latest.json < +# resolves to this manifest. Canary uses the SAME resolver but a different +# filename, so a stable release still has /latest/download/latest.json AND a +# canary manifest coexisting. LATEST_TAG=$(gh release list --repo "$REPO" --limit 5 --json tagName,isLatest --jq '.[]|select(.isLatest)|.tagName' | head -1) if [ "$LATEST_TAG" = "$TAG" ]; then - echo " $TAG is the /latest alias; the URL /releases/latest/download/latest.json will resolve to this manifest." + echo " $TAG is the /latest alias; /releases/latest/download/$MANIFEST resolves to this manifest." fi diff --git a/settings/start/bearstart-autoconfig.js b/settings/start/bearstart-autoconfig.js index dc6ce11..988ef18 100644 --- a/settings/start/bearstart-autoconfig.js +++ b/settings/start/bearstart-autoconfig.js @@ -183,6 +183,12 @@ try { // %LOCALE% per install. Our version: fetch a static latest.json from the release // page ONCE PER WEEK, compare, notify via observer if newer. No auto-download. // One GitHub HTTPS request per user per week, no fingerprint-bearing template. +// +// Channel selection: `bearbrowser.update.channel` (string) → "stable" (default) +// or "canary". Canary users get latest-canary.json — a release soaks on canary +// for 24-72h before being promoted to stable. This contains regressions (like +// the v150.0.5 Referer leak or the semver rc-suffix parse break) to a small +// opt-in population instead of every user's next weekly poll. try { const LAST_KEY = "bearbrowser.update.last_check"; const WEEK_MS = 7 * 24 * 3600 * 1000; @@ -192,7 +198,12 @@ try { Services.prefs.setIntPref(LAST_KEY, now); // 30s delay so this never adds to startup latency setTimeout(() => { - fetch("https://github.com/SourceOS-Linux/BearBrowser/releases/latest/download/latest.json", + const channel = Services.prefs.getCharPref( + "bearbrowser.update.channel", "stable" + ); + const manifest = channel === "canary" ? "latest-canary.json" : "latest.json"; + const url = "https://github.com/SourceOS-Linux/BearBrowser/releases/latest/download/" + manifest; + fetch(url, { cache: "no-store", credentials: "omit", // never send GitHub any cookie referrer: "", // no Referer header at all From 4451554ea208d36ffa0b7c8e39baff0f9ad29a3f Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:46:13 -0400 Subject: [PATCH 2/3] fix(infra-quartet): 3 post-CI fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failed on first CI pass, all my bugs, all mechanical: 1. cockpit-boot: bare glob `tar -xJf nightly/BearBrowser-*.tar.xz` handed tar TWO archives (nightly tag now carries both the schedule's 150.0.1-dev tarball AND my workflow_dispatch-versioned 150.0.6). tar treated the second as a member pattern → 'Not found in archive'. Pick exactly ONE tarball: prefer ubuntu variant, then any linux. 2. update-check-js: fetch-block regex required latest.json INSIDE the fetch() args. Canary refactor moved the URL to a `url` variable, so the fetch(url, ...) block has no literal filename. Switch to anchor-based region matching — scope by 'Sovereign update check' comment + 3000 chars, tolerant to future refactors. All hygiene assertions still fire on the region. 3. windows-selfsigned: hardcoded signtool path C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\ signtool.exe — SDK version subdir moves across runner-image revisions. Dynamic Get-ChildItem finds newest 10.0.* variant. Meta: each of these is exactly the class of bug the CI is meant to catch. The first three CI runs of new tooling are the shakedown. --- .../workflows/cockpit-boot-integration.yml | 11 ++++++- .github/workflows/signing-rehearsal.yml | 29 +++++++++++++---- scripts/tests/test_update_check.mjs | 32 ++++++++++++------- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cockpit-boot-integration.yml b/.github/workflows/cockpit-boot-integration.yml index b59b7a1..2284e6b 100644 --- a/.github/workflows/cockpit-boot-integration.yml +++ b/.github/workflows/cockpit-boot-integration.yml @@ -63,7 +63,16 @@ jobs: - name: Extract + start browser under xvfb run: | mkdir -p ext - tar -xJf nightly/BearBrowser-*.tar.xz -C ext + # Pick exactly ONE tarball. A nightly tag may carry BOTH the schedule + # artifact (150.0.1-dev) and a workflow_dispatch versioned artifact + # (e.g. 150.0.6). A bare glob passed to tar treats extras as archive + # members, not additional inputs → "Not found in archive". Pick one + # deterministically, prefer ubuntu variant. + TAR=$(ls nightly/BearBrowser-*linux-x86_64-ubuntu*.tar.xz 2>/dev/null | head -1) + [ -z "$TAR" ] && TAR=$(ls nightly/BearBrowser-*linux-x86_64*.tar.xz 2>/dev/null | head -1) + [ -n "$TAR" ] || { echo "::error::no linux tarball in nightly/"; ls -la nightly/; exit 1; } + echo "extracting: $TAR" + tar -xJf "$TAR" -C ext BB=$(find ext -maxdepth 3 -name bearbrowser -type f -perm -111 | head -1) [ -n "$BB" ] || { echo "::error::no bearbrowser executable found"; find ext -maxdepth 3; exit 1; } echo "browser: $BB" diff --git a/.github/workflows/signing-rehearsal.yml b/.github/workflows/signing-rehearsal.yml index a90d0b2..9c86318 100644 --- a/.github/workflows/signing-rehearsal.yml +++ b/.github/workflows/signing-rehearsal.yml @@ -112,11 +112,28 @@ jobs: - name: Sign with signtool shell: pwsh run: | + # Locate signtool dynamically — the Windows SDK version subdir under + # 'Windows Kits\10\bin\' moves across runner-image revisions. + # Hardcoding one version (as the first rehearsal did) breaks whenever + # the runner is updated. Search all 10.0.* SDK subdirs, prefer newest. + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\10.0.*\x64\signtool.exe" ` + -ErrorAction SilentlyContinue | + Sort-Object -Property { [Version]$_.Directory.Parent.Name } -Descending | + Select-Object -First 1 -ExpandProperty FullName + if (-not $signtool) { + # Fallback: any signtool.exe under Windows Kits\10\bin\x64 (root of tree). + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe" ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + } + if (-not $signtool) { + Write-Error "signtool.exe not found on this runner — Windows Kits layout has changed" + exit 1 + } + Write-Host "using signtool: $signtool" $exe = Get-ChildItem *.exe | Select-Object -First 1 - & "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" ` - sign /f $env:REHEARSAL_PFX /p "rehearsal" ` - /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com ` - $exe.FullName - & "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" ` - verify /pa $exe.FullName + & $signtool sign /f $env:REHEARSAL_PFX /p "rehearsal" ` + /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com ` + $exe.FullName + & $signtool verify /pa $exe.FullName Write-Host "rehearsal OK — pipeline can sign a real EXE when secrets arrive" diff --git a/scripts/tests/test_update_check.mjs b/scripts/tests/test_update_check.mjs index dbfbcdb..e9105f1 100644 --- a/scripts/tests/test_update_check.mjs +++ b/scripts/tests/test_update_check.mjs @@ -88,21 +88,31 @@ check( "shipped code sets referrerPolicy:\"no-referrer\"", /referrerPolicy\s*:\s*"no-referrer"/.test(SRC), ); -// They must be on the SAME fetch call. Extract the update-check fetch block and -// assert both keywords appear inside a single fetch(...). -const fetchBlocks = SRC.match(/fetch\s*\([^)]*\{[^}]+\}[^)]*\)/gs) || []; -const updateFetch = fetchBlocks.find(f => - /latest\.json|github\.com\/.*releases\/latest/.test(f), +// They must be on the SAME fetch call. Locate the "update-check region" — the +// contiguous source lines around the update-check fetch — and assert both +// keywords appear inside it. Prior regex required latest.json to appear +// INSIDE the fetch() args, which broke when the URL was moved into a `url` +// variable for the canary channel refactor. New approach: scope by comment +// anchor + 40 lines, tolerant to future refactors. +const anchor = SRC.indexOf("Sovereign update check"); +check( + "update-check anchor comment present", + anchor >= 0, +); +const updateRegion = anchor >= 0 ? SRC.slice(anchor, anchor + 3000) : ""; +check( + "update-check region references latest.json (either directly or via manifest var)", + /latest(-canary)?\.json/.test(updateRegion), ); check( - "update-check fetch block exists", - Boolean(updateFetch), + "update-check region contains a fetch(...) call", + /fetch\s*\(/.test(updateRegion), ); -if (updateFetch) { +if (updateRegion) { check( - "update-fetch block sets BOTH credentials:omit AND referrerPolicy:no-referrer", - /credentials\s*:\s*"omit"/.test(updateFetch) && - /referrerPolicy\s*:\s*"no-referrer"/.test(updateFetch), + "update-check region sets BOTH credentials:omit AND referrerPolicy:no-referrer", + /credentials\s*:\s*"omit"/.test(updateRegion) && + /referrerPolicy\s*:\s*"no-referrer"/.test(updateRegion), "one without the other = a leak that would take another release to notice", ); } From 17ed555dea88f9981e3fed762e1ea4693566e870 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:48:49 -0400 Subject: [PATCH 3/3] signing-rehearsal: drop verify /pa (self-signed can't chain to trusted root) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign step succeeded — the failing step was 'signtool verify /pa' which requires the cert to chain to a machine-trusted root. A self-signed rehearsal cert cannot do that by definition. Replace with Get-AuthenticodeSignature — reports signer info even for untrusted chains, which is exactly what proves signtool ran end-to-end and wrote a valid Authenticode structure. Assert: (a) signature present, (b) subject matches the rehearsal cert CN. That gives real proof the pipeline works while accepting that 'trust' can only be tested with real certs. --- .github/workflows/signing-rehearsal.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/signing-rehearsal.yml b/.github/workflows/signing-rehearsal.yml index 9c86318..0ba4823 100644 --- a/.github/workflows/signing-rehearsal.yml +++ b/.github/workflows/signing-rehearsal.yml @@ -135,5 +135,22 @@ jobs: & $signtool sign /f $env:REHEARSAL_PFX /p "rehearsal" ` /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com ` $exe.FullName - & $signtool verify /pa $exe.FullName + if ($LASTEXITCODE -ne 0) { Write-Error "signtool sign failed"; exit 1 } + # Do NOT use `signtool verify /pa` — that requires the cert to chain + # to a machine-trusted root, which a self-signed rehearsal cert + # cannot do by definition. The meaningful assertion is: a signature + # is now present on the exe. Get-AuthenticodeSignature reports the + # signer info even when the chain is untrusted, which is exactly + # what we need for rehearsal — proof signtool ran end-to-end. + $sig = Get-AuthenticodeSignature $exe.FullName + Write-Host "signature status: $($sig.Status)" + Write-Host "signer subject: $($sig.SignerCertificate.Subject)" + if (-not $sig.SignerCertificate) { + Write-Error "no signer certificate attached — sign step did not produce a signature" + exit 1 + } + if ($sig.SignerCertificate.Subject -notlike "*BearBrowser Rehearsal*") { + Write-Error "signer subject does not match rehearsal cert: $($sig.SignerCertificate.Subject)" + exit 1 + } Write-Host "rehearsal OK — pipeline can sign a real EXE when secrets arrive"