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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ shared conformance corpus, documentation, and CI atomically.
1. Install the iPad beta from
[TestFlight](https://testflight.apple.com/join/yU4e8s6d), or build the
Flutter app locally.
2. Open [pyble.dev/flash](https://pyble.dev/flash) in desktop Chrome or Edge.
2. Open [pyble.dev/flash](https://pyble.dev/flash) in a supported desktop
Chromium browser.
The exact v0.4.2 hardware-tested beta is active. Browser installation and
interrupted-flash recovery passed on both exact profiles; complete release
qualification continues. Confirm the active version, your exact profile,
Expand Down
8 changes: 8 additions & 0 deletions tools/web/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ browser can load it without a certificate warning.

## Deploy a release

The deploy helper proves that the active `pyble.dev` HTTPS configuration and
security-header snippet are byte-identical to the repository before it builds
or uploads a release. When either repository file changes, first copy it to a
root-owned temporary path on the VPS, retain a dated backup of the installed
file, install it atomically at the path in the bootstrap table, run `nginx -t`,
reload Nginx, and verify the affected policy endpoints. Only then run the
website deployment. A configuration mismatch fails before activation.

The deploy helper refuses every `tools/web/.env*` filesystem node before it
chooses a deployment mode, so Next cannot inject an inherited selector or
other unreviewed build input. It also refuses a dirty source tree, freezes the
Expand Down
5 changes: 4 additions & 1 deletion tools/web/deploy/nginx/10-pyble-dev-https.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ map $status $pyble_firmware_cache_control {
# social cards out of caches while retaining the normal no-cache/no-transform
# policy for the shared website 404 page.
map $request_uri $pyble_not_found_cache_control {
~^/social/pyble-beta-og-1200x630\.(?:png|svg)(?:\?|$) "no-store";
"/social/pyble-beta-og-1200x630.png" "no-store";
"/social/pyble-beta-og-1200x630.svg" "no-store";
~^/social/pyble-beta-og-1200x630\.png\? "no-store";
~^/social/pyble-beta-og-1200x630\.svg\? "no-store";
~^/firmware/ "no-store";
default "no-cache, no-transform";
}
Expand Down
72 changes: 65 additions & 7 deletions tools/web/deploy/vps/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,54 @@ if [[ -n $(git -C "${repository_root}" status --porcelain --untracked-files=all
fi
readonly commit=$(git -C "${repository_root}" rev-parse HEAD)

verify_remote_runtime_config() {
local source_config=$1
local installed_config=$2
local config_label=$3
local source_digest=
local installed_digest=

test ! -L "${source_config}"
test -f "${source_config}"
source_digest=$(shasum --algorithm 256 -- "${source_config}" |
awk '{ print $1 }')
if ! installed_digest=$(
ssh -o BatchMode=yes "${deploy_target}" bash -s -- \
"${installed_config}" <<'REMOTE'
set -euo pipefail

readonly installed_config=$1
test ! -L "${installed_config}"
test -f "${installed_config}"
sha256sum -- "${installed_config}" | awk '{ print $1 }'
REMOTE
); then
printf 'Refusing deployment: active %s is missing or unsafe at %s.\n' \
"${config_label}" "${installed_config}" >&2
exit 65
fi
if [[ ! "${source_digest}" =~ ^[0-9a-f]{64}$ ||
! "${installed_digest}" =~ ^[0-9a-f]{64}$ ]]; then
printf 'Refusing deployment: %s digest validation failed.\n' \
"${config_label}" >&2
exit 65
fi
if [[ "${source_digest}" != "${installed_digest}" ]]; then
printf 'Refusing deployment: active %s differs from repository source; install, validate, and reload Nginx first.\n' \
"${config_label}" >&2
exit 65
fi
}

verify_remote_runtime_config \
"${script_directory}/../nginx/10-pyble-dev-https.conf" \
/etc/nginx/sites-available/10-pyble-dev-https.conf \
'pyble.dev HTTPS configuration'
verify_remote_runtime_config \
"${script_directory}/../nginx/pyble-security-headers.conf" \
/etc/nginx/snippets/pyble-security-headers.conf \
'security-header configuration'

verify_firmware_tree_parity() {
local expected_tree=$1
local actual_tree=$2
Expand Down Expand Up @@ -1048,6 +1096,16 @@ rollback_on_smoke_error() {
fi
exit "${smoke_status}"
}
reject_post_activation_smoke() {
local smoke_status=$1
if [[ ! "${smoke_status}" =~ ^[1-9][0-9]*$ ||
"${smoke_status}" -gt 255 ]]; then
printf 'Invalid post-activation smoke status: %s.\n' \
"${smoke_status}" >&2
return 70
fi
return "${smoke_status}"
}
trap rollback_on_smoke_error ERR

smoke_root=$(mktemp -d)
Expand Down Expand Up @@ -1122,13 +1180,13 @@ for firmware_release in out/firmware/v*; do
if [[ "${firmware_path}" == "${checksum_line}" ]]; then
printf 'Invalid firmware checksum entry: %s\n' \
"${checksum_line}" >&2
exit 67
reject_post_activation_smoke 67
fi
case "${firmware_path}" in
""|/*|*\\*|*..*)
printf 'Unsafe firmware checksum path: %s\n' \
"${firmware_path}" >&2
exit 67
reject_post_activation_smoke 67
;;
esac
mkdir -p -- "$(dirname -- "${public_release}/${firmware_path}")"
Expand All @@ -1154,7 +1212,7 @@ readonly not_found_status=$(
if [[ "${not_found_status}" != 404 ]]; then
printf 'Public 404 smoke failed: expected 404, received %s.\n' \
"${not_found_status}" >&2
exit 66
reject_post_activation_smoke 66
fi

retired_public_asset_paths=(
Expand All @@ -1180,7 +1238,7 @@ for retired_public_asset_path in "${retired_public_asset_paths[@]}"; do
"${retired_public_asset_method}" \
"${retired_public_asset_path}" \
"${retired_public_asset_status}" >&2
exit 66
reject_post_activation_smoke 66
fi
retired_public_asset_normalized_headers="${retired_public_asset_headers}.normalized"
tr -d '\r' < "${retired_public_asset_headers}" > \
Expand All @@ -1190,7 +1248,7 @@ for retired_public_asset_path in "${retired_public_asset_paths[@]}"; do
printf 'Retired public asset smoke failed for %s %s: Cache-Control is not no-store.\n' \
"${retired_public_asset_method}" \
"${retired_public_asset_path}" >&2
exit 66
reject_post_activation_smoke 66
fi
done
retired_public_asset_index=$((retired_public_asset_index + 1))
Expand Down Expand Up @@ -1227,7 +1285,7 @@ for firmware_not_found_path in "${firmware_not_found_paths[@]}"; do
"${firmware_not_found_method}" \
"${firmware_not_found_path}" \
"${firmware_not_found_status}" >&2
exit 66
reject_post_activation_smoke 66
fi
firmware_not_found_normalized_headers="${firmware_not_found_headers}.normalized"
tr -d '\r' < "${firmware_not_found_headers}" > \
Expand All @@ -1237,7 +1295,7 @@ for firmware_not_found_path in "${firmware_not_found_paths[@]}"; do
printf 'Firmware 404 smoke failed for %s %s: Cache-Control is not no-store.\n' \
"${firmware_not_found_method}" \
"${firmware_not_found_path}" >&2
exit 66
reject_post_activation_smoke 66
fi
done
firmware_not_found_index=$((firmware_not_found_index + 1))
Expand Down
49 changes: 46 additions & 3 deletions tools/web/src/test/vps-deployment-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ describe("Cloudflare-fronted VPS deployment", () => {
expect(headers).toContain("Strict-Transport-Security");
});

it("refuses deployment when active Nginx contracts differ from source", async () => {
const script = await readFile(
join(deploymentRoot, "vps", "deploy.sh"),
"utf8",
);
const parityIndex = script.indexOf("verify_remote_runtime_config");
const buildIndex = script.indexOf(
"NEXT_TELEMETRY_DISABLED=1 npm run check",
);
const uploadIndex = script.search(/\brsync\b/);

expect(parityIndex).toBeGreaterThan(-1);
expect(parityIndex).toBeLessThan(buildIndex);
expect(parityIndex).toBeLessThan(uploadIndex);
expect(script).toContain(
"/etc/nginx/sites-available/10-pyble-dev-https.conf",
);
expect(script).toContain("/etc/nginx/snippets/pyble-security-headers.conf");
expect(script).toMatch(
/verify_remote_runtime_config\(\)[\s\S]*?shasum[\s\S]*?ssh[\s\S]*?sha256sum[\s\S]*?(?:mismatch|differs)/i,
);
});

it("keeps firmware 404 responses non-cacheable through the shared error page", async () => {
const config = await readFile(
join(deploymentRoot, "nginx", "10-pyble-dev-https.conf"),
Expand All @@ -112,9 +135,11 @@ describe("Cloudflare-fronted VPS deployment", () => {
readFile(join(deploymentRoot, "vps", "deploy.sh"), "utf8"),
]);

expect(config).toContain(
'~^/social/pyble-beta-og-1200x630\\.(?:png|svg)(?:\\?|$) "no-store";',
);
for (const extension of ["png", "svg"]) {
const path = `/social/pyble-beta-og-1200x630.${extension}`;
expect(config).toContain(`"${path}" "no-store";`);
expect(config).toContain(`~^${path.replace(".", "\\.")}\\? "no-store";`);
}
expect(script).toContain("retired_public_asset_paths=(");
expect(script).toContain("/social/pyble-beta-og-1200x630.png");
expect(script).toContain("/social/pyble-beta-og-1200x630.svg");
Expand Down Expand Up @@ -257,6 +282,24 @@ describe("Cloudflare-fronted VPS deployment", () => {
);
});

it("routes explicit post-activation smoke rejections through rollback", async () => {
const script = await readFile(
join(deploymentRoot, "vps", "deploy.sh"),
"utf8",
);
const smokeStart = script.indexOf("trap rollback_on_smoke_error ERR");
const smokeEnd = script.indexOf("trap - ERR", smokeStart + 1);
const smokeRegion = script.slice(smokeStart, smokeEnd);

expect(smokeStart).toBeGreaterThan(-1);
expect(smokeEnd).toBeGreaterThan(smokeStart);
expect(script).toMatch(
/reject_post_activation_smoke\(\)[\s\S]*?return\s+"\$\{smoke_status\}"/,
);
expect(smokeRegion).not.toMatch(/\bexit\s+(?:66|67)\b/);
expect(smokeRegion).toMatch(/\breject_post_activation_smoke\s+(?:66|67)\b/);
});

it("arms rollback before the current symlink can switch or the activation SSH can fail", async () => {
const script = await readFile(
join(deploymentRoot, "vps", "deploy.sh"),
Expand Down
Loading