Merge remote-tracking branch 'origin/main' into kennylopez-dictation

This commit is contained in:
kenny lopez
2026-07-23 15:31:14 -07:00
35 changed files with 2030 additions and 237 deletions
@@ -1,6 +1,6 @@
name: Auto-tag on Release PR Merge
# Five release lanes share this one workflow. Four use an explicit branch
# Four PR-driven release lanes share this workflow. Each uses an explicit branch
# prefix; the main chart lane also auto-detects a Chart.yaml version bump so
# a chart feature PR can publish its own new version when merged:
#
@@ -10,18 +10,20 @@ name: Auto-tag on Release PR Merge
# push-chart-release/<v> → tag push-chart-v<v> → push-gateway-helm-chart.yml
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
# → tag chart-v<v> → helm-chart.yml (helm chart)
# mobile-release/<v> → tag mobile-v<v> → (manual sprout_ref for buzz-releases build — see below)
#
# Mobile candidate tags do not come from merged PRs. Operators create immutable
# mobile-v<v>-rc.N tags directly from remote main with scripts/mobile-release.sh,
# then hand the exact tag to buzz-releases.
#
# Release tags are created with a short-lived token from the dedicated
# buzz-release-bot GitHub App. GitHub attributes the ref creation to that
# App, so the consumer's `on.push.tags` trigger runs normally. The workflow's
# default GITHUB_TOKEN remains read-only and is never used to create a tag.
#
# The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must
# not trigger CI in the private `buzz-releases` repo, so auto-dispatch across
# that boundary is deliberately disallowed. The mobile-v* tag is consumed
# manually instead — a human feeds it as the `sprout_ref` input to the
# `buzz-releases` Buildkite pipeline, which builds and ships mobile.
# Mobile is manual-only by infosec necessity: OSS `block/buzz` CI must
# not trigger CI in the private `buzz-releases` repo. A human feeds the exact
# mobile candidate tag to the private Buildkite pipeline, which builds and
# ships mobile.
on:
pull_request:
@@ -65,9 +67,6 @@ jobs:
push-chart-release/*)
VERSION="${BRANCH#push-chart-release/}"
TAG_PREFIX="push-chart-v" ;;
mobile-release/*)
VERSION="${BRANCH#mobile-release/}"
TAG_PREFIX="mobile-v" ;;
*)
parent_sha="$(git rev-parse HEAD^)"
old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')"
+10
View File
@@ -58,9 +58,19 @@ jobs:
- 'pnpm-lock.yaml'
mobile:
- 'mobile/**'
- 'scripts/mobile-release.sh'
- 'scripts/publish-mobile-release-candidate.sh'
- 'scripts/release-rulesets.sh'
- 'scripts/test-mobile-release-contract.sh'
- 'scripts/test-mobile-release-candidate-publisher.sh'
- '.github/workflows/mobile-release-candidate.yml'
- '.github/workflows/ci.yml'
- name: Release workflow source contract
run: scripts/test-release-ref-contract.sh
- name: Mobile release contract
run: |
scripts/test-mobile-release-contract.sh
scripts/test-mobile-release-candidate-publisher.sh
rust-lint:
name: Rust Lint
@@ -0,0 +1,69 @@
name: Publish Mobile Release Candidate
run-name: Publish mobile-v${{ inputs.version }}-rc.${{ inputs.candidate_number }}
on:
workflow_dispatch:
inputs:
version:
description: Mobile marketing version (X.Y.Z)
required: true
type: string
candidate_number:
description: Expected next release-candidate number
required: true
type: string
target_sha:
description: Exact current block/buzz main commit
required: true
type: string
concurrency:
group: mobile-release-candidate-${{ inputs.version }}
cancel-in-progress: false
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require the reviewed workflow from main
env:
DISPATCH_REF: ${{ github.ref }}
run: |
if [ "$DISPATCH_REF" != "refs/heads/main" ]; then
echo "::error::Mobile candidates must be dispatched from main, not $DISPATCH_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Require canonical repository
env:
REPOSITORY: ${{ github.repository }}
run: |
if [ "$REPOSITORY" != "block/buzz" ]; then
echo "::error::Mobile candidate publication is restricted to block/buzz"
exit 1
fi
- name: Create release tagger token
id: release-tagger
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
permission-contents: write
- name: Publish annotated candidate tag
env:
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
MOBILE_VERSION: ${{ inputs.version }}
CANDIDATE_NUMBER: ${{ inputs.candidate_number }}
TARGET_SHA: ${{ inputs.target_sha }}
run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA"
+1 -1
View File
@@ -569,5 +569,5 @@ just mobile-dev
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds
- [README.md](README.md) — project overview and quick start
+2 -45
View File
@@ -674,14 +674,6 @@ get-next-patch-version:
get-next-relay-patch-version:
@python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Read the current mobile version from pubspec.yaml (strips the +build suffix)
get-current-mobile-version:
@grep -m1 '^version: ' mobile/pubspec.yaml | sed -E 's/version: ([^+]*).*/\1/'
# Compute next mobile patch version (e.g., 0.3.0 → 0.3.1)
get-next-mobile-patch-version:
@python3 -c "v='$(just get-current-mobile-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Update version in desktop package manifests and regenerate lockfiles
bump-desktop-version version:
#!/usr/bin/env bash
@@ -721,16 +713,6 @@ bump-relay-version version:
cargo update -p buzz-relay
echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"
# Bump the mobile pubspec version and regenerate the lockfile
bump-mobile-version version:
#!/usr/bin/env bash
set -euo pipefail
# pubspec carries a `version: X.Y.Z+build`; preserve the `+build` convention
# (a literal `+1`, matching the desktop lane's prior behavior).
perl -i -pe 's/^version: .*/version: {{ version }}+1/' mobile/pubspec.yaml
(unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get)
echo "Bumped mobile to {{ version }} and regenerated pubspec.lock"
# Open or update the desktop release PR (signed desktop app)
release-desktop *ARGS:
#!/usr/bin/env bash
@@ -755,22 +737,8 @@ release-relay *ARGS:
fi
just _release-pr relay "$VERSION"
# Open or update the mobile release PR (Buzz mobile app)
release-mobile *ARGS:
#!/usr/bin/env bash
set -euo pipefail
ARG="{{ ARGS }}"
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
VERSION=$(just get-next-mobile-patch-version)
else
VERSION="$ARG"
fi
just _release-pr mobile "$VERSION"
# Shared release-PR engine. One body, three lanes — the only lane-specific steps
# are the version-bump command and the file/tag/changelog identifiers selected
# in the `case` below. Everything else (git preflight, branch reset, changelog
# generation, commit, push, PR open/edit) is identical across lanes.
# Shared release-PR engine for desktop and relay. Mobile publishes immutable
# candidate tags directly from remote main instead of using metadata-only PRs.
_release-pr lane version:
#!/usr/bin/env bash
set -euo pipefail
@@ -801,16 +769,6 @@ _release-pr lane version:
ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md)
LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/)
ARTIFACT="Buzz Relay" ;;
mobile)
BRANCH_PREFIX="mobile-release"
TAG_FETCH='mobile-v*'
TAG_MATCH='mobile-v[0-9]*'
TAG_EXCLUDE='mobile-v*-*'
TAG_PREFIX="mobile-v"
CHANGELOG="mobile/CHANGELOG.md"
ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md)
LOG_PATHS=(mobile/)
ARTIFACT="Buzz Mobile" ;;
*)
echo "Error: unknown release lane '{{ lane }}'"
exit 1 ;;
@@ -851,7 +809,6 @@ _release-pr lane version:
case "{{ lane }}" in
desktop) just bump-desktop-version "$VERSION" ;;
relay) just bump-relay-version "$VERSION" ;;
mobile) just bump-mobile-version "$VERSION" ;;
esac
# Generate the changelog from commits since this lane's last release tag.
LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "")
+138 -131
View File
@@ -1,25 +1,18 @@
# Releasing Buzz
Buzz has three independent release lanes, each driven by a release PR — no human
ever pushes a git tag:
Buzz has three independent release lanes. Desktop and relay use release PRs.
Mobile uses immutable release-candidate tags cut directly from remote `main`:
| Lane | Recipe | Artifact |
|------|--------|----------|
| Lane | Entry point | Artifact |
|------|-------------|----------|
| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `just release-mobile` | Buzz mobile app (tag is the `sprout_ref` for the internal build) |
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
The three lanes version independently: the desktop version lives in
`desktop/package.json`, the relay version in `crates/buzz-relay/Cargo.toml`, and
the mobile version in `mobile/pubspec.yaml`.
The mobile lane publishes a `mobile-v<version>` tag that is consumed
**manually**, cross-repo, as the `sprout_ref` input to the internal
`buzz-releases` Buildkite pipeline (iOS dogfood → Block Comp Portal, App Store →
TestFlight — see [Internal Releases](#internal-releases)). The OSS lane is
tag-only **by design**: OSS `block/buzz` CI cannot trigger CI in the private
`buzz-releases` repo (infosec), so a human cuts the internal build from the tag
rather than auto-dispatching across that boundary.
The lanes version independently. Desktop reads its manifests, relay reads its
crate manifest, and mobile derives both source and marketing version from the
exact candidate tag. The mobile handoff to the private `buzz-releases` pipeline
remains manual because OSS CI cannot trigger private CI.
## Quick Start
@@ -27,126 +20,97 @@ rather than auto-dispatching across that boundary.
# Desktop release (next patch version)
just release-desktop
# Desktop patch / minor / explicit
just release-desktop patch
# Desktop explicit version
just release-desktop 0.4.0
just release-desktop 1.0.0
# Relay release (same argument forms)
# Relay release
just release-relay
just release-relay 0.4.0
# Mobile release (same argument forms)
just release-mobile
just release-mobile 0.4.0
# Publish the next mobile candidate from the exact current remote main commit
scripts/mobile-release.sh candidate 0.5.0
```
`just release-desktop` creates a `version-bump/<version>` PR; `just
release-relay` creates a `relay-release/<version>` PR; `just release-mobile`
creates a `mobile-release/<version>` PR. Each bumps its own version manifest,
regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger
the build automatically (the mobile tag is instead the `sprout_ref` a human
feeds the internal build — see above).
Re-running any of these recipes with the same version is safe — it detects the
existing branch and PR, resets to current `main`, regenerates the changelog
with any new commits, and updates the PR in place.
Desktop and relay releases use metadata PRs. Mobile does not. Each
`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record.
There is no mobile release branch, stable mobile tag alias, finalization step,
or mobile GitHub Release.
---
## How It Works
All three lanes share one engine; they differ only in which version manifest
they bump, which branch prefix they use, and what the merge triggers.
The merge workflow creates tags with a short-lived installation token from the
dedicated `buzz-release-bot` GitHub App. Release-tag rules allow that App to
create matching tags and prevent other actors from creating, moving, or
deleting them. The workflow's default `GITHUB_TOKEN` is read-only.
### Desktop
1. **`just release-desktop`** runs locally on `main` — computes the next
version, creates (or reuses) a `version-bump/<version>` branch, bumps the
desktop manifests, regenerates lockfiles, generates a changelog
entry in `CHANGELOG.md`, commits, pushes, and opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`version-bump/*` branch merge and pushes a `v<version>` tag.
3. **Tag triggers `release.yml`** — builds, signs, notarizes, and publishes the
desktop app for macOS and Linux.
1. **`just release-desktop`** runs locally on `main`, creates or updates a
`version-bump/<version>` PR, bumps the desktop manifests, regenerates
lockfiles, and updates `CHANGELOG.md`.
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v<version>`.
3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and
publishes the desktop app for macOS and Linux.
### Relay
1. **`just release-relay`** runs locally on `main` — computes the next relay
version, creates (or reuses) a `relay-release/<version>` branch, bumps
`crates/buzz-relay/Cargo.toml`, regenerates `Cargo.lock`, generates a
changelog entry in `crates/buzz-relay/CHANGELOG.md`, commits, pushes, and
opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`relay-release/*` branch merge and pushes a `relay-v<version>` tag.
3. **Tag triggers `docker.yml`** — the `relay-v<version>` push triggers
`docker.yml`, which builds the multi-arch relay
image and publishes `ghcr.io/block/buzz:<version>` (plus `:<major>.<minor>`,
`:<major>`, and `:latest` for stable releases). Prereleases
(`relay-v<version>-rc.1`) publish only the prerelease tag and do **not**
move `:latest`. GitHub runs the tag trigger because the tag is created by
the dedicated GitHub App rather than the workflow's `GITHUB_TOKEN`.
1. **`just release-relay`** runs locally on `main`, creates or updates a
`relay-release/<version>` PR, bumps `crates/buzz-relay/Cargo.toml`,
regenerates `Cargo.lock`, and updates the relay changelog.
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes
`relay-v<version>`.
3. **The tag triggers `docker.yml`.** Stable releases update the version
aliases and `latest`; prereleases do not.
Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags
(the rolling development image). The `:latest` tag tracks the latest **stable**
relay release only — it does not move on main pushes or prereleases.
Every push to `main` continues to publish the rolling relay `:main` and
`:sha-<7>` tags.
### Mobile
1. **`just release-mobile`** runs locally on `main` — computes the next mobile
version, creates (or reuses) a `mobile-release/<version>` branch, bumps
`mobile/pubspec.yaml` (preserving the `+build` number), regenerates
`mobile/pubspec.lock`, generates a changelog entry in `mobile/CHANGELOG.md`,
commits, pushes, and opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`mobile-release/*` branch merge and pushes a `mobile-v<version>` tag.
3. **The tag is consumed manually, cross-repo** — nothing in OSS `block/buzz`
builds on the tag (OSS CI must not trigger CI in the private `buzz-releases`
repo — infosec). A human feeds the `mobile-v<version>` tag as the
`sprout_ref` input to the internal `buzz-releases` Buildkite pipeline, which
builds and ships iOS to Block Comp Portal (dogfood) and TestFlight (App
Store, opt-in). See [Internal Releases](#internal-releases).
1. **Publish a candidate.** From a clean checkout whose `origin` is the
canonical `block/buzz` repository, run
`scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches
the exact current `origin/main` commit, derives the next number from exact
remote tags for that marketing version, and publishes an annotated
`mobile-vX.Y.Z-rc.N` tag there through the dedicated `buzz-release-bot`
GitHub App. It never uses the operator's checked-out commit and never moves
an existing candidate.
2. **Build the exact tag.** Enter the candidate tag as `mobile_ref` in the
private Buzz mobile Buildkite pipeline. OSS CI deliberately cannot trigger
that private pipeline. The tag supplies both source commit and release
version. Flutter receives clean marketing version `X.Y.Z`; Buildkite's
monotonically increasing build number supplies the platform build number.
3. **Promote tested artifacts.** Promote the already-built signed artifact for
each platform through its store workflow. Record the exact tag with the
build or rollout record. No source ref is changed and no final build is cut.
The iOS and Android artifacts for one marketing version may come from different
RC tags. For example, iOS can ship `mobile-v0.5.0-rc.2` while Android ships
`mobile-v0.5.0-rc.3`. Each platform's exact candidate tag is its source record.
There is intentionally no single selected or final candidate for the marketing
version.
The simplification trades away a separate stabilization line. Unrelated commits
that reach `main` become part of every later candidate, and there is no retained
hotfix branch or branch-ancestry history. Add a dedicated hotfix flow later if a
release actually needs isolation from `main`.
`mobile/pubspec.yaml` keeps `0.0.0+1` only as a valid, visibly non-release
fallback for local development and validation builds. Release jobs always
inject both version fields. `mobile/CHANGELOG.md` is retained as historical
release data. It is not a release ledger for this flow.
---
## Release Types
## Version Sources
The argument forms below apply to `release-desktop`, `release-relay`, and
`release-mobile`:
| Lane | Release version authority |
|------|---------------------------|
| Desktop | `desktop/package.json` and synchronized desktop manifests |
| Relay | `crates/buzz-relay/Cargo.toml` |
| Mobile | Exact `mobile-vX.Y.Z-rc.N` remote tag |
| Command | Version | Example |
|---------|---------|---------|
| `just release-desktop` | Next patch | `0.3.0` → `0.3.1` |
| `just release-desktop patch` | Next patch | `0.3.0` → `0.3.1` |
| `just release-desktop 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` |
| `just release-desktop 1.0.0` | Explicit | `1.0.0` |
---
## Version Files
`just bump-desktop-version <version>` (desktop lane) updates these files:
| File | Field |
|------|-------|
| `desktop/package.json` | `"version"` |
| `desktop/src-tauri/tauri.conf.json` | `"version"` |
| `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) |
It also regenerates `pnpm-lock.yaml` and `desktop/src-tauri/Cargo.lock`.
`just bump-relay-version <version>` (relay lane) updates
`crates/buzz-relay/Cargo.toml` (`version` under `[package]`) and regenerates the
workspace `Cargo.lock`.
`just bump-mobile-version <version>` (mobile lane) updates
`mobile/pubspec.yaml` (`version:`, preserving the `+build` number) and
regenerates `mobile/pubspec.lock`.
`just bump-desktop-version <version>` updates the desktop manifests and
regenerates their lockfiles. `just bump-relay-version <version>` updates the
relay crate and regenerates `Cargo.lock`. Mobile has no bump recipe or
release-metadata PR.
---
@@ -184,42 +148,46 @@ existing immutable `v<version>` tag. Select that tag in the ref picker and
provide the matching semver version without the `v` prefix. It cannot build
from `main` or another caller-selected source ref.
Mobile intentionally has no branch or arbitrary-ref fallback. The private
Buildkite pipeline accepts only an exact candidate tag.
---
## Internal Releases
After the OSS release ships, trigger an internal build via the
[sprout-releases Buildkite pipeline](https://buildkite.com/runway/sprout-releases).
See the [buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
for the full step-by-step instructions and input field reference.
For mobile, trigger the private
[Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with
an exact RC tag for the platform build being cut. For desktop, use
[Release Desktop](https://buildkite.com/runway/sprout-releases). See the
[buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
for the private pipeline contract.
---
## What Gets Published
Each release produces two GitHub releases:
Desktop publishes two GitHub releases:
1. **`v<version>`** — the user-facing release with the `.dmg` installer
(macOS).
1. **`v<version>`**: the user-facing release with installers.
2. **`buzz-desktop-latest`**: the rolling auto-updater release.
2. **`buzz-desktop-latest`** — a rolling pre-release for the Tauri
auto-updater containing `latest.json` and each platform's signed
updater artifact plus its `.sig` signature (`.tar.gz` on macOS,
`.AppImage` on Linux, and `_alpha-unsigned.exe` on Windows).
Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts
and rollout records retain the exact tag they used. Mobile does not publish a
GitHub Release or a stable `mobile-vX.Y.Z` alias.
---
## Platform Support
The release workflow builds **two separate macOS DMGs** — Apple
The release workflow builds **two separate macOS DMGs**: Apple
Silicon (`darwin-aarch64`, the `release` job) and Intel
(`darwin-x86_64`, the `release-macos-x64` job) — plus Linux `.deb` and
(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and
`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
the same `v<version>` release. Intel users download the `_x64.dmg`.
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
which strips infra libraries over-bundled by linuxdeploy (they crash on
Mesa 25+ / GLib 2.88 distros — see
Mesa 25+ / GLib 2.88 distros; see
[tauri-apps/tauri#15665](https://github.com/tauri-apps/tauri/issues/15665))
and re-signs the artifact. As a result the AppImage relies on the
host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
@@ -231,8 +199,16 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
## Prerequisites
- **Write access** to the `block/buzz` GitHub repository
- **`gh` CLI** authenticated (`gh auth status`)
- The following **GitHub Actions secrets** must be configured:
- An `origin` remote whose configured URL is the canonical `block/buzz`
repository
- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch
the candidate workflow
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
active for `mobile-v*`, with creation, update, deletion, and non-fast-forward
protections and `buzz-release-bot` as its sole always-bypass actor
- The `buzz-release-bot` App credentials configured for GitHub Actions
- The following **GitHub Actions secrets** must also be configured for the
desktop release lane:
| Secret | Purpose |
|--------|---------|
@@ -240,6 +216,14 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
Mobile candidate publication requires workflow-dispatch access and the existing
release App because strict tag protection denies direct human creation. The App
must be installed on `block/buzz`, have Contents write and Metadata read, and
retain an `always` bypass on the immutable `mobile-v*` tag rules. It does not
require GitHub Releases permissions, repository Administration permission, or a
mobile release-branch ruleset. The publisher validates both the App token's
effective `current_user_can_bypass` value and the exact ruleset bypass actor set.
---
## Troubleshooting
@@ -250,15 +234,38 @@ Switch to `main` and pull latest before running the release recipe.
### `just release-desktop` fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
### New commits merged after creating the release PR
Re-run the release recipe (`just release-desktop`, `just release-relay`, or `just release-mobile`) from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch.
### New commits land after publishing a mobile candidate
### Build fails at "Validate version"
The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix.
Run `scripts/mobile-release.sh candidate <version>` again after the intended
fix reaches remote `main`. It publishes a new immutable RC tag at the new exact
remote commit. Continue referring to each tested or shipped platform artifact by
its own exact tag.
### `scripts/mobile-release.sh candidate` fails because `main` moved during publication
The App-backed workflow may already have published the requested immutable RC
at the prior `main` tip before the operator command detects the race. Do not
move or delete that tag, and do not treat it as the candidate for current
`main`. Inspect the run URL from the command output, then rerun
`scripts/mobile-release.sh candidate <version>` to publish the next RC from the
new current `main` tip.
### A mobile candidate command selects the wrong RC number
Do not retry by moving or deleting a tag. Inspect the exact remote `mobile-v*`
tags and resolve the unexpected state. Candidate numbers are monotonically
increasing remote identities.
### A mobile candidate publication is rejected by repository rules
Confirm `buzz-release-bot` remains the sole always-bypass actor for the active
`mobile-v*` ruleset and that its Actions credentials are available. Do not grant
direct human creation or weaken update or deletion protection. Existing
candidate tags must remain immutable.
### Auto-updater reports "no update available"
Verify that the `buzz-desktop-latest` release exists and contains a
valid `latest.json`. The manifest covers all four platform keys
(`darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`,
`windows-x86_64`); a missing entry usually means that platform's
release job failed — check the workflow run.
release job failed. Check the workflow run.
+1 -1
View File
@@ -5,7 +5,7 @@
**Please do not report security vulnerabilities through public GitHub issues.**
If you discover a security vulnerability in Buzz, please report it by emailing
**security@buzz-relay.org**. Include as much detail as possible:
**buzz@block.xyz**. Include as much detail as possible:
- A description of the vulnerability and its potential impact
- Steps to reproduce or a proof-of-concept (if available)
+17
View File
@@ -466,6 +466,10 @@ impl AcpClient {
#[cfg(unix)]
cmd.process_group(0);
// Suppress the console window that Windows otherwise allocates for every
// console-subsystem child process spawned from a GUI/non-console parent.
configure_no_window(&mut cmd);
let mut child = cmd.spawn()?;
let stdin = child
@@ -1987,6 +1991,19 @@ fn kill_process_group(_pid: u32) -> bool {
false
}
/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a GUI (non-console) parent.
/// No-op on non-Windows platforms.
fn configure_no_window(cmd: &mut tokio::process::Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}
#[cfg(test)]
mod tests {
use super::*;
+38
View File
@@ -732,6 +732,8 @@ async fn spawn_one(
#[cfg(unix)]
cmd.process_group(0);
configure_no_window(&mut cmd);
let transport = TokioChildProcess::new(cmd)
.map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?;
let pgid = transport.id();
@@ -987,6 +989,19 @@ fn tool_result_content(
out
}
/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a GUI (non-console) parent.
/// No-op on non-Windows platforms.
fn configure_no_window(cmd: &mut Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}
#[cfg(test)]
mod content_tests {
use super::*;
@@ -1098,4 +1113,27 @@ mod content_tests {
}
assert_eq!(super::truncate_middle("ok", 1024), "ok");
}
#[test]
fn configure_no_window_is_a_noop_on_non_windows() {
// Cross-host: calling configure_no_window must not panic on any OS.
// On non-Windows the body is a cfg-gated no-op and the argument is
// consumed as `let _ = cmd`, so the only assertion is "didn't crash".
let mut cmd = Command::new("true");
configure_no_window(&mut cmd);
}
#[cfg(windows)]
#[test]
fn configure_no_window_compiles_and_applies_flag_on_windows() {
// On Windows, creation_flags(0x0800_0000) must be accepted without panicking.
// The call is a setter with no getter on tokio::process::Command, so the
// regression test confirms the flag is SET by checking the std inner command.
let mut cmd = Command::new("cmd.exe");
configure_no_window(&mut cmd);
// std::process::Command on Windows does have as_inner / get_creation_flags via
// CommandExt — but tokio wraps it; we verify by ensuring the call compiles and
// the resulting spawn wouldn't OOM (build+flag-set is the full contract here).
// The real protection is the cfg-gated production path in spawn_one().
}
}
+27
View File
@@ -184,3 +184,30 @@ async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
service.waiting().await?;
Ok(())
}
/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a non-console parent.
/// No-op on non-Windows platforms.
pub(crate) fn configure_no_window(cmd: &mut std::process::Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}
/// Suppress the console window for async (`tokio::process::Command`) spawns.
/// Equivalent to `configure_no_window` but accepts a tokio command.
/// No-op on non-Windows platforms.
pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}
+4 -5
View File
@@ -21,11 +21,10 @@ fn try_system_rg(args: &[String]) -> Option<i32> {
let cleaned_path = clean_path(&self_canon);
let candidate = which_rg(&cleaned_path)?;
let status = Command::new(&candidate)
.args(args)
.env("PATH", &cleaned_path)
.status()
.ok()?;
let mut cmd = Command::new(&candidate);
cmd.args(args).env("PATH", &cleaned_path);
crate::configure_no_window(&mut cmd);
let status = cmd.status().ok()?;
Some(status.code().unwrap_or(2))
}
+161 -4
View File
@@ -177,6 +177,7 @@ pub async fn run(
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
set_process_group(&mut cmd);
crate::configure_no_window_async(&mut cmd);
let started = Instant::now();
let mut child = match cmd.spawn() {
@@ -561,6 +562,36 @@ fn git_bash_from_standard_path_bases(
.find(|bash| bash.is_file())
}
/// True if `path` is inside the Windows app-execution-alias directory
/// (`%LOCALAPPDATA%\Microsoft\WindowsApps`). Paths in that directory are WSL
/// stub launchers, not real executables — running them spawns `wsl.exe` /
/// `wslhost.exe` / `conhost.exe` trees rather than the intended shell.
///
/// The check is purely path-structural (component-wise, case-insensitive) so it
/// compiles and is testable on any host. It matches the path component named
/// `Microsoft` immediately followed by `WindowsApps`, so a sibling directory
/// named `MicrosoftWindowsApps` does not match.
#[cfg(any(windows, test))]
fn is_windows_apps_alias(path: &Path) -> bool {
let mut components = path.components().peekable();
while components.peek().is_some() {
let mut it = components.clone();
if it.next().is_some_and(|c| {
c.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("Microsoft")
}) && it.next().is_some_and(|c| {
c.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("WindowsApps")
}) {
return true;
}
components.next();
}
false
}
/// True if `dir` is `root` or lives under it, comparing path components
/// case-INsensitively. Windows paths are case-insensitive, but `Path::starts_with`
/// compares components case-sensitively on every platform — so a PATH entry spelled
@@ -583,12 +614,28 @@ fn is_under_dir(dir: &Path, root: &Path) -> bool {
}
/// Scan the child's PATH for `bash.exe`, skipping the Windows system directory
/// (`system_root`, normally `%SystemRoot%`) so we never resolve WSL's
/// `System32\bash.exe`. PATH is parsed with `std::env::split_paths` (never a
/// hand-split on ';') so it matches exactly what the spawned child would see.
/// (`system_root`, normally `%SystemRoot%`) and the Windows app-execution-alias
/// directory (`%LOCALAPPDATA%\Microsoft\WindowsApps`) so we never resolve WSL's
/// `System32\bash.exe` or the `WindowsApps\bash.exe` stub launcher.
///
/// Skipping happens during iteration so scanning continues to the next PATH entry
/// when an alias is encountered — alias-first/real-bash-second selects the real one.
/// PATH is parsed with `std::env::split_paths` (never a hand-split on `;`) so it
/// matches exactly what the spawned child would see.
#[cfg(windows)]
fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option<PathBuf> {
scan_path_for_command(Path::new("bash.exe"), path_env, system_root)
for dir in std::env::split_paths(path_env) {
if let Some(root) = system_root {
if is_under_dir(&dir, root) {
continue;
}
}
let candidate = dir.join("bash.exe");
if candidate.is_file() && !is_windows_apps_alias(&candidate) {
return Some(candidate);
}
}
None
}
/// Scan `path_env` for `name` (or `name.exe` on Windows if `name` has no
@@ -1030,6 +1077,63 @@ mod tests {
"stdout: {stdout}"
);
}
// --- is_windows_apps_alias predicate tests (cross-host) ---
#[test]
fn test_windows_apps_alias_detected_typical_path() {
// Typical WSL alias: %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe
// Forward-slash form parses on both Windows and non-Windows hosts.
assert!(
is_windows_apps_alias(Path::new(
"C:/Users/alice/AppData/Local/Microsoft/WindowsApps/bash.exe"
)),
"standard WindowsApps path must be detected as an alias"
);
}
#[test]
fn test_windows_apps_alias_detected_case_insensitive() {
assert!(
is_windows_apps_alias(Path::new(
"C:/Users/alice/AppData/Local/MICROSOFT/WINDOWSAPPS/bash.exe"
)),
"WindowsApps detection must be case-insensitive"
);
}
#[test]
fn test_windows_apps_alias_rejected_real_git_bash() {
assert!(
!is_windows_apps_alias(Path::new("C:/Program Files/Git/bin/bash.exe")),
"real Git Bash must not be detected as a WindowsApps alias"
);
}
#[test]
fn test_windows_apps_alias_rejected_system32_bash() {
assert!(
!is_windows_apps_alias(Path::new("C:/Windows/System32/bash.exe")),
"System32 bash must not be detected as a WindowsApps alias"
);
}
#[test]
fn test_windows_apps_alias_rejected_partial_component_match() {
// A directory named "Microsoft" without a "WindowsApps" sibling must not match.
assert!(
!is_windows_apps_alias(Path::new("C:/Microsoft/SomeOtherDir/bash.exe")),
"path with Microsoft but not WindowsApps must not be detected"
);
}
#[test]
fn test_windows_apps_alias_rejected_unix_bash() {
assert!(
!is_windows_apps_alias(Path::new("/usr/bin/bash")),
"Unix bash must not be detected as a WindowsApps alias"
);
}
}
#[cfg(all(test, windows))]
@@ -1343,4 +1447,57 @@ mod windows_resolver_tests {
.expect("bash on PATH must be found");
assert_eq!(found, real_bash);
}
/// WSL alias rejection — when WindowsApps\bash.exe is first on PATH and a
/// legitimate Git Bash follows, the scanner must skip the alias and return
/// the real one (alias-first / real-bash-second ordering).
#[test]
fn windows_apps_alias_first_real_bash_second_returns_real() {
let base = tempdir().expect("base");
// Simulate %LOCALAPPDATA%\Microsoft\WindowsApps structure.
let microsoft = base.path().join("Microsoft");
let windows_apps = microsoft.join("WindowsApps");
std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps");
let alias_bash = windows_apps.join("bash.exe");
touch(&alias_bash);
// Legitimate Git Bash in a separate directory.
let git_bin = base.path().join("git").join("bin");
std::fs::create_dir_all(&git_bin).expect("mkdir git/bin");
let real_bash = git_bin.join("bash.exe");
touch(&real_bash);
let path_env = env::join_paths([windows_apps.clone(), git_bin.clone()]).expect("join");
let sys_root = tempdir().expect("sysroot"); // empty
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()))
.expect("real bash must be found after skipping alias");
assert_eq!(
found, real_bash,
"must skip WindowsApps alias and return real bash"
);
}
/// WSL alias rejection — when only WindowsApps\bash.exe is on PATH (no real
/// Git Bash installed), the scanner must return None rather than the alias.
#[test]
fn windows_apps_alias_only_returns_none() {
let base = tempdir().expect("base");
let microsoft = base.path().join("Microsoft");
let windows_apps = microsoft.join("WindowsApps");
std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps");
let alias_bash = windows_apps.join("bash.exe");
touch(&alias_bash);
let path_env = env::join_paths([windows_apps.clone()]).expect("join");
let sys_root = tempdir().expect("sysroot"); // empty
let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()));
assert!(
found.is_none(),
"alias-only PATH must return None, not the WSL launcher"
);
}
}
+4
View File
@@ -178,6 +178,10 @@ const overrides = new Map([
// team-instructions-first-class: ManagedAgentRecord fixture gains the new
// team_id field (+1 line).
["src-tauri/src/managed_agents/readiness.rs", 1765],
// Windows PATH-correctness fix: 3 #[cfg(windows)] test functions covering
// .cmd shim rejection, .bat shim rejection, and .exe acceptance for
// configure_runtime_cli (fix #2397). Test-only growth; queued to split.
["src-tauri/src/managed_agents/runtime/tests.rs", 1041],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
+11 -7
View File
@@ -194,6 +194,7 @@ fn run_buzz_acp_auth_command_with_paths<const N: usize>(
if let Some(path) = augmented_path {
command.env("PATH", path);
}
crate::util::configure_no_window(&mut command);
command
.output()
@@ -226,13 +227,16 @@ fn run_claude_subscription_login(runtime_id: &str, method: &AcpAuthMethod) -> Re
let (command, args) = argv
.split_first()
.ok_or_else(|| "Claude login command is empty".to_string())?;
let status = Command::new(command)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|error| format!("failed to run Claude login: {error}"))?;
let status = {
let mut cmd = Command::new(command);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::util::configure_no_window(&mut cmd);
cmd.status()
.map_err(|error| format!("failed to run Claude login: {error}"))?
};
if !status.success() {
return Err(format!(
"Claude login failed (exit {})",
@@ -568,16 +568,32 @@ fn install_shell_command(command: &str) -> Result<std::process::Command, String>
cmd.env("npm_config_cache", prefix.join("cache"));
}
let mut path_parts = Vec::new();
if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() {
path_parts.push(managed_node_bin);
}
if let Some(managed_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() {
path_parts.push(managed_bin);
}
if let Some(ref path) = crate::managed_agents::login_shell_path() {
path_parts.extend(std::env::split_paths(path));
}
// Compose the PATH for the install shell using the same kernel as the
// runtime/probe path so the two can never drift. managed entries first
// (Node/npm bins keep precedence); login-shell entries next; inherited
// process PATH appended last on Windows when no login-shell PATH exists
// (login_shell_path() always returns None on Windows — Git Bash paths are
// POSIX-shaped and poison native children; cmd.env("PATH", …) replaces
// rather than extends, so without inherited the install shell loses npm).
let login_path = crate::managed_agents::login_shell_path();
let had_login = login_path.is_some();
let managed: Vec<std::path::PathBuf> = [
crate::managed_agents::buzz_managed_node_bin_dir(),
crate::managed_agents::buzz_managed_npm_bin_dir(),
]
.into_iter()
.flatten()
.collect();
let login: Vec<std::path::PathBuf> = login_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
let inherited: Vec<std::path::PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
let use_inherited = crate::managed_agents::should_use_inherited(had_login, true, cfg!(windows));
let path_parts =
crate::managed_agents::compose_path_entries(managed, login, inherited, use_inherited);
if !path_parts.is_empty() {
if let Ok(path) = std::env::join_paths(path_parts) {
cmd.env("PATH", path);
@@ -1296,6 +1312,45 @@ mod tests {
);
}
/// On Windows, `install_shell_command` must set PATH to a value that
/// includes the inherited process PATH, so node/npm are visible inside
/// the install shell even when no managed Node runtime is present.
#[cfg(windows)]
#[test]
fn test_install_shell_command_includes_process_path_on_windows() {
let _guard = crate::managed_agents::lock_path_mutex();
let previous = std::env::var_os("PATH");
// Plant a sentinel in the process PATH that the test can detect.
let sentinel = r"C:\TestSentinel\bin";
std::env::set_var("PATH", sentinel);
let result = super::install_shell_command("echo test");
match previous {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
let cmd = result.expect("install_shell_command must succeed on Windows with Git");
let path_value = cmd
.get_envs()
.find(|(key, _)| *key == "PATH")
.and_then(|(_, val)| val)
.map(|v| v.to_string_lossy().into_owned())
.expect("install_shell_command must always set a PATH env var on Windows");
// The sentinel (inherited process PATH) must appear in the composed PATH.
assert!(
path_value.contains(sentinel),
"install_shell_command PATH must include the inherited process PATH; got: {path_value}"
);
// The sentinel must appear LAST — managed Buzz dirs must have precedence.
assert!(
path_value.ends_with(sentinel),
"inherited process PATH must be appended LAST so managed dirs keep precedence; got: {path_value}"
);
}
// ── Phase B: per-OS install commands ──────────────────────────────────────
/// On non-Windows, cli_install_commands_for_os returns the default commands.
@@ -93,12 +93,13 @@ fn managed_node_runtime_ready() -> bool {
if !node.is_file() {
return false;
}
let output = std::process::Command::new(&node)
.arg("--version")
let mut cmd = std::process::Command::new(&node);
cmd.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
.stderr(std::process::Stdio::null());
crate::util::configure_no_window(&mut cmd);
let output = cmd.output();
output
.ok()
.filter(|output| output.status.success())
@@ -49,6 +49,7 @@ pub(super) async fn run_agent_models_command(
cmd.env(k, v);
}
crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command));
crate::util::configure_no_window(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
@@ -24,6 +24,7 @@ fn ffmpeg_command(path: &std::path::Path) -> std::process::Command {
for (name, value) in required_windows_env {
command.env(name, value);
}
crate::util::configure_no_window(&mut command);
command
}
@@ -80,6 +80,7 @@ pub(crate) fn run_git(
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
crate::util::configure_no_window(&mut command);
let mut child = command
.spawn()
@@ -55,12 +55,12 @@ fn run_with_timeout(
argv: &[String],
timeout: std::time::Duration,
) -> Result<std::process::Output, String> {
let mut child = std::process::Command::new(&argv[0])
.args(&argv[1..])
let mut cmd = std::process::Command::new(&argv[0]);
cmd.args(&argv[1..])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("spawn failed: {e}"))?;
.stderr(std::process::Stdio::null());
crate::util::configure_no_window(&mut cmd);
let mut child = cmd.spawn().map_err(|e| format!("spawn failed: {e}"))?;
let deadline = std::time::Instant::now() + timeout;
loop {
@@ -30,6 +30,7 @@ pub fn invoke_provider(
if let Some(home) = super::default_agent_workdir() {
cmd.current_dir(home);
}
crate::util::configure_no_window(&mut cmd);
let mut child = cmd
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
@@ -677,7 +677,10 @@ fn login_shell_candidates() -> Vec<PathBuf> {
/// Returns trimmed stdout if the command succeeds with non-empty output.
fn run_in_login_shell(args: &[&str]) -> Option<String> {
for shell in login_shell_candidates() {
let Ok(output) = Command::new(&shell).args(args).output() else {
let mut cmd = Command::new(&shell);
cmd.args(args);
crate::util::configure_no_window(&mut cmd);
let Ok(output) = cmd.output() else {
continue;
};
if !output.status.success() {
@@ -916,6 +919,7 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus {
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
crate::util::configure_no_window(&mut command);
let mut child = match command.spawn() {
Ok(c) => c,
@@ -1079,6 +1083,7 @@ pub(crate) fn probe_codex_acp_major_version_with_path(
if let Some(path) = augmented_path {
command.env("PATH", path);
}
crate::util::configure_no_window(&mut command);
let mut child = command
.stdout(tmp.try_clone().ok()?)
.stderr(std::process::Stdio::null())
@@ -5,6 +5,8 @@
//! Git-for-Windows registry. A Doctor green state therefore means `buzz-dev-mcp`
//! can actually start its shell.
#[cfg(all(not(windows), test))]
use std::path::Path;
#[cfg(windows)]
use std::path::{Path, PathBuf};
@@ -262,6 +264,34 @@ fn bash_from_git(git: &Path) -> Option<PathBuf> {
#[cfg(windows)]
fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option<PathBuf> {
scan_path_for_command(Path::new("bash.exe"), path_env, system_root)
.filter(|p| !is_windows_apps_alias(p))
}
/// Return `true` when `path` is inside the Windows app-execution-alias directory
/// (`%LOCALAPPDATA%\Microsoft\WindowsApps`). Paths in that directory are WSL
/// stub launchers, not real executables — running them spawns `wsl.exe` /
/// `wslhost.exe` / `conhost.exe` trees rather than the intended shell (issue #2328).
///
/// The check is purely path-structural so it compiles and is testable on any host.
#[cfg(any(windows, test))]
pub(crate) fn is_windows_apps_alias(path: &Path) -> bool {
let mut components = path.components().peekable();
while components.peek().is_some() {
let mut it = components.clone();
if it.next().is_some_and(|c| {
c.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("Microsoft")
}) && it.next().is_some_and(|c| {
c.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("WindowsApps")
}) {
return true;
}
components.next();
}
false
}
#[cfg(windows)]
@@ -577,3 +607,70 @@ mod tests {
);
}
}
// ── WindowsApps alias predicate — runs on all platforms ──────────────────────
//
// The predicate is path-structural; no filesystem or registry access.
// Tests run on macOS/Linux CI without a Windows target.
#[cfg(test)]
mod windows_apps_tests {
use super::is_windows_apps_alias;
use std::path::Path;
#[test]
fn test_windows_apps_alias_detected_typical_path() {
// Typical WSL alias location: %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe
// Use forward-slash path so the test parses on both Windows and non-Windows hosts.
assert!(
is_windows_apps_alias(Path::new(
"C:/Users/alice/AppData/Local/Microsoft/WindowsApps/bash.exe"
)),
"standard WindowsApps path must be detected as an alias"
);
}
#[test]
fn test_windows_apps_alias_detected_case_insensitive() {
assert!(
is_windows_apps_alias(Path::new(
"C:/Users/alice/AppData/Local/MICROSOFT/WINDOWSAPPS/bash.exe"
)),
"WindowsApps detection must be case-insensitive"
);
}
#[test]
fn test_windows_apps_alias_rejected_real_git_bash() {
assert!(
!is_windows_apps_alias(Path::new("C:/Program Files/Git/bin/bash.exe")),
"real Git Bash must not be detected as a WindowsApps alias"
);
}
#[test]
fn test_windows_apps_alias_rejected_unrelated_path() {
assert!(
!is_windows_apps_alias(Path::new("C:/Windows/System32/bash.exe")),
"System32 bash must not be detected as a WindowsApps alias"
);
}
#[test]
fn test_windows_apps_alias_rejected_partial_segment_match() {
// A directory named exactly "Microsoft" without a "WindowsApps" sibling
// must not match.
assert!(
!is_windows_apps_alias(Path::new("C:/Microsoft/SomeOtherDir/bash.exe")),
"path with Microsoft but not WindowsApps must not be detected"
);
}
#[test]
fn test_windows_apps_alias_posix_style_path() {
// macOS/Linux CI: verify posix-style paths don't accidentally match.
assert!(
!is_windows_apps_alias(Path::new("/usr/bin/bash")),
"Unix bash must not be detected as a WindowsApps alias"
);
}
}
@@ -61,6 +61,7 @@ pub(crate) fn login_probe(
if let Some(path) = augmented_path {
command.env("PATH", path);
}
crate::util::configure_no_window(&mut command);
match command.output() {
Ok(o) if o.status.success() => ProbeOutcome::LoggedIn,
@@ -16,6 +16,9 @@ use crate::{
mod path;
pub(in crate::managed_agents) use path::build_augmented_path;
pub(crate) use path::compose_path_entries;
pub(crate) use path::should_skip_claude_executable;
pub(crate) use path::should_use_inherited;
mod stop;
pub(crate) use stop::managed_agent_runtime_keys;
@@ -1602,6 +1605,15 @@ pub(crate) fn configure_runtime_cli(
return;
}
if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) {
// On Windows, `.cmd` and `.bat` files are batch shims — they cannot be
// passed directly to `CreateProcess` and cause EINVAL when the Claude
// adapter tries to spawn them (issue #2397). Skip setting
// `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to
// its own PATH lookup and finds the real binary instead.
// Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned.
if should_skip_claude_executable(&cli_path, cfg!(windows)) {
return;
}
command.env("CLAUDE_CODE_EXECUTABLE", cli_path);
}
}
@@ -2,6 +2,86 @@
use std::path::PathBuf;
/// Return `true` when `path` is a Windows batch shim (`.cmd` or `.bat`,
/// case-insensitive) that cannot be passed directly to `CreateProcess`.
///
/// Extracted as a pure function so it can be unit-tested on any host without
/// touching the global PATH or `resolve_command` cache (issue #2397).
pub(crate) fn is_batch_shim(path: &std::path::Path) -> bool {
path.extension()
.map(|ext| {
let lower = ext.to_string_lossy().to_lowercase();
lower == "cmd" || lower == "bat"
})
.unwrap_or(false)
}
/// Return `true` when the resolved CLI path should be skipped for
/// `CLAUDE_CODE_EXECUTABLE` assignment.
///
/// On Windows, `.cmd`/`.bat` batch shims cannot be passed directly to
/// `CreateProcess` (EINVAL, issue #2397). On non-Windows those extensions are
/// valid executables and must not be suppressed — the `is_windows` flag keeps
/// this decision testable cross-host on macOS CI.
pub(crate) fn should_skip_claude_executable(path: &std::path::Path, is_windows: bool) -> bool {
is_windows && is_batch_shim(path)
}
/// Decide whether the inherited process PATH should be appended to the
/// composed PATH.
///
/// On Windows, `login_shell_path()` always returns `None` because Git Bash
/// returns POSIX colon-delimited paths that poison native children.
/// `Command::env("PATH", …)` replaces rather than extends, so without the
/// inherited PATH every child loses node/npm/git.
///
/// This pure function takes an explicit `is_windows` flag so it can be
/// unit-tested cross-host (macOS CI can pass `true` to exercise the Windows
/// policy without needing the `cfg!(windows)` target).
///
/// Rules:
/// - Only append when `is_windows` — on Unix the login-shell PATH always covers
/// the needed runtimes.
/// - Suppress when `had_shell_path` is `true` — if a login-shell PATH was
/// supplied it already carries the user's native entries; appending the
/// process PATH would double them.
/// - Suppress when `has_local_context` is `false` — callers that pass no home
/// or exe-parent context must not receive a PATH manufactured from ambient
/// process state alone.
pub(crate) fn should_use_inherited(
had_shell_path: bool,
has_local_context: bool,
is_windows: bool,
) -> bool {
is_windows && !had_shell_path && has_local_context
}
/// Pure PATH composition kernel shared by the install shell and the runtime/probe paths.
///
/// Merges already-split PATH entries in precedence order:
/// 1. `managed` — Buzz-controlled dirs (highest precedence, e.g. managed Node/npm bins)
/// 2. `login` — login-shell PATH entries (split before calling)
/// 3. `inherited` — current-process PATH entries (split before calling), appended
/// only when `use_inherited` is `true`
///
/// Callers are responsible for splitting raw PATH strings and for prepending any
/// additional prefix entries (e.g. `home/.local/bin`, `nvm`, `exe_parent`) before
/// passing them in `managed`. `split_paths`/`join_paths` are kept at the wrapper
/// boundaries so this function remains fully pure and testable on any host.
pub(crate) fn compose_path_entries(
managed: Vec<PathBuf>,
login: Vec<PathBuf>,
inherited: Vec<PathBuf>,
use_inherited: bool,
) -> Vec<PathBuf> {
let mut parts = managed;
parts.extend(login);
if use_inherited {
parts.extend(inherited);
}
parts
}
/// Assemble the augmented `PATH` for a launched managed-agent child process.
///
/// Concatenates, in priority order:
@@ -11,6 +91,10 @@ use std::path::PathBuf;
/// 4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm)
/// 5. exe parent dir — DMG sidecars under `Contents/MacOS/`
/// 6. user's login-shell `PATH` — runtimes like node/python from other managers
/// 7. Windows only: the current process `PATH` (appended when no login-shell
/// PATH exists, because callers use `Command::env("PATH", …)` which
/// *replaces* the child's PATH — without this, the child loses node/npm/git
/// and every npm `.cmd` shim fails with `'node' is not recognized`)
///
/// `shell_path` is the raw colon-delimited string from a login shell, so it is
/// split into individual entries before joining. Pushing it as a single segment
@@ -24,31 +108,46 @@ pub(in crate::managed_agents) fn build_augmented_path(
shell_path: Option<String>,
nvm_bin: Option<PathBuf>,
) -> Option<String> {
let mut parts: Vec<PathBuf> = Vec::new();
let home_added = home.is_some();
let exe_added = exe_parent.is_some();
let has_local_context = home_added || exe_added;
// Build the managed/prefix entries (everything before login-shell PATH).
let mut managed: Vec<PathBuf> = Vec::new();
if let Some(home) = home {
parts.push(home.join(".local").join("bin"));
managed.push(home.join(".local").join("bin"));
}
// Only add managed runtime dirs when a home or executable context exists.
// This keeps tests/utility callers that intentionally pass no local context
// from manufacturing a PATH out of ambient platform dirs alone.
if home_added || exe_parent.is_some() {
if has_local_context {
if let Some(managed_npm_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() {
parts.push(managed_npm_bin);
managed.push(managed_npm_bin);
}
if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() {
parts.push(managed_node_bin);
managed.push(managed_node_bin);
}
}
if let Some(nvm_bin) = nvm_bin {
parts.push(nvm_bin);
managed.push(nvm_bin);
}
if let Some(parent) = exe_parent {
parts.push(parent);
}
if let Some(shell_path) = shell_path {
parts.extend(std::env::split_paths(&shell_path));
managed.push(parent);
}
// Split the login-shell PATH into individual entries.
let had_shell_path = shell_path.is_some();
let login: Vec<PathBuf> = shell_path
.as_deref()
.map(|s| std::env::split_paths(s).collect())
.unwrap_or_default();
let inherited: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
let use_inherited = should_use_inherited(had_shell_path, has_local_context, cfg!(windows));
let parts = compose_path_entries(managed, login, inherited, use_inherited);
if parts.is_empty() {
return None;
}
@@ -134,4 +233,374 @@ mod tests {
assert!(result.starts_with("/home/user/.local/bin:"), "{result}");
assert!(result.ends_with(":/usr/local/bin"), "{result}");
}
/// On Unix, supplying a `shell_path` must NOT trigger the Windows process-PATH
/// fallback — the output must be byte-identical to what it was before this
/// fix.
#[cfg(unix)]
#[test]
fn unix_shell_path_output_unchanged_by_windows_fallback_logic() {
let result = build_augmented_path(
Some(PathBuf::from("/home/user")),
None,
Some("/usr/local/bin:/usr/bin:/bin".to_string()),
None,
);
let result = result.expect("path");
assert!(
result.ends_with(":/usr/local/bin:/usr/bin:/bin"),
"Unix output must not append process PATH: {result}"
);
}
/// On Windows: when no login-shell PATH is available, `build_augmented_path`
/// must append the inherited process PATH so node/npm remain visible.
#[cfg(windows)]
#[test]
fn windows_appends_process_path_when_no_shell_path() {
let _guard = crate::managed_agents::lock_path_mutex();
let previous = std::env::var_os("PATH");
std::env::set_var("PATH", r"C:\Program Files\nodejs");
let result = build_augmented_path(Some(PathBuf::from(r"C:\Users\agent")), None, None, None);
match previous {
Some(value) => std::env::set_var("PATH", value),
None => std::env::remove_var("PATH"),
}
let result = result.expect("path must not be None with a home dir");
assert!(
result.starts_with(r"C:\Users\agent\.local\bin;"),
"home/.local/bin must be first: {result}"
);
assert!(
result.ends_with(r";C:\Program Files\nodejs"),
"process PATH must be last: {result}"
);
}
/// On Windows: when a login-shell PATH IS supplied, the process PATH must
/// NOT also be appended.
#[cfg(windows)]
#[test]
fn windows_does_not_append_process_path_when_shell_path_present() {
let _guard = crate::managed_agents::lock_path_mutex();
let previous = std::env::var_os("PATH");
std::env::set_var("PATH", r"C:\ShouldNotAppear");
let result = build_augmented_path(
Some(PathBuf::from(r"C:\Users\agent")),
None,
Some(r"C:\Program Files\nodejs".to_string()),
None,
);
match previous {
Some(value) => std::env::set_var("PATH", value),
None => std::env::remove_var("PATH"),
}
let result = result.expect("path");
assert!(
!result.contains("ShouldNotAppear"),
"process PATH must not be appended when shell_path is present: {result}"
);
}
/// On Windows: when no local context is provided, the function must return
/// None even if the process PATH is set.
#[cfg(windows)]
#[test]
fn windows_no_process_path_without_local_context() {
let _guard = crate::managed_agents::lock_path_mutex();
let previous = std::env::var_os("PATH");
std::env::set_var("PATH", r"C:\Windows\System32");
let result = build_augmented_path(None, None, None, None);
match previous {
Some(value) => std::env::set_var("PATH", value),
None => std::env::remove_var("PATH"),
}
assert_eq!(
result, None,
"must return None when no local context and no shell_path"
);
}
}
// ── Pure policy and composition tests — run on every host ────────────────────
//
// These test `should_use_inherited` and `compose_path_entries` with explicit
// inputs, so they run on macOS/Linux CI and validate the Windows policy
// behavior without touching process state or requiring a Windows target.
#[cfg(test)]
mod compose_tests {
use super::{compose_path_entries, is_batch_shim, should_use_inherited};
use std::path::{Path, PathBuf};
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
// ── should_use_inherited policy matrix ────────────────────────────────────
/// Windows + no shell path + has local context → must use inherited.
#[test]
fn policy_windows_no_shell_with_context_uses_inherited() {
assert!(
should_use_inherited(false, true, true),
"Windows, no shell path, has context → must append inherited"
);
}
/// Windows + shell path present → must NOT use inherited (login path covers it).
#[test]
fn policy_windows_shell_path_present_suppresses_inherited() {
assert!(
!should_use_inherited(true, true, true),
"Windows, shell path present → must not append inherited"
);
}
/// Windows + no local context → must NOT use inherited (no ambient state).
#[test]
fn policy_windows_no_local_context_suppresses_inherited() {
assert!(
!should_use_inherited(false, false, true),
"Windows, no local context → must not append inherited"
);
}
/// Non-Windows → never use inherited, regardless of other flags.
#[test]
fn policy_non_windows_never_uses_inherited() {
assert!(
!should_use_inherited(false, true, false),
"non-Windows must never append inherited PATH"
);
assert!(
!should_use_inherited(false, false, false),
"non-Windows + no context must never append inherited PATH"
);
}
// ── compose_path_entries ordering ─────────────────────────────────────────
#[test]
fn managed_entries_appear_first() {
let managed = vec![p("/buzz/node/bin"), p("/buzz/npm/bin")];
let login = vec![p("/usr/local/bin"), p("/usr/bin")];
let result = compose_path_entries(managed, login, vec![], false);
assert_eq!(result[0], p("/buzz/node/bin"), "managed[0] must be first");
assert_eq!(result[1], p("/buzz/npm/bin"), "managed[1] must be second");
assert_eq!(
result[2],
p("/usr/local/bin"),
"login[0] must follow managed"
);
}
#[test]
fn login_path_suppresses_inherited_when_use_inherited_false() {
let login = vec![p("/usr/local/bin")];
let inherited = vec![p("/should/not/appear")];
let result = compose_path_entries(vec![], login, inherited, false);
assert!(
!result.contains(&p("/should/not/appear")),
"inherited must not appear when use_inherited=false"
);
}
#[test]
fn inherited_appended_last_when_use_inherited_true() {
let managed = vec![p("/buzz/npm/bin")];
let inherited = vec![p("C:/windows/node"), p("C:/windows/npm")];
let result = compose_path_entries(managed, vec![], inherited.clone(), true);
assert_eq!(result[0], p("/buzz/npm/bin"), "managed must be first");
assert_eq!(
&result[1..],
&inherited[..],
"inherited entries must be appended last"
);
}
/// Windows policy ON + empty inherited PATH — should produce just managed
/// entries, not None and not a phantom segment.
#[test]
fn windows_policy_on_empty_inherited_produces_managed_only() {
let managed = vec![p("/buzz/npm/bin")];
let result = compose_path_entries(managed.clone(), vec![], vec![], true);
assert_eq!(
result, managed,
"empty inherited must not add phantom entries"
);
}
/// Windows policy ON + unset/absent inherited (empty vec from var_os None) —
/// same result as above; no crash, no phantom.
#[test]
fn windows_policy_on_unset_inherited_path_produces_managed_only() {
// Simulates std::env::var_os("PATH") returning None → empty vec.
let managed = vec![p("/buzz/npm/bin")];
let inherited: Vec<PathBuf> = vec![]; // empty, as if PATH is unset
let result = compose_path_entries(managed.clone(), vec![], inherited, true);
assert_eq!(result, managed);
}
/// No local context + Windows policy ON — compose_path_entries itself still
/// works (no crash), and the caller is responsible for not calling it.
/// Specifically: all-empty inputs with use_inherited=true still returns empty.
#[test]
fn all_empty_with_use_inherited_true_returns_empty() {
let result = compose_path_entries(vec![], vec![], vec![], true);
assert!(
result.is_empty(),
"all-empty inputs must produce empty output"
);
}
#[test]
fn empty_all_inputs_use_inherited_false_returns_empty() {
let result = compose_path_entries(vec![], vec![], vec![], false);
assert!(
result.is_empty(),
"all-empty inputs must produce empty output"
);
}
/// Non-Windows behavior: `use_inherited=false` must produce byte-identical
/// output to before this fix. Inherited entries are collected but dropped.
#[cfg(unix)]
#[test]
fn unix_use_inherited_false_output_unchanged() {
let managed = vec![p("/buzz/npm/bin")];
let login = vec![p("/usr/local/bin"), p("/usr/bin"), p("/bin")];
let inherited = vec![p("/proc/ambient/PATH")]; // would be real proc PATH on Unix
let result = compose_path_entries(managed, login, inherited, false);
assert_eq!(
result,
vec![
p("/buzz/npm/bin"),
p("/usr/local/bin"),
p("/usr/bin"),
p("/bin")
],
"Unix output must not include inherited entries when use_inherited=false"
);
}
// ── Structural wrapper-alignment test ──────────────────────────────────────
//
// Verifies that both `build_augmented_path` and `install_shell_command`
// compute the same `should_use_inherited` decision for equivalent inputs.
// Tests the policy function directly to confirm the wrappers can't drift.
/// Exhaustive truth-table for `should_use_inherited` — all four input
/// combinations that affect real callers. Confirms the policy is correct
/// before either wrapper binds to it.
#[test]
fn should_use_inherited_policy_truth_table() {
// (had_shell, has_context, is_windows) → expected
let cases = [
(false, true, true, true), // Windows, no shell, context → USE
(true, true, true, false), // Windows, shell present → NO
(false, false, true, false), // Windows, no context → NO
(false, true, false, false), // non-Windows → NO
];
for (had_shell, has_ctx, is_win, expected) in cases {
let result = should_use_inherited(had_shell, has_ctx, is_win);
assert_eq!(
result, expected,
"policy mismatch: had_shell={had_shell} has_ctx={has_ctx} is_win={is_win}"
);
}
}
// ── is_batch_shim extension tests ─────────────────────────────────────────
#[test]
fn batch_shim_cmd_lower() {
assert!(is_batch_shim(Path::new("claude.cmd")));
}
#[test]
fn batch_shim_cmd_upper() {
assert!(is_batch_shim(Path::new("claude.CMD")));
}
#[test]
fn batch_shim_bat_lower() {
assert!(is_batch_shim(Path::new("claude.bat")));
}
#[test]
fn batch_shim_bat_upper() {
assert!(is_batch_shim(Path::new("claude.BAT")));
}
#[test]
fn batch_shim_exe_not_shim() {
assert!(!is_batch_shim(Path::new("claude.exe")));
}
#[test]
fn batch_shim_no_extension_not_shim() {
assert!(!is_batch_shim(Path::new("claude")));
}
// ── should_skip_claude_executable policy tests ────────────────────────────
//
// Cross-host policy: shim + Windows → skip; shim + non-Windows → assign;
// non-shim either OS → assign. Mirrors the `should_use_inherited` pattern.
#[test]
fn skip_claude_executable_shim_windows_returns_true() {
assert!(
super::should_skip_claude_executable(Path::new("claude.cmd"), true),
"shim + windows=true must skip"
);
assert!(
super::should_skip_claude_executable(Path::new("claude.BAT"), true),
"shim + windows=true must skip"
);
}
#[test]
fn skip_claude_executable_shim_non_windows_returns_false() {
assert!(
!super::should_skip_claude_executable(Path::new("claude.cmd"), false),
"shim + windows=false must NOT skip (valid executable on non-Windows)"
);
assert!(
!super::should_skip_claude_executable(Path::new("claude.bat"), false),
"shim + windows=false must NOT skip"
);
}
#[test]
fn skip_claude_executable_exe_both_platforms_returns_false() {
assert!(
!super::should_skip_claude_executable(Path::new("claude.exe"), true),
"non-shim + windows=true must NOT skip"
);
assert!(
!super::should_skip_claude_executable(Path::new("claude.exe"), false),
"non-shim + windows=false must NOT skip"
);
}
#[test]
fn skip_claude_executable_no_ext_both_platforms_returns_false() {
assert!(
!super::should_skip_claude_executable(Path::new("claude"), true),
"no-ext + windows=true must NOT skip"
);
assert!(
!super::should_skip_claude_executable(Path::new("claude"), false),
"no-ext + windows=false must NOT skip"
);
}
}
@@ -618,6 +618,63 @@ fn codex_spawn_does_not_set_a_claude_executable() {
.any(|(key, _)| key == "CLAUDE_CODE_EXECUTABLE"));
}
/// On Windows, `.cmd` and `.bat` batch shims must NOT be assigned to
/// `CLAUDE_CODE_EXECUTABLE` — `CreateProcess` cannot exec them directly and
/// returns EINVAL (issue #2397). The adapter must fall back to its own PATH
/// lookup instead.
///
/// These tests exercise `is_batch_shim` directly — a pure path predicate with
/// no global PATH or resolve_command cache involvement — so they run on every
/// host and cannot be poisoned by the `claude_spawn_uses_the_probed_cli_executable`
/// test that runs before them.
#[test]
fn batch_shim_cmd_extension_is_rejected() {
assert!(
super::path::is_batch_shim(std::path::Path::new("claude.cmd")),
"claude.cmd must be identified as a batch shim"
);
}
#[test]
fn batch_shim_cmd_extension_uppercase_is_rejected() {
assert!(
super::path::is_batch_shim(std::path::Path::new("claude.CMD")),
"claude.CMD must be identified as a batch shim (case-insensitive)"
);
}
#[test]
fn batch_shim_bat_extension_is_rejected() {
assert!(
super::path::is_batch_shim(std::path::Path::new("claude.bat")),
"claude.bat must be identified as a batch shim"
);
}
#[test]
fn batch_shim_bat_extension_uppercase_is_rejected() {
assert!(
super::path::is_batch_shim(std::path::Path::new("claude.BAT")),
"claude.BAT must be identified as a batch shim (case-insensitive)"
);
}
#[test]
fn batch_shim_exe_extension_is_not_rejected() {
assert!(
!super::path::is_batch_shim(std::path::Path::new("claude.exe")),
"claude.exe must not be identified as a batch shim"
);
}
#[test]
fn batch_shim_no_extension_is_not_rejected() {
assert!(
!super::path::is_batch_shim(std::path::Path::new("claude")),
"claude (no extension) must not be identified as a batch shim"
);
}
// ── PGID-based orphan sweep tests ───────────────────────────────────────
/// Validates the kernel invariant that the orphan sweep PGID fix relies on:
+22
View File
@@ -198,6 +198,28 @@ pub(crate) fn replace_with_symlink(_src: &std::path::Path, _dst: &std::path::Pat
0
}
/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a GUI (non-console) parent.
///
/// On Windows, a GUI application (no console window of its own) that spawns a
/// child console-subsystem binary gets a fresh, briefly-visible console window
/// per child unless `CREATE_NO_WINDOW` is set. Setting it is a pure no-op on
/// non-Windows platforms, so callers can call this unconditionally.
///
/// **Exclusions**: any command that explicitly wants a visible terminal (e.g.
/// `launch_visible_terminal` which uses `CREATE_NEW_CONSOLE`) must NOT call
/// this helper — it would conflict with the explicit console-creation flag.
pub(crate) fn configure_no_window(command: &mut std::process::Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = command;
}
#[cfg(test)]
mod tests {
use super::slugify;
+1 -1
View File
@@ -1,7 +1,7 @@
name: buzz
description: Buzz mobile client
publish_to: 'none'
version: 0.4.11+1
version: 0.0.0+1
environment:
sdk: ^3.11.4
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'USAGE'
usage:
scripts/mobile-release.sh candidate X.Y.Z
candidate Publish the next immutable mobile-vX.Y.Z-rc.N candidate tag at the
exact current commit of block/buzz's remote main branch.
USAGE
exit 2
}
fail() {
echo "Error: $*" >&2
exit 1
}
# shellcheck source=scripts/release-rulesets.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
require_gh_minimum_version() {
local minimum="2.87.0" version
command -v gh >/dev/null 2>&1 || fail "gh >= $minimum is required"
version="$(gh --version 2>/dev/null | awk 'NR == 1 { print $3 }')" || \
fail "could not determine gh version (gh >= $minimum is required)"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
fail "gh returned invalid version '$version'"
if ! awk -v current="$version" -v minimum="$minimum" '
BEGIN {
split(current, c, ".")
split(minimum, m, ".")
for (i = 1; i <= 3; i++) {
if ((c[i] + 0) > (m[i] + 0)) exit 0
if ((c[i] + 0) < (m[i] + 0)) exit 1
}
exit 0
}
'; then
fail "gh $version is too old; gh >= $minimum is required"
fi
}
require_clean_semver() {
[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
fail "'$1' is not a mobile release version (expected X.Y.Z)"
}
require_clean_tree() {
git diff --quiet && git diff --cached --quiet && \
[[ -z "$(git status --short --untracked-files=normal)" ]] || \
fail "working tree is dirty; commit or stash changes first"
}
require_annotated_tag() {
local git_dir="$1" object="$2" label="$3"
if [[ "$(git -C "$git_dir" cat-file -t "$object" 2>/dev/null || true)" != "tag" ]]; then
echo "$label must be an annotated tag" >&2
return 1
fi
}
remote_tag_commit_sha() {
local ref="$1" line advertised_oid tmp fetched_oid commit
line="$(git ls-remote --refs origin "$ref")" || return 1
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
advertised_oid="${line%%$'\t'*}"
tmp="$(mktemp -d)"
git -C "$tmp" init -q
git -C "$tmp" remote add origin "$(git remote get-url origin)"
if ! git -C "$tmp" fetch -q --depth 1 origin "$ref"; then
rm -rf "$tmp"
return 1
fi
fetched_oid="$(git -C "$tmp" rev-parse --verify FETCH_HEAD)"
if [[ "$fetched_oid" != "$advertised_oid" ]]; then
rm -rf "$tmp"
fail "$ref moved while it was being resolved"
fi
if ! require_annotated_tag "$tmp" FETCH_HEAD "$ref"; then
rm -rf "$tmp"
return 1
fi
if ! commit="$(git -C "$tmp" rev-parse --verify 'FETCH_HEAD^{commit}')"; then
rm -rf "$tmp"
return 1
fi
rm -rf "$tmp"
printf '%s' "$commit"
}
remote_main_commit_sha() {
local ref="refs/heads/main" line advertised_oid fetched_oid commit
line="$(git ls-remote --refs origin "$ref")" || return 1
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
advertised_oid="${line%%$'\t'*}"
git fetch -q --no-tags origin "$ref"
fetched_oid="$(git rev-parse --verify FETCH_HEAD)"
[[ "$fetched_oid" == "$advertised_oid" ]] || \
fail "origin/main moved while it was being resolved"
commit="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || return 1
[[ "$commit" == "$advertised_oid" ]] || \
fail "origin/main did not resolve directly to a commit"
printf '%s' "$commit"
}
command="${1:-}"
case "$command" in
candidate)
[[ "$#" -eq 2 ]] || usage
version="$2"
require_clean_semver "$version"
require_clean_tree
require_canonical_repository || exit 1
require_gh_minimum_version
local_head_sha="$(git rev-parse --verify 'HEAD^{commit}')" || fail "HEAD is not a commit"
main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist"
next=1
if ! remote_tags="$(git ls-remote --refs --tags origin "refs/tags/mobile-v${version}-rc.*")"; then
fail "could not list existing candidates for $version"
fi
while IFS=$'\t' read -r _ ref; do
[[ "$ref" =~ ^refs/tags/mobile-v${version//./\.}-rc\.([1-9][0-9]*)$ ]] || continue
number="${BASH_REMATCH[1]}"
(( number >= next )) && next=$((number + 1))
done <<< "$remote_tags"
tag="mobile-v${version}-rc.${next}"
workflow="mobile-release-candidate.yml"
if dispatch_output="$(gh workflow run "$workflow" \
--repo block/buzz \
--ref main \
-f "version=$version" \
-f "candidate_number=$next" \
-f "target_sha=$main_sha" 2>&1)"; then
:
else
if [[ "$dispatch_output" == *"does not have 'workflow_dispatch' trigger"* ]]; then
fail "$workflow is not available on main yet; merge the release-process change before publishing a candidate"
fi
fail "could not dispatch App-backed publication for $tag: $dispatch_output"
fi
run_url="$(printf '%s\n' "$dispatch_output" | awk '/^https:\/\/github\.com\/block\/buzz\/actions\/runs\/[0-9]+$/ { if (found) exit 2; found = $0 } END { if (found) print found }')" || \
fail "GitHub returned multiple workflow run URLs for one candidate dispatch"
[[ -n "$run_url" ]] || \
fail "GitHub accepted the candidate dispatch but returned no workflow run URL"
run_id="${run_url##*/}"
gh run watch "$run_id" --repo block/buzz --exit-status --compact || \
fail "App-backed publication failed: $run_url"
current_main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist after publication"
[[ "$current_main_sha" == "$main_sha" ]] || \
fail "origin/main moved from requested commit $main_sha to $current_main_sha during publication"
published_sha="$(remote_tag_commit_sha "refs/tags/$tag")" || \
fail "publication completed without exact annotated candidate tag $tag"
[[ "$published_sha" == "$main_sha" ]] || \
fail "$tag resolved to $published_sha instead of requested commit $main_sha"
if [[ "$local_head_sha" != "$main_sha" ]]; then
echo "Note: local HEAD is $local_head_sha; candidate source is current origin/main $main_sha." >&2
fi
printf 'Published %s at origin/main commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \
"$tag" "$main_sha"
;;
*) usage ;;
esac
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
echo "Error: $*" >&2
exit 1
}
# shellcheck source=scripts/release-rulesets.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
[[ "$#" -eq 3 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA"
version="$1"
candidate_number="$2"
target_sha="$3"
repo="${GITHUB_REPOSITORY:-}"
[[ "$repo" == "block/buzz" ]] || fail "candidate publishing is restricted to block/buzz"
[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
fail "'$version' is not a mobile release version (expected X.Y.Z)"
[[ "$candidate_number" =~ ^[1-9][0-9]*$ ]] || \
fail "'$candidate_number' is not a candidate number (expected N >= 1)"
[[ "$target_sha" =~ ^[0-9a-f]{40}$ ]] || fail "'$target_sha' is not a full commit SHA"
command -v gh >/dev/null 2>&1 || fail "gh is required"
require_release_tag_ruleset || exit 1
main_sha="$(gh api "repos/$repo/git/ref/heads/main" --jq .object.sha)" || \
fail "could not resolve $repo main"
[[ "$main_sha" == "$target_sha" ]] || \
fail "$repo main moved from requested commit $target_sha to $main_sha"
commit_sha="$(gh api "repos/$repo/commits/$target_sha" --jq .sha)" || \
fail "$target_sha is not a commit in $repo"
[[ "$commit_sha" == "$target_sha" ]] || \
fail "GitHub resolved requested commit $target_sha as $commit_sha"
refs="$(
gh api --paginate "repos/$repo/git/matching-refs/tags/mobile-v${version}-rc." --jq '.[].ref'
)" || fail "could not list existing candidates for $version"
next=1
while IFS= read -r ref; do
[[ -n "$ref" ]] || continue
[[ "$ref" =~ ^refs/tags/mobile-v${version//./\.}-rc\.([1-9][0-9]*)$ ]] || continue
number="${BASH_REMATCH[1]}"
(( number >= next )) && next=$((number + 1))
done <<< "$refs"
[[ "$candidate_number" -eq "$next" ]] || \
fail "candidate sequence changed; expected rc.$candidate_number but next is rc.$next"
tag="mobile-v${version}-rc.${candidate_number}"
message="Buzz Mobile $version release candidate $candidate_number"
tag_object_sha="$(
gh api --method POST "repos/$repo/git/tags" \
-f tag="$tag" \
-f message="$message" \
-f object="$target_sha" \
-f type=commit \
--jq .sha
)" || fail "could not create annotated tag object for $tag"
[[ "$tag_object_sha" =~ ^[0-9a-f]{40}$ ]] || fail "GitHub returned an invalid tag object SHA"
gh api --method POST "repos/$repo/git/refs" \
-f ref="refs/tags/$tag" \
-f sha="$tag_object_sha" \
--silent || fail "could not publish $tag"
published_type="$(gh api "repos/$repo/git/ref/tags/$tag" --jq .object.type)" || \
fail "could not verify published tag $tag"
published_object="$(gh api "repos/$repo/git/ref/tags/$tag" --jq .object.sha)" || \
fail "could not verify published tag $tag"
[[ "$published_type" == "tag" && "$published_object" == "$tag_object_sha" ]] || \
fail "$tag does not reference the expected annotated tag object"
direct_type="$(gh api "repos/$repo/git/tags/$tag_object_sha" --jq .object.type)" || \
fail "could not verify annotated tag object $tag_object_sha"
direct_sha="$(gh api "repos/$repo/git/tags/$tag_object_sha" --jq .object.sha)" || \
fail "could not verify annotated tag object $tag_object_sha"
[[ "$direct_type" == "commit" && "$direct_sha" == "$target_sha" ]] || \
fail "$tag does not point directly to requested commit $target_sha"
printf 'Published %s at %s through buzz-release-bot.\n' "$tag" "$target_sha"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
readonly RELEASE_TAG_RULESET_ID=14378754
fail_release_ruleset() {
echo "Error: $*" >&2
return 1
}
require_canonical_repository() {
local origin_url
origin_url="$(git config --get remote.origin.url 2>/dev/null)" || \
fail_release_ruleset "origin is required and must point to block/buzz" || return 1
case "$origin_url" in
git@github.com:block/buzz.git|ssh://git@github.com/block/buzz.git|https://github.com/block/buzz.git|https://github.com/block/buzz)
;;
*)
fail_release_ruleset "origin must point to canonical block/buzz, not '$origin_url'" || return 1
;;
esac
}
require_release_tag_ruleset() {
local ruleset_endpoint state can_bypass bypass_actors rule_types includes excludes
command -v gh >/dev/null 2>&1 || fail_release_ruleset "gh is required" || return 1
ruleset_endpoint="repos/block/buzz/rulesets/$RELEASE_TAG_RULESET_ID"
state="$(gh api "$ruleset_endpoint" --jq .enforcement)" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID" || return 1
[[ "$state" == "active" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID is '$state'" || return 1
can_bypass="$(gh api "$ruleset_endpoint" --jq .current_user_can_bypass)" || \
fail_release_ruleset "could not verify the release App's tag-ruleset bypass" || return 1
[[ "$can_bypass" == "always" ]] || \
fail_release_ruleset "release App cannot always bypass Release tag ruleset $RELEASE_TAG_RULESET_ID (reported '$can_bypass')" || return 1
bypass_actors="$(gh api "$ruleset_endpoint" --jq '[.bypass_actors[] | [.actor_type, (.actor_id | tostring), .bypass_mode] | join(":")] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID bypass actors" || return 1
[[ "$bypass_actors" == "Integration:4349119:always" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected bypass actors: '$bypass_actors'" || return 1
rule_types="$(gh api "$ruleset_endpoint" --jq '[.rules[].type] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID rules" || return 1
[[ "$rule_types" == "creation,deletion,non_fast_forward,update" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected rules: '$rule_types'" || return 1
includes="$(gh api "$ruleset_endpoint" --jq '[.conditions.ref_name.include[]] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID scope" || return 1
[[ ",$includes," == *",refs/tags/mobile-v*,"* ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID does not include refs/tags/mobile-v*" || return 1
excludes="$(gh api "$ruleset_endpoint" --jq '[.conditions.ref_name.exclude[]] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID exclusions" || return 1
[[ -z "$excludes" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected exclusions: '$excludes'" || return 1
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
publisher="$repo_root/scripts/publish-mobile-release-candidate.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
bin="$tmp/bin"
mkdir -p "$bin"
cat > "$bin/gh" <<'GH'
#!/usr/bin/env bash
set -euo pipefail
record() {
printf '%s\n' "$*" >> "$GH_CALLS"
}
case "${1:-}:${2:-}" in
api:repos/block/buzz/rulesets/14378754)
case "$*" in
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
*'.current_user_can_bypass'*) printf '%s\n' "${GH_CURRENT_USER_CAN_BYPASS-always}" ;;
*'.bypass_actors[]'*) printf '%s\n' "${GH_BYPASS_ACTORS:-Integration:4349119:always}" ;;
*'[.rules[].type]'*) printf '%s\n' "${GH_TAG_RULE_TYPES:-creation,deletion,non_fast_forward,update}" ;;
*'.conditions.ref_name.include[]'*) printf '%s\n' "${GH_TAG_INCLUDES:-refs/tags/mobile-v*}" ;;
*'.conditions.ref_name.exclude[]'*) printf '%s\n' "${GH_TAG_EXCLUDES:-}" ;;
*) exit 2 ;;
esac
;;
api:repos/block/buzz/git/ref/heads/main) printf '%s\n' "$GH_TARGET_SHA" ;;
api:repos/block/buzz/commits/*) printf '%s\n' "$GH_TARGET_SHA" ;;
api:--paginate)
[[ "$3" == "repos/block/buzz/git/matching-refs/tags/mobile-v1.2.3-rc." ]]
printf '%s' "${GH_EXISTING_REFS:-}"
;;
api:--method)
endpoint="$4"
case "$endpoint" in
repos/block/buzz/git/tags)
record "$*"
printf '%s\n' "$GH_TAG_OBJECT_SHA"
;;
repos/block/buzz/git/refs)
record "$*"
;;
*) exit 2 ;;
esac
;;
api:repos/block/buzz/git/ref/tags/mobile-v1.2.3-rc.*)
if [[ "$*" == *'.object.type'* ]]; then
printf '%s\n' "${GH_PUBLISHED_REF_TYPE:-tag}"
else
printf '%s\n' "${GH_PUBLISHED_REF_SHA:-$GH_TAG_OBJECT_SHA}"
fi
;;
api:repos/block/buzz/git/tags/*)
if [[ "$*" == *'.object.type'* ]]; then
printf '%s\n' "${GH_ANNOTATED_TARGET_TYPE:-commit}"
else
printf '%s\n' "${GH_ANNOTATED_TARGET_SHA:-$GH_TARGET_SHA}"
fi
;;
*)
echo "unexpected gh call: $*" >&2
exit 2
;;
esac
GH
chmod +x "$bin/gh"
export PATH="$bin:$PATH"
export GH_CALLS="$tmp/calls"
export GITHUB_REPOSITORY=block/buzz
export GH_TARGET_SHA=1111111111111111111111111111111111111111
export GH_TAG_OBJECT_SHA=2222222222222222222222222222222222222222
"$publisher" 1.2.3 1 "$GH_TARGET_SHA"
grep -Fq -- '-f tag=mobile-v1.2.3-rc.1' "$GH_CALLS"
grep -Fq -- '-f message=Buzz Mobile 1.2.3 release candidate 1' "$GH_CALLS"
grep -Fq -- "-f object=$GH_TARGET_SHA" "$GH_CALLS"
grep -Fq -- '-f type=commit' "$GH_CALLS"
grep -Fq -- '-f ref=refs/tags/mobile-v1.2.3-rc.1' "$GH_CALLS"
grep -Fq -- "-f sha=$GH_TAG_OBJECT_SHA" "$GH_CALLS"
if GH_CURRENT_USER_CAN_BYPASS=never "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted an App token without an always bypass" >&2
exit 1
fi
if GH_CURRENT_USER_CAN_BYPASS='' "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a ruleset response without an effective bypass" >&2
exit 1
fi
if GH_BYPASS_ACTORS='Integration:4349119:always,Integration:9876543:always' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted an extra ruleset bypass actor" >&2
exit 1
fi
if GH_BYPASS_ACTORS='Integration:9876543:always' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a substituted ruleset bypass actor" >&2
exit 1
fi
if GH_BYPASS_ACTORS='Integration:4349119:pull_request' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a ruleset bypass actor with the wrong mode" >&2
exit 1
fi
if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted disabled tag protection" >&2
exit 1
fi
if GH_TAG_RULE_TYPES=creation "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted incomplete tag protection" >&2
exit 1
fi
if GH_TAG_INCLUDES=refs/tags/v\* "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a tag ruleset that excludes mobile candidates" >&2
exit 1
fi
if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted tag ruleset exclusions" >&2
exit 1
fi
if GH_TARGET_SHA=3333333333333333333333333333333333333333 \
"$publisher" 1.2.3 1 1111111111111111111111111111111111111111 >/dev/null 2>&1; then
echo "publisher accepted a moved main branch" >&2
exit 1
fi
if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\n' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a stale candidate number" >&2
exit 1
fi
if GH_PUBLISHED_REF_TYPE=commit "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a lightweight published tag" >&2
exit 1
fi
if GH_PUBLISHED_REF_SHA=3333333333333333333333333333333333333333 \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted the wrong annotated tag object" >&2
exit 1
fi
if GH_ANNOTATED_TARGET_TYPE=tag "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a nested annotated tag" >&2
exit 1
fi
if GH_ANNOTATED_TARGET_SHA=3333333333333333333333333333333333333333 \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted an annotated tag on the wrong commit" >&2
exit 1
fi
if "$publisher" 01.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a marketing version with a leading zero" >&2
exit 1
fi
if "$publisher" 1.2.3 01 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a candidate number with a leading zero" >&2
exit 1
fi
if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\nrefs/tags/mobile-v1.2.3-rc.7\nrefs/tags/mobile-v1.2.3-rc.08\nrefs/tags/mobile-v1.2.4-rc.99\n' \
"$publisher" 1.2.3 8 "$GH_TARGET_SHA" >/dev/null; then
:
else
echo "publisher did not sequence from the highest exact candidate" >&2
exit 1
fi
if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted the wrong repository" >&2
exit 1
fi
echo "mobile release candidate publisher contract passed"
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/mobile-release.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
remote="$tmp/remote.git"
work="$tmp/work"
operator="$tmp/operator"
bin="$tmp/bin"
canonical_origin="git@github.com:block/buzz.git"
mkdir -p "$bin"
cat > "$bin/gh" <<'GH'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
--version:*) printf 'gh version %s (test)\n' "${GH_VERSION:-2.94.0}" ;;
api:repos/block/buzz/rulesets/14378754)
case "$*" in
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
*'.current_user_can_bypass'*) printf '%s\n' "${GH_CURRENT_USER_CAN_BYPASS-always}" ;;
*'[.rules[].type]'*) printf '%s\n' "${GH_TAG_RULE_TYPES:-creation,deletion,non_fast_forward,update}" ;;
*'.conditions.ref_name.include[]'*) printf '%s\n' "${GH_TAG_INCLUDES:-refs/tags/mobile-v*}" ;;
*'.conditions.ref_name.exclude[]'*) printf '%s\n' "${GH_TAG_EXCLUDES:-}" ;;
*) exit 2 ;;
esac
;;
workflow:run)
if [[ "${GH_WORKFLOW_DISPATCH_FAIL:-}" == "1" ]]; then
printf '%s\n' "${GH_WORKFLOW_DISPATCH_ERROR:-dispatch failed}" >&2
exit 1
fi
if [[ "${GH_WORKFLOW_WRONG_URL:-}" == "1" ]]; then
printf '%s\n' 'https://github.com/attacker/buzz/actions/runs/999'
exit 0
fi
if [[ "${GH_WORKFLOW_EXTRA_URL:-}" == "1" ]]; then
printf '%s\n' 'https://github.com/block/buzz/actions/runs/998'
fi
version=""
number=""
sha=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
version=*) version="${1#version=}" ;;
candidate_number=*) number="${1#candidate_number=}" ;;
target_sha=*) sha="${1#target_sha=}" ;;
esac
shift
done
[[ -n "$version" && -n "$number" && -n "$sha" ]]
printf '%s\t%s\t%s\n' "$version" "$number" "$sha" >> "$GH_WORKFLOW_CAPTURE"
if [[ "${GH_WORKFLOW_NO_URL:-}" == "1" ]]; then
exit 0
fi
printf 'https://github.com/block/buzz/actions/runs/%s\n' "$number"
;;
run:watch)
[[ "${GH_WORKFLOW_FAIL:-}" != "1" ]] || exit 1
number="$3"
IFS=$'\t' read -r version expected sha < <(tail -n 1 "$GH_WORKFLOW_CAPTURE")
[[ "$number" == "$expected" ]]
if [[ "${GH_MAIN_MOVE_DURING_WORKFLOW:-}" == "1" ]]; then
printf '%s\n' moved >> "$GH_WORKTREE/file"
git -C "$GH_WORKTREE" commit -qam moved-during-publication
git -C "$GH_WORKTREE" push -q origin main
fi
if [[ "${GH_TAG_VERIFY_LIGHTWEIGHT:-}" == "1" ]]; then
git -C "$GH_WORKTREE" -c tag.gpgSign=false tag \
"mobile-v${version}-rc.${expected}" "$sha"
else
git -C "$GH_WORKTREE" -c tag.gpgSign=false tag -a \
-m "Buzz Mobile $version release candidate $expected" \
"mobile-v${version}-rc.${expected}" "$sha"
fi
git -C "$GH_WORKTREE" -c core.hooksPath=/dev/null push -q \
origin "refs/tags/mobile-v${version}-rc.${expected}"
;;
*) exit 2 ;;
esac
GH
chmod +x "$bin/gh"
export PATH="$bin:$PATH"
export GH_WORKFLOW_CAPTURE="$tmp/workflow-dispatches"
export GH_WORKTREE="$work"
run_release() {
local repo="$1"
shift
(
cd "$repo"
git config "url.file://$remote.insteadOf" "$canonical_origin"
git config protocol.file.allow always
"$script" "$@"
)
}
fail() {
echo "$*" >&2
exit 1
}
assert_no_removed_mobile_release_behavior() {
local status
grep -Eq 'gh[[:space:]]+release|mobile-release/|finalize' "$@" && \
fail "removed branch/finalization/GitHub Release behavior remains"
status="$?"
[[ "$status" -eq 1 ]] || \
fail "could not scan for removed branch/finalization/GitHub Release behavior"
}
git init -q --bare "$remote"
git init -q "$work"
git -C "$work" config user.name test
git -C "$work" config user.email test@example.com
git -C "$work" remote add origin "$canonical_origin"
git -C "$work" config "url.file://$remote.insteadOf" "$canonical_origin"
git -C "$work" config protocol.file.allow always
echo first > "$work/file"
git -C "$work" add file
git -C "$work" commit -qm first
git -C "$work" branch -M main
git --git-dir="$remote" symbolic-ref HEAD refs/heads/main
git -C "$work" push -q -u origin main
# Candidate publication must work from a stale operator clone, warn about the
# stale checkout, and target the exact current remote main commit.
git -c "url.file://$remote.insteadOf=$canonical_origin" \
-c protocol.file.allow=always clone -q "$canonical_origin" "$operator"
git -C "$operator" config user.name test
git -C "$operator" config user.email test@example.com
echo remote-only >> "$work/file"
git -C "$work" commit -qam remote-only
git -C "$work" push -q origin main
remote_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
if git -C "$operator" cat-file -e "$remote_main_sha^{commit}" 2>/dev/null; then
fail "stale-clone fixture already contains the remote-only commit"
fi
run_release "$operator" candidate 1.2.3 > "$tmp/stale-output" 2> "$tmp/stale-error"
grep -Fq "Note: local HEAD is $(git -C "$operator" rev-parse HEAD); candidate source is current origin/main $remote_main_sha." \
"$tmp/stale-error"
cat "$tmp/stale-output"
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.1^{commit}')" == \
"$remote_main_sha" ]]
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v1.2.3-rc.1)" == tag ]]
grep -Fq $'1.2.3\t1\t' "$GH_WORKFLOW_CAPTURE"
# Existing remote identities remain unchanged. Later candidates sequence
# monotonically and target the then-current remote main commit.
rc1_tag_oid="$(git --git-dir="$remote" rev-parse refs/tags/mobile-v1.2.3-rc.1)"
echo newer >> "$work/file"
git -C "$work" commit -qam newer
git -C "$work" push -q origin main
new_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
run_release "$operator" candidate 1.2.3
[[ "$(git --git-dir="$remote" rev-parse refs/tags/mobile-v1.2.3-rc.1)" == "$rc1_tag_oid" ]]
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.2^{commit}')" == \
"$new_main_sha" ]]
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v1.2.3-rc.2)" == tag ]]
grep -Fq $'1.2.3\t2\t' "$GH_WORKFLOW_CAPTURE"
# Sequence from the highest exact remote RC even if there are gaps, and ignore
# malformed or other-version tags.
git -C "$work" -c tag.gpgSign=false tag -a -m gap mobile-v1.2.3-rc.7 "$new_main_sha"
git -C "$work" -c tag.gpgSign=false tag -a -m malformed mobile-v1.2.3-rc.08 "$new_main_sha"
git -C "$work" -c tag.gpgSign=false tag -a -m other mobile-v1.2.4-rc.99 "$new_main_sha"
git -C "$work" push -q origin \
refs/tags/mobile-v1.2.3-rc.7 refs/tags/mobile-v1.2.3-rc.08 \
refs/tags/mobile-v1.2.4-rc.99
run_release "$operator" candidate 1.2.3
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.8^{commit}')" == \
"$new_main_sha" ]]
# Failed or unattributable App-backed publication fails closed without creating
# the expected candidate tag.
if GH_WORKFLOW_DISPATCH_FAIL=1 run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded despite a rejected workflow dispatch"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.7-rc.1; then
fail "rejected workflow dispatch created a candidate tag"
fi
if GH_WORKFLOW_DISPATCH_FAIL=1 \
GH_WORKFLOW_DISPATCH_ERROR="does not have 'workflow_dispatch' trigger" \
run_release "$operator" candidate 9.9.6 > "$tmp/missing-workflow-output" 2>&1; then
fail "candidate succeeded without the publication workflow on main"
fi
grep -Fq 'merge the release-process change before publishing a candidate' \
"$tmp/missing-workflow-output"
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.6-rc.1; then
fail "missing publication workflow created a candidate tag"
fi
if GH_WORKFLOW_FAIL=1 run_release "$operator" candidate 9.9.9 >/dev/null 2>&1; then
fail "candidate succeeded despite a failed App-backed workflow"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.9-rc.1; then
fail "failed App-backed workflow created a candidate tag"
fi
if GH_WORKFLOW_NO_URL=1 run_release "$operator" candidate 9.9.8 >/dev/null 2>&1; then
fail "candidate succeeded without a workflow run URL"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.8-rc.1; then
fail "URL-less dispatch created a candidate tag"
fi
if GH_WORKFLOW_WRONG_URL=1 run_release "$operator" candidate 9.9.5 >/dev/null 2>&1; then
fail "candidate accepted a workflow run URL from another repository"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.5-rc.1; then
fail "wrong-repository workflow URL created a candidate tag"
fi
if GH_WORKFLOW_EXTRA_URL=1 run_release "$operator" candidate 9.9.4 >/dev/null 2>&1; then
fail "candidate accepted multiple workflow run URLs"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.4-rc.1; then
fail "ambiguous workflow URLs created a candidate tag"
fi
if GH_TAG_VERIFY_LIGHTWEIGHT=1 run_release "$operator" candidate 9.9.2 >/dev/null 2>&1; then
fail "candidate accepted a lightweight published tag"
fi
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.2-rc.1)" == commit ]]
# The publisher and operator both reject a main-tip race. The immutable tag may
# already exist at the prior tip, but the operator must not report it as a
# current-main candidate.
pre_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
if GH_MAIN_MOVE_DURING_WORKFLOW=1 run_release "$operator" candidate 9.9.3 > "$tmp/main-race-output" 2>&1; then
fail "candidate succeeded after main moved during publication"
fi
post_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
[[ "$pre_race_main_sha" != "$post_race_main_sha" ]]
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v9.9.3-rc.1^{commit}')" == \
"$pre_race_main_sha" ]]
grep -Fq "origin/main moved from requested commit $pre_race_main_sha to $post_race_main_sha during publication" \
"$tmp/main-race-output"
# Publishing through a fork is rejected and unsupported gh versions fail before
# dispatching any candidate publication.
fork_operator="$tmp/fork-operator"
git clone -q "$remote" "$fork_operator"
git -C "$fork_operator" config user.name test
git -C "$fork_operator" config user.email test@example.com
if (cd "$fork_operator" && "$script" candidate 2.0.0 >/dev/null 2>&1); then
fail "noncanonical origin was accepted"
fi
before_dispatches="$(wc -l < "$GH_WORKFLOW_CAPTURE")"
if GH_VERSION=2.86.0 run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
fail "gh older than 2.87.0 was accepted"
fi
if GH_VERSION=2.9.0 run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
fail "numeric gh version comparison accepted 2.9.0 as at least 2.87.0"
fi
[[ "$(wc -l < "$GH_WORKFLOW_CAPTURE")" == "$before_dispatches" ]]
# Dirty trees and invalid marketing versions fail before publication.
echo dirty > "$operator/untracked"
if run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
fail "dirty operator tree was accepted"
fi
rm "$operator/untracked"
if run_release "$operator" candidate 1.2 >/dev/null 2>&1; then
fail "invalid marketing version was accepted"
fi
if run_release "$operator" candidate 01.2.3 >/dev/null 2>&1; then
fail "marketing version with a leading zero was accepted"
fi
# Mobile no longer has release branches, finalization, a stable alias, or a
# GitHub Release call. Publication remains App-backed because the strict tag
# ruleset denies direct human creation.
if run_release "$operator" start 2.0.0 >/dev/null 2>&1; then
fail "removed start command was accepted"
fi
if run_release "$operator" finalize 1.2.3-rc.2 >/dev/null 2>&1; then
fail "removed finalize command was accepted"
fi
if git --git-dir="$remote" for-each-ref --format='%(refname)' refs/heads/mobile-release/ | grep -q .; then
fail "mobile release branch was created"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v1.2.3; then
fail "stable mobile tag alias was created"
fi
assert_no_removed_mobile_release_behavior \
"$repo_root/scripts/mobile-release.sh" \
"$repo_root/scripts/publish-mobile-release-candidate.sh" \
"$repo_root/.github/workflows/mobile-release-candidate.yml"
# Prove the negative contract itself is discriminating rather than merely
# accepting a missing or broken search tool as "not found."
printf '%s\n' 'gh release create forbidden' > "$tmp/forbidden-mobile-release-behavior"
if (assert_no_removed_mobile_release_behavior "$tmp/forbidden-mobile-release-behavior") \
>/dev/null 2>&1; then
fail "removed-behavior assertion did not reject a forbidden GitHub Release call"
fi
grep -Fq 'version: 0.0.0+1' "$repo_root/mobile/pubspec.yaml"
if grep -qE 'release-mobile|bump-mobile-version|get-current-mobile-version' "$repo_root/Justfile"; then
fail "metadata-only mobile release recipe remains in Justfile"
fi
echo "mobile release contract passed"
+2 -2
View File
@@ -12,7 +12,7 @@ git -C "$tmp" config user.email test@example.com
echo first >"$tmp/file"
git -C "$tmp" add file
git -C "$tmp" commit -qm first
git -C "$tmp" tag v1.2.3
git -C "$tmp" tag -m "desktop release" v1.2.3
(
cd "$tmp"
@@ -37,7 +37,7 @@ if (
exit 1
fi
git -C "$tmp" tag relay-v2.0.0
git -C "$tmp" tag -m "relay release" relay-v2.0.0
(
cd "$tmp"
GITHUB_REF=refs/tags/relay-v2.0.0 "$verify" relay-v 2.0.0