mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge remote-tracking branch 'origin/main' into kennylopez-dictation
This commit is contained in:
@@ -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}')"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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().
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+169
@@ -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
|
||||
Executable
+80
@@ -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"
|
||||
Executable
+59
@@ -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
@@ -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"
|
||||
Executable
+301
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user