From 30ca980b49a7479dd89cb1ced0871af05ff1e12e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 18:04:04 -0700 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 1 + .../workflows/mobile-release-candidate.yml | 10 +- RELEASING.md | 75 +++++++++--- scripts/ci-selection.test.mjs | 5 + scripts/mobile-release-source.sh | 44 +++++++ scripts/mobile-release.sh | 46 +++++--- scripts/publish-mobile-release-candidate.sh | 15 ++- ...test-mobile-release-candidate-publisher.sh | 110 +++++++++++++++--- scripts/test-mobile-release-contract.sh | 44 ++++++- 9 files changed, 291 insertions(+), 59 deletions(-) create mode 100644 scripts/mobile-release-source.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 351c7bef6a..be87fa4be4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/mobile-release-candidate.yml b/.github/workflows/mobile-release-candidate.yml index e1f1c1c918..5f2ecf305a 100644 --- a/.github/workflows/mobile-release-candidate.yml +++ b/.github/workflows/mobile-release-candidate.yml @@ -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" diff --git a/RELEASING.md b/RELEASING.md index 34fc9b49da..0385dd9e4a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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 ` 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 ` 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 diff --git a/scripts/ci-selection.test.mjs b/scripts/ci-selection.test.mjs index ccad2f604c..4fb7508293 100644 --- a/scripts/ci-selection.test.mjs +++ b/scripts/ci-selection.test.mjs @@ -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", diff --git a/scripts/mobile-release-source.sh b/scripts/mobile-release-source.sh new file mode 100644 index 0000000000..6420ae0d03 --- /dev/null +++ b/scripts/mobile-release-source.sh @@ -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 + } +} diff --git a/scripts/mobile-release.sh b/scripts/mobile-release.sh index 5702281700..cd81c50fd1 100755 --- a/scripts/mobile-release.sh +++ b/scripts/mobile-release.sh @@ -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 ;; diff --git a/scripts/publish-mobile-release-candidate.sh b/scripts/publish-mobile-release-candidate.sh index 6b0ee65b21..edeb9ad59b 100755 --- a/scripts/publish-mobile-release-candidate.sh +++ b/scripts/publish-mobile-release-candidate.sh @@ -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" ]] || \ diff --git a/scripts/test-mobile-release-candidate-publisher.sh b/scripts/test-mobile-release-candidate-publisher.sh index 92cfc69d84..87156a451a 100755 --- a/scripts/test-mobile-release-candidate-publisher.sh +++ b/scripts/test-mobile-release-candidate-publisher.sh @@ -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" diff --git a/scripts/test-mobile-release-contract.sh b/scripts/test-mobile-release-contract.sh index 531590a585..ca6b79b9e1 100755 --- a/scripts/test-mobile-release-contract.sh +++ b/scripts/test-mobile-release-contract.sh @@ -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)"