feat(release): support version-matched mobile stable branches (#8430)

Mobile candidates currently require the current `main` commit, so a
stable release branch cannot publish through the existing protected tag
workflow.

Add optional `--source-branch mobile-vMAJOR.MINOR` selection while
preserving `main` as the default. Both the command and App-backed
publisher reject a stable branch whose major/minor differs from the
requested version. Candidates retain exact-tip checks and immutable
annotated tags; the private build pipeline is unchanged.

Document the temporary stable-only distribution policy and explicit
per-fix backport decisions. No candidate, build, or upload is triggered
by this change. No duplicate implementation was found among open release
PRs.

Stable publication now checks active review and status-check protection
in both the operator and publisher. The process permits approved
backport PRs and stable-only corrections for regressions caused by
earlier backports. Administrator activation and verification of the
no-bypass ruleset remain outstanding; non-admin API responses hide the
bypass actor list.

### Validation

- Full local `just ci` passed during preparation; the final changes
affect shell release tooling, its workflow inputs, CI path detection,
and documentation.
- Publisher contract passed, including version-matched stable sources
and mismatched branch rejection.
- Operator contract covers default main, a divergent stable commit,
missing or mismatched sources, immutable sequencing, and publication
races. The final operator contract run passed.


- Updated publisher and operator contracts pass, including 17
weakened-policy cases and rejection of unavailable protection. The live,
currently unprotected stable branch is rejected.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 18:04:04 -07:00
committed by GitHub
parent 8f2c7710e9
commit 30ca980b49
9 changed files with 291 additions and 59 deletions

View File

@@ -91,6 +91,7 @@ jobs:
- 'Justfile'
- 'scripts/test-mobile-gateway-recipes.sh'
- 'scripts/mobile-release.sh'
- 'scripts/mobile-release-source.sh'
- 'scripts/mobile-worktree-overrides.sh'
- 'scripts/mobile-worktree-clean.sh'
- 'scripts/publish-mobile-release-candidate.sh'

View File

@@ -12,8 +12,13 @@ on:
description: Expected next release-candidate number
required: true
type: string
source_branch:
description: Source branch (main or mobile-vMAJOR.MINOR matching version)
required: true
default: main
type: string
target_sha:
description: Exact current block/buzz main commit
description: Exact current source branch commit
required: true
type: string
@@ -65,5 +70,6 @@ jobs:
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
MOBILE_VERSION: ${{ inputs.version }}
CANDIDATE_NUMBER: ${{ inputs.candidate_number }}
SOURCE_BRANCH: ${{ inputs.source_branch }}
TARGET_SHA: ${{ inputs.target_sha }}
run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA"
run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA" "$SOURCE_BRANCH"

View File

@@ -1,7 +1,8 @@
# Releasing Buzz
Buzz has three independent release lanes. Desktop and relay use release PRs.
Mobile uses immutable release-candidate tags cut directly from remote `main`:
Mobile uses immutable release-candidate tags cut from remote `main` by default,
with an optional stable source branch:
| Lane | Entry point | Artifact |
|------|-------------|----------|
@@ -38,7 +39,8 @@ scripts/mobile-release.sh candidate 0.5.0
Desktop uses an immutable generated candidate PR; relay continues using its
metadata PR. 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,
Optional stable branches use `mobile-vMAJOR.MINOR`. There is no
stable mobile tag alias, finalization step,
or mobile GitHub Release.
---
@@ -153,10 +155,47 @@ RC tags. For example, iOS can ship `mobile-v0.5.0-rc.2` while Android ships
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`.
### Optional stable release branches
For a release that needs isolation from ongoing development, create a branch
such as `mobile-v0.25` from an explicitly selected, verified source commit.
Publish candidates from its current remote tip:
```bash
scripts/mobile-release.sh candidate 0.25.0 --source-branch mobile-v0.25
```
The branch must match the marketing version's major and minor components:
`mobile-v0.25` accepts 0.25.x, but rejects 0.26.x and 1.25.x. Without the option,
the source remains `main`. Both the operator and publisher validate the source;
a missing branch or changed tip fails rather than falling back to `main`.
The GitHub workflow itself continues to run from reviewed `main`. The private
pipeline still builds only the resulting immutable candidate tag.
For the October 2026 stabilization period, all distributed mobile builds,
including Company Portal dogfood builds, must come from `mobile-v0.25` until
after the conference. Do not upload builds from `main` or a later minor version
that would displace the stable dogfood build. The branch starts from
`mobile-v0.24.0-rc.2`, the source of uploaded iOS 0.24.0 (7).
General fix PRs must target `main` and land there first. At each fix landing,
ask the release owner whether to
backport the merged commit to the stable branch. For each explicitly approved
fix, cherry-pick with `-x --signoff` onto a backport branch and open a backport-only
PR targeting the stable branch. Require review and passing checks before merging
that PR. Preserve the original commit provenance and validate the resulting
stable source. A correction for a regression introduced by an earlier backport
may target the stable branch directly; identify the faulty backport and keep the
correction scoped to that regression. The same review and checks apply.
Never merge `main` wholesale.
Remote agents and App Clip are post-conference work. There is no preselected
fix list and no requirement to create a stable branch for every minor release.
The initial 0.25.0 candidate uses an empty commit above that baseline to verify
that the existing pipeline builds a commit outside `main`. Verify identical
source trees, absence of that commit from remote `main`, and the exact tag,
commit, marketing version and build number in the uploaded artifacts. Upload
and external TestFlight review do not establish production App Store approval.
`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
@@ -317,8 +356,15 @@ 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 the App token's effective
require GitHub Releases permissions or repository Administration permission.
Stable sources additionally require active branch ruleset `19321162` covering
`refs/heads/mobile-v*`, with no exclusions or bypass actors, deletion and
force-push prevention, at least one approval, stale-approval dismissal, last-push
approval, resolved review threads, and strict required `Mobile` and `DCO Check`
checks. An administrator must configure and audit the empty bypass list; GitHub
hides that list from non-admin tokens. Both operator and publisher verify the
public policy and their effective lack of branch bypass on every publication.
The publisher validates the App token's effective
`current_user_can_bypass` value rather than reading the ruleset's hidden bypass
actor list.
@@ -343,18 +389,19 @@ Commit or stash your changes before running the release recipe.
### New commits land after publishing a mobile candidate
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
fix reaches the intended remote source branch. Use the same `--source-branch`
option for a stable release. The command 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
### `scripts/mobile-release.sh candidate` fails because the source branch 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
at the prior source branch tip before the operator command detects the race. Do not
move or delete that tag, and do not treat it as the candidate for the current
source branch. 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.
new current source branch tip, preserving the source-branch option.
### A mobile candidate command selects the wrong RC number

View File

@@ -217,6 +217,11 @@ const scenarios = [
["mobile"],
],
["mobile lockfile", ["mobile/pubspec.lock"], ["mobile"]],
[
"mobile release source helper",
["scripts/mobile-release-source.sh"],
["mobile"],
],
["mobile release script", ["scripts/mobile-release.sh"], ["mobile"]],
[
"mobile gateway recipe tests",

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Validate an explicitly selected stable branch against the marketing version.
# main remains the default for releases without a stabilization branch.
require_mobile_release_source() {
local version="$1" branch="$2"
if [[ "$branch" == main ]]; then
return 0
fi
if [[ "$branch" != "mobile-v${version%.*}" ]]; then
echo "Source branch must be main or mobile-v${version%.*} for version $version" >&2
return 1
fi
}
# The repository administrator must configure this ruleset without bypass actors.
# GitHub hides that list from non-admin tokens; check the caller's effective
# bypass plus every public policy field on each publication.
require_mobile_release_branch_ruleset() {
local branch="$1" valid
[[ "$branch" != main ]] || return 0
valid="$(gh api repos/block/buzz/rulesets/19321162 --jq '
.target == "branch" and .enforcement == "active"
and .current_user_can_bypass == "never"
and (.conditions.ref_name.include | index("refs/heads/mobile-v*") != null)
and (.conditions.ref_name.exclude == [])
and ((has("bypass_actors") | not) or .bypass_actors == [])
and any(.rules[]; .type == "deletion")
and any(.rules[]; .type == "non_fast_forward")
and any(.rules[]; .type == "pull_request"
and .parameters.required_approving_review_count >= 1
and .parameters.dismiss_stale_reviews_on_push == true
and .parameters.require_last_push_approval == true
and .parameters.required_review_thread_resolution == true)
and any(.rules[]; .type == "required_status_checks"
and .parameters.strict_required_status_checks_policy == true
and ([.parameters.required_status_checks[].context] | index("Mobile") != null)
and ([.parameters.required_status_checks[].context] | index("DCO Check") != null))
')" || { echo "Could not verify mobile stable-branch protection" >&2; return 1; }
[[ "$valid" == true ]] || {
echo "Mobile stable-branch ruleset 19321162 must enforce review and checks on mobile-v* without bypass" >&2
return 1
}
}

View File

@@ -4,10 +4,10 @@ set -euo pipefail
usage() {
cat >&2 <<'USAGE'
usage:
scripts/mobile-release.sh candidate X.Y.Z
scripts/mobile-release.sh candidate X.Y.Z [--source-branch mobile-vX.Y]
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.
exact current remote source commit (main unless --source-branch is set).
USAGE
exit 2
}
@@ -19,6 +19,8 @@ fail() {
# shellcheck source=scripts/release-rulesets.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
# shellcheck source=scripts/mobile-release-source.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mobile-release-source.sh"
require_gh_minimum_version() {
local minimum="2.87.0" version
@@ -91,33 +93,40 @@ remote_tag_commit_sha() {
printf '%s' "$commit"
}
remote_main_commit_sha() {
local ref="refs/heads/main" line advertised_oid fetched_oid commit
remote_source_commit_sha() {
local ref="refs/heads/$MOBILE_RELEASE_SOURCE_BRANCH" 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"
fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH 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"
fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH did not resolve directly to a commit"
printf '%s' "$commit"
}
command="${1:-}"
case "$command" in
candidate)
[[ "$#" -eq 2 ]] || usage
[[ "$#" -eq 2 || "$#" -eq 4 ]] || usage
version="$2"
MOBILE_RELEASE_SOURCE_BRANCH=main
if [[ "$#" -eq 4 ]]; then
[[ "$3" == --source-branch ]] || usage
MOBILE_RELEASE_SOURCE_BRANCH="$4"
fi
require_clean_semver "$version"
require_mobile_release_source "$version" "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1
require_clean_tree
require_canonical_repository || exit 1
require_gh_minimum_version
require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1
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"
source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH 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"
@@ -135,7 +144,8 @@ case "$command" in
--ref main \
-f "version=$version" \
-f "candidate_number=$next" \
-f "target_sha=$main_sha" 2>&1)"; then
-f "source_branch=$MOBILE_RELEASE_SOURCE_BRANCH" \
-f "target_sha=$source_sha" 2>&1)"; then
:
else
if [[ "$dispatch_output" == *"does not have 'workflow_dispatch' trigger"* ]]; then
@@ -151,18 +161,18 @@ case "$command" in
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"
current_source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist after publication"
[[ "$current_source_sha" == "$source_sha" ]] || \
fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $source_sha to $current_source_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
[[ "$published_sha" == "$source_sha" ]] || \
fail "$tag resolved to $published_sha instead of requested commit $source_sha"
if [[ "$local_head_sha" != "$source_sha" ]]; then
echo "Note: local HEAD is $local_head_sha; candidate source is current origin/$MOBILE_RELEASE_SOURCE_BRANCH $source_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"
printf 'Published %s at origin/%s commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \
"$tag" "$MOBILE_RELEASE_SOURCE_BRANCH" "$source_sha"
;;
*) usage ;;

View File

@@ -8,11 +8,14 @@ fail() {
# shellcheck source=scripts/release-rulesets.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
# shellcheck source=scripts/mobile-release-source.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mobile-release-source.sh"
[[ "$#" -eq 3 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA"
[[ "$#" -eq 4 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA SOURCE_BRANCH"
version="$1"
candidate_number="$2"
target_sha="$3"
MOBILE_RELEASE_SOURCE_BRANCH="$4"
repo="${GITHUB_REPOSITORY:-}"
[[ "$repo" == "block/buzz" ]] || fail "candidate publishing is restricted to block/buzz"
@@ -21,13 +24,15 @@ repo="${GITHUB_REPOSITORY:-}"
[[ "$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"
require_mobile_release_source "$version" "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1
command -v gh >/dev/null 2>&1 || fail "gh is required"
require_release_tag_ruleset || exit 1
require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || 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"
source_sha="$(gh api "repos/$repo/git/ref/heads/$MOBILE_RELEASE_SOURCE_BRANCH" --jq .object.sha)" || \
fail "could not resolve $repo $MOBILE_RELEASE_SOURCE_BRANCH"
[[ "$source_sha" == "$target_sha" ]] || \
fail "$repo $MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $target_sha to $source_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" ]] || \

View File

@@ -17,6 +17,11 @@ record() {
}
case "${1:-}:${2:-}" in
api:repos/block/buzz/rulesets/19321162)
[[ "${GH_BRANCH_POLICY_FAIL:-}" != 1 ]] || exit 1
policy='{"target":"branch","enforcement":"active","current_user_can_bypass":"never","conditions":{"ref_name":{"include":["refs/heads/mobile-v*"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true,"require_last_push_approval":true,"required_review_thread_resolution":true}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Mobile"},{"context":"DCO Check"}]}}]}'
printf '%s\n' "${GH_BRANCH_POLICY:-$policy}" | jq -r "$4"
;;
api:repos/block/buzz/rulesets/14378754)
case "$*" in
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
@@ -28,7 +33,9 @@ case "${1:-}:${2:-}" in
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:repos/block/buzz/git/ref/heads/mobile-v1.2) printf '%s\n' "$GH_STABLE_SHA" ;;
api:repos/block/buzz/git/ref/heads/*) printf '%s\n' "$GH_MISMATCH_SHA" ;;
api:repos/block/buzz/commits/*) printf '%s\n' "${2##*/}" ;;
api:--paginate)
[[ "$3" == "repos/block/buzz/git/matching-refs/tags/mobile-v1.2.3-rc." ]]
printf '%s' "${GH_EXISTING_REFS:-}"
@@ -72,9 +79,11 @@ export PATH="$bin:$PATH"
export GH_CALLS="$tmp/calls"
export GITHUB_REPOSITORY=block/buzz
export GH_TARGET_SHA=1111111111111111111111111111111111111111
export GH_STABLE_SHA=4444444444444444444444444444444444444444
export GH_MISMATCH_SHA=5555555555555555555555555555555555555555
export GH_TAG_OBJECT_SHA=2222222222222222222222222222222222222222
"$publisher" 1.2.3 1 "$GH_TARGET_SHA"
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main
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"
@@ -82,76 +91,141 @@ 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
if GH_CURRENT_USER_CAN_BYPASS=never "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
if GH_CURRENT_USER_CAN_BYPASS='' "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a ruleset response without an effective bypass" >&2
exit 1
fi
if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
if GH_TAG_RULE_TYPES=creation "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
if GH_TAG_INCLUDES=refs/tags/v\* "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
"$publisher" 1.2.3 1 1111111111111111111111111111111111111111 main >/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
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
if GH_PUBLISHED_REF_TYPE=commit "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
if GH_ANNOTATED_TARGET_TYPE=tag "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/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
if "$publisher" 01.2.3 1 "$GH_TARGET_SHA" main >/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
if "$publisher" 1.2.3 01 "$GH_TARGET_SHA" main >/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
"$publisher" 1.2.3 8 "$GH_TARGET_SHA" main >/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
if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted the wrong repository" >&2
exit 1
fi
GH_ANNOTATED_TARGET_SHA="$GH_STABLE_SHA" "$publisher" 1.2.3 1 "$GH_STABLE_SHA" mobile-v1.2
for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary ''; do
if GH_ANNOTATED_TARGET_SHA="$GH_MISMATCH_SHA" "$publisher" 1.2.3 1 "$GH_MISMATCH_SHA" "$source" >/dev/null 2>&1; then
echo "publisher accepted mismatched source '$source'" >&2
exit 1
fi
done
# Mutate actual policy JSON so these tests exercise the production jq predicate.
python3 - "$publisher" <<'PYPOLICY'
import copy, json, os, subprocess, sys
policy = {'target': 'branch', 'enforcement': 'active', 'current_user_can_bypass': 'never', 'conditions': {'ref_name': {'include': ['refs/heads/mobile-v*'], 'exclude': []}}, 'bypass_actors': [], 'rules': [{'type': 'deletion'}, {'type': 'non_fast_forward'}, {'type': 'pull_request', 'parameters': {'required_approving_review_count': 1, 'dismiss_stale_reviews_on_push': True, 'require_last_push_approval': True, 'required_review_thread_resolution': True}}, {'type': 'required_status_checks', 'parameters': {'strict_required_status_checks_policy': True, 'required_status_checks': [{'context': 'Mobile'}, {'context': 'DCO Check'}]}}]}
mutations = {
"disabled": lambda p: p.update(enforcement="disabled"),
"wrong target": lambda p: p.update(target="tag"),
"wrong scope": lambda p: p["conditions"]["ref_name"].update(include=["refs/heads/mobile-release/*"]),
"excluded branch": lambda p: p["conditions"]["ref_name"].update(exclude=["refs/heads/mobile-v1.2"]),
"caller bypass": lambda p: p.update(current_user_can_bypass="always"),
"other bypass": lambda p: p.update(bypass_actors=[{"actor_id":1}]),
"no deletion guard": lambda p: p["rules"].pop(0),
"no force guard": lambda p: p["rules"].pop(1),
"no review": lambda p: p["rules"].pop(2),
"no checks": lambda p: p["rules"].pop(3),
"no approvals": lambda p: p["rules"][2]["parameters"].update(required_approving_review_count=0),
"stale approvals": lambda p: p["rules"][2]["parameters"].update(dismiss_stale_reviews_on_push=False),
"self approval": lambda p: p["rules"][2]["parameters"].update(require_last_push_approval=False),
"open threads": lambda p: p["rules"][2]["parameters"].update(required_review_thread_resolution=False),
"non-strict checks": lambda p: p["rules"][3]["parameters"].update(strict_required_status_checks_policy=False),
"missing mobile": lambda p: p["rules"][3]["parameters"]["required_status_checks"].pop(0),
"missing DCO": lambda p: p["rules"][3]["parameters"]["required_status_checks"].pop(1),
}
for name, mutate in mutations.items():
value = copy.deepcopy(policy)
mutate(value)
env = dict(os.environ, GH_BRANCH_POLICY=json.dumps(value), GH_ANNOTATED_TARGET_SHA=os.environ["GH_STABLE_SHA"])
before = open(env["GH_CALLS"]).read()
result = subprocess.run([sys.argv[1], "1.2.3", "1", env["GH_STABLE_SHA"], "mobile-v1.2"], env=env, capture_output=True, text=True)
assert result.returncode != 0 and "must enforce review" in result.stderr, (name, result.stderr)
assert open(env["GH_CALLS"]).read() == before, name
PYPOLICY
if GH_BRANCH_POLICY_FAIL=1 "$publisher" 1.2.3 1 "$GH_STABLE_SHA" mobile-v1.2 >/dev/null 2>&1; then
echo "publisher accepted unavailable branch policy" >&2
exit 1
fi
# Exercise the checked-in workflow command with its actual source input binding.
python3 - "$repo_root/.github/workflows/mobile-release-candidate.yml" "$tmp" <<'PYWORKFLOW'
import os
from pathlib import Path
import re
import subprocess
import sys
workflow = Path(sys.argv[1]).read_text()
step = workflow.split(" - name: Publish annotated candidate tag\n", 1)[1]
assert re.search(r"^ SOURCE_BRANCH: \$\{\{ inputs\.source_branch \}\}$", step, re.M), "source input is not bound to SOURCE_BRANCH"
command = re.search(r"^ run: (.+)$", step, re.M).group(1)
root = Path(sys.argv[2]) / "workflow"
(root / "scripts").mkdir(parents=True)
stub = root / "scripts/publish-mobile-release-candidate.sh"
stub.write_text('#!/usr/bin/env bash\nprintf "%s\\n" "$@"\n')
stub.chmod(0o755)
env = dict(os.environ, MOBILE_VERSION="1.2.3", CANDIDATE_NUMBER="1", TARGET_SHA="4" * 40, SOURCE_BRANCH="mobile-v1.2")
actual = subprocess.check_output(["bash", "-eu", "-c", command], cwd=root, env=env, text=True).splitlines()
assert actual == ["1.2.3", "1", "4" * 40, "mobile-v1.2"], actual
PYWORKFLOW
echo "mobile release candidate publisher contract passed"

View File

@@ -17,6 +17,11 @@ cat > "$bin/gh" <<'GH'
set -euo pipefail
case "${1:-}:${2:-}" in
--version:*) printf 'gh version %s (test)\n' "${GH_VERSION:-2.94.0}" ;;
api:repos/block/buzz/rulesets/19321162)
[[ "${GH_BRANCH_POLICY_FAIL:-}" != 1 ]] || exit 1
policy='{"target":"branch","enforcement":"active","current_user_can_bypass":"never","conditions":{"ref_name":{"include":["refs/heads/mobile-v*"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true,"require_last_push_approval":true,"required_review_thread_resolution":true}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Mobile"},{"context":"DCO Check"}]}}]}'
printf '%s\n' "${GH_BRANCH_POLICY:-$policy}" | jq -r "$4"
;;
api:repos/block/buzz/rulesets/14378754)
case "$*" in
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
@@ -42,15 +47,18 @@ case "${1:-}:${2:-}" in
version=""
number=""
sha=""
source_branch=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
version=*) version="${1#version=}" ;;
candidate_number=*) number="${1#candidate_number=}" ;;
source_branch=*) source_branch="${1#source_branch=}" ;;
target_sha=*) sha="${1#target_sha=}" ;;
esac
shift
done
[[ -n "$version" && -n "$number" && -n "$sha" ]]
[[ -n "$version" && -n "$number" && -n "$sha" && -n "$source_branch" ]] || exit 1
[[ "$(git --git-dir="$GH_REMOTE" rev-parse "refs/heads/$source_branch")" == "$sha" ]] || exit 1
printf '%s\t%s\t%s\n' "$version" "$number" "$sha" >> "$GH_WORKFLOW_CAPTURE"
if [[ "${GH_WORKFLOW_NO_URL:-}" == "1" ]]; then
exit 0
@@ -86,6 +94,7 @@ chmod +x "$bin/gh"
export PATH="$bin:$PATH"
export GH_WORKFLOW_CAPTURE="$tmp/workflow-dispatches"
export GH_WORKTREE="$work"
export GH_REMOTE="$remote"
run_release() {
local repo="$1"
@@ -106,7 +115,7 @@ fail() {
assert_no_removed_mobile_release_behavior() {
local status
grep -Eq 'gh[[:space:]]+release|mobile-release/|finalize' "$@" && \
grep -Eq 'gh[[:space:]]+release|finalize' "$@" && \
fail "removed branch/finalization/GitHub Release behavior remains"
status="$?"
[[ "$status" -eq 1 ]] || \
@@ -149,6 +158,37 @@ cat "$tmp/stale-output"
[[ "$(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"
# A stable branch may diverge from main, including an empty release-only commit.
git -C "$work" switch -q -c mobile-v1.2
git -C "$work" commit -q --allow-empty -m stable-only
git -C "$work" push -q origin mobile-v1.2
stable_sha="$(git -C "$work" rev-parse HEAD)"
git -C "$work" switch -q main
if GH_BRANCH_POLICY_FAIL=1 run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2 > "$tmp/policy-error" 2>&1; then
fail "operator accepted unavailable stable-branch policy"
fi
grep -Fq 'Could not verify mobile stable-branch protection' "$tmp/policy-error"
if GH_BRANCH_POLICY='{}' run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2 > "$tmp/policy-error" 2>&1; then
fail "operator accepted missing stable-branch policy"
fi
run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.4-rc.1^{commit}')" == "$stable_sha" ]]
# Existing mismatched refs must be rejected by the guard, not missing-ref errors.
for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary; do
git -C "$work" switch -q -c "$source"
git -C "$work" commit -q --allow-empty -m "$source"
git -C "$work" push -q origin "$source"
done
git -C "$work" switch -q main
for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary ''; do
if run_release "$operator" candidate 1.2.4 --source-branch "$source" >/dev/null 2>&1; then
fail "accepted mismatched source '$source'"
fi
done
if run_release "$operator" candidate 8.8.0 --source-branch mobile-v8.8 >/dev/null 2>&1; then
fail "missing stable branch fell back to main"
fi
# 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)"