From 9808f9d104b3ebc1f5f5d6f5d39a70230306b229 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 2 Sep 2026 12:58:14 +0200 Subject: [PATCH 1/4] Add guarded release workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40801f35-ff1f-4057-99ad-4a7b0c9f7ddf --- .github/workflows/release.yml | 58 ++++++++++++++ script/publish-release | 69 +++++++++++++++++ script/release | 69 +++++++++++++++++ script/validate-release | 139 ++++++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100755 script/publish-release create mode 100755 script/release create mode 100755 script/validate-release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8b8e446 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release +run-name: Release ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: Version to release (v2.MINOR.PATCH) + required: true + type: string + +concurrency: + group: release + cancel-in-progress: false + +permissions: + checks: read # Verify required checks on the release commit. + contents: read # Check out and inspect the release commit. + +jobs: + validate: + name: Validate release + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: script/validate-release "$VERSION" "$GITHUB_SHA" + + release: + name: Publish release + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: release + permissions: + checks: read # Reverify required checks after environment approval. + contents: write # Create the release and its tag. + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: script/publish-release "$VERSION" "$GITHUB_SHA" diff --git a/script/publish-release b/script/publish-release new file mode 100755 index 0000000..c6c205f --- /dev/null +++ b/script/publish-release @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +readonly SCRIPT_DIR +readonly REPOSITORY="cli/go-gh" +readonly RELEASE_MAJOR="2" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +usage() { + cat <&2 + exit 1 +} + +version=$1 +target_sha=$2 + +[[ ${GITHUB_ACTIONS:-} == "true" && + ${GITHUB_EVENT_NAME:-} == "workflow_dispatch" ]] || + fail "releases may only be published by the release workflow" + +for command in gh git; do + command -v "$command" >/dev/null 2>&1 || + fail "required command not found: $command" +done + +repository_root=$(git rev-parse --show-toplevel 2>/dev/null) || + fail "run this script from a go-gh checkout" +cd "$repository_root" || + fail "could not enter repository root: $repository_root" + +actual_repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) +[[ $actual_repository == "$REPOSITORY" ]] || + fail "expected repository $REPOSITORY, found $actual_repository" + +"$SCRIPT_DIR/validate-release" "$version" "$target_sha" + +can_push=$(gh api "repos/$REPOSITORY" --jq '.permissions.push') +[[ $can_push == "true" ]] || + fail "the GitHub token cannot create releases in $REPOSITORY" + +release_url=$(gh release create "$version" \ + --repo "$REPOSITORY" \ + --target "$target_sha" \ + --title "$version" \ + --generate-notes \ + --fail-on-no-commits) + +echo +echo "Release published: $release_url" diff --git a/script/release b/script/release new file mode 100755 index 0000000..f538f25 --- /dev/null +++ b/script/release @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +readonly SCRIPT_DIR +readonly REPOSITORY="cli/go-gh" +readonly BRANCH="trunk" +readonly RELEASE_MAJOR="2" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +usage() { + cat <&2 + exit 1 +} + +version=$1 + +for command in gh git; do + command -v "$command" >/dev/null 2>&1 || + fail "required command not found: $command" +done + +repository_root=$(git rev-parse --show-toplevel 2>/dev/null) || + fail "run this script from a go-gh checkout" +cd "$repository_root" || + fail "could not enter repository root: $repository_root" + +actual_repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) +[[ $actual_repository == "$REPOSITORY" ]] || + fail "expected repository $REPOSITORY, found $actual_repository" + +"$SCRIPT_DIR/validate-release" "$version" + +can_push=$(gh api "repos/$REPOSITORY" --jq '.permissions.push') +[[ $can_push == "true" ]] || + fail "your GitHub account cannot create releases in $REPOSITORY" + +echo +read -r -p "Dispatch the release workflow for $version? [y/N] " confirmation || + fail "confirmation is required to dispatch the release workflow" +[[ $confirmation == "y" || $confirmation == "Y" ]] || + fail "release cancelled" + +run_url=$(gh workflow run release.yml \ + --repo "$REPOSITORY" \ + --ref "$BRANCH" \ + --raw-field "version=$version") + +echo +echo "Release workflow dispatched: $run_url" diff --git a/script/validate-release b/script/validate-release new file mode 100755 index 0000000..04f024b --- /dev/null +++ b/script/validate-release @@ -0,0 +1,139 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly REPOSITORY="cli/go-gh" +readonly BRANCH="trunk" +readonly RELEASE_MAJOR="2" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +usage() { + cat <&2 + exit 1 +} + +version=$1 +requested_target=${2:-} + +if [[ ! $version =~ ^v${RELEASE_MAJOR}\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + fail "version must be a stable v${RELEASE_MAJOR}.MINOR.PATCH tag without leading zeroes" +fi + +for command in gh git grep mktemp; do + command -v "$command" >/dev/null 2>&1 || + fail "required command not found: $command" +done + +repository_root=$(git rev-parse --show-toplevel 2>/dev/null) || + fail "run this script from a go-gh checkout" +cd "$repository_root" || + fail "could not enter repository root: $repository_root" + +actual_repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) +[[ $actual_repository == "$REPOSITORY" ]] || + fail "expected repository $REPOSITORY, found $actual_repository" + +echo "Fetching origin/${BRANCH} and tags..." +git fetch --quiet origin "$BRANCH" --tags +target_sha=$(git rev-parse "refs/remotes/origin/${BRANCH}^{commit}") + +if [[ -n $requested_target ]]; then + [[ $requested_target =~ ^[0-9a-f]{40}$ ]] || + fail "target must be a full commit SHA" + [[ $requested_target == "$target_sha" ]] || + fail "target is not the current origin/${BRANCH} commit" +fi + +if git show-ref --verify --quiet "refs/tags/$version"; then + fail "tag already exists: $version" +fi + +if release_lookup=$(gh release view "$version" \ + --repo "$REPOSITORY" 2>&1); then + fail "release already exists: $version" +elif [[ $release_lookup != "release not found" ]]; then + fail "could not check for an existing release: $release_lookup" +fi + +latest_version=$(gh release view \ + --repo "$REPOSITORY" \ + --json tagName \ + --jq .tagName) +if [[ ! $latest_version =~ ^v${RELEASE_MAJOR}\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + fail "latest release has an unexpected version: $latest_version" +fi + +latest_minor=${BASH_REMATCH[1]} +latest_patch=${BASH_REMATCH[2]} +[[ $version =~ ^v${RELEASE_MAJOR}\.([0-9]+)\.([0-9]+)$ ]] +candidate_minor=${BASH_REMATCH[1]} +candidate_patch=${BASH_REMATCH[2]} + +if ((candidate_minor < latest_minor || + candidate_minor == latest_minor && candidate_patch <= latest_patch)); then + fail "$version must be newer than the latest release, $latest_version" +fi + +latest_sha=$(git rev-list -n 1 "$latest_version") +git merge-base --is-ancestor "$latest_sha" "$target_sha" || + fail "origin/${BRANCH} does not contain $latest_version" +[[ $latest_sha != "$target_sha" ]] || + fail "there are no commits since $latest_version" + +required_checks_file=$(mktemp) +check_runs_file=$(mktemp) +trap 'rm -f "$required_checks_file" "$check_runs_file"' EXIT + +gh api \ + "repos/$REPOSITORY/branches/$BRANCH/protection/required_status_checks" \ + --jq '.checks[] | [.context, (.app_id | tostring)] | @tsv' \ + >"$required_checks_file" +gh api \ + "repos/$REPOSITORY/commits/$target_sha/check-runs?per_page=100" \ + --jq '.check_runs[] | [.name, (.app.id | tostring), .status, (.conclusion // "")] | @tsv' \ + >"$check_runs_file" + +failed_checks=() +while IFS=$'\t' read -r check_name app_id; do + [[ -n $check_name ]] || continue + if ! grep -Fqx \ + "$check_name"$'\t'"$app_id"$'\t'"completed"$'\t'"success" \ + "$check_runs_file"; then + failed_checks+=("$check_name") + fi +done <"$required_checks_file" + +if ((${#failed_checks[@]} > 0)); then + printf 'error: required checks have not succeeded on origin/%s (%s):\n' \ + "$BRANCH" "${target_sha:0:12}" >&2 + printf ' - %s\n' "${failed_checks[@]}" >&2 + exit 1 +fi + +remote_sha=$(git ls-remote origin "refs/heads/$BRANCH" | cut -f1) +[[ $remote_sha == "$target_sha" ]] || + fail "origin/${BRANCH} changed during validation" + +echo +echo "Release: $version" +echo "Previous: $latest_version" +echo "Target: $target_sha" +echo "Checks: all required checks succeeded" From e89e3629f01cfce7c2b5f8573cb288942899f7f1 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 2 Sep 2026 13:46:41 +0200 Subject: [PATCH 2/4] Harden release validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0268640a-dbfd-4065-9112-eff95ca18d66 --- script/publish-release | 14 ++++--- script/release | 12 ++++-- script/validate-release | 90 ++++++++++++++++++++++++++--------------- 3 files changed, 76 insertions(+), 40 deletions(-) diff --git a/script/publish-release b/script/publish-release index c6c205f..576452f 100755 --- a/script/publish-release +++ b/script/publish-release @@ -1,11 +1,18 @@ #!/usr/bin/env bash +if [ -z "${BASH_VERSION:-}" ]; then + printf 'error: this script must be run with Bash\n' >&2 + exit 1 +fi + set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) readonly SCRIPT_DIR readonly REPOSITORY="cli/go-gh" +readonly BRANCH="trunk" readonly RELEASE_MAJOR="2" +readonly RELEASE_WORKFLOW_REF="$REPOSITORY/.github/workflows/release.yml@refs/heads/$BRANCH" fail() { echo "error: $*" >&2 @@ -35,7 +42,8 @@ version=$1 target_sha=$2 [[ ${GITHUB_ACTIONS:-} == "true" && - ${GITHUB_EVENT_NAME:-} == "workflow_dispatch" ]] || + ${GITHUB_EVENT_NAME:-} == "workflow_dispatch" && + ${GITHUB_WORKFLOW_REF:-} == "$RELEASE_WORKFLOW_REF" ]] || fail "releases may only be published by the release workflow" for command in gh git; do @@ -54,10 +62,6 @@ actual_repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) "$SCRIPT_DIR/validate-release" "$version" "$target_sha" -can_push=$(gh api "repos/$REPOSITORY" --jq '.permissions.push') -[[ $can_push == "true" ]] || - fail "the GitHub token cannot create releases in $REPOSITORY" - release_url=$(gh release create "$version" \ --repo "$REPOSITORY" \ --target "$target_sha" \ diff --git a/script/release b/script/release index f538f25..3bea37e 100755 --- a/script/release +++ b/script/release @@ -1,5 +1,10 @@ #!/usr/bin/env bash +if [ -z "${BASH_VERSION:-}" ]; then + printf 'error: this script must be run with Bash\n' >&2 + exit 1 +fi + set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -60,10 +65,11 @@ read -r -p "Dispatch the release workflow for $version? [y/N] " confirmation || [[ $confirmation == "y" || $confirmation == "Y" ]] || fail "release cancelled" -run_url=$(gh workflow run release.yml \ +gh workflow run release.yml \ --repo "$REPOSITORY" \ --ref "$BRANCH" \ - --raw-field "version=$version") + --raw-field "version=$version" echo -echo "Release workflow dispatched: $run_url" +echo "Release workflow dispatched for $version" +echo "View runs: https://github.com/$REPOSITORY/actions/workflows/release.yml" diff --git a/script/validate-release b/script/validate-release index 04f024b..6a228fb 100755 --- a/script/validate-release +++ b/script/validate-release @@ -1,5 +1,10 @@ #!/usr/bin/env bash +if [ -z "${BASH_VERSION:-}" ]; then + printf 'error: this script must be run with Bash\n' >&2 + exit 1 +fi + set -euo pipefail readonly REPOSITORY="cli/go-gh" @@ -16,7 +21,7 @@ usage() { Usage: script/validate-release v${RELEASE_MAJOR}.MINOR.PATCH [TARGET_SHA] Validate a go-gh release against the current origin/${BRANCH}. -TARGET_SHA defaults to the current origin/${BRANCH} commit. +When provided, TARGET_SHA must match the current origin/${BRANCH} commit. EOF } @@ -37,7 +42,7 @@ if [[ ! $version =~ ^v${RELEASE_MAJOR}\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; th fail "version must be a stable v${RELEASE_MAJOR}.MINOR.PATCH tag without leading zeroes" fi -for command in gh git grep mktemp; do +for command in gh git mktemp; do command -v "$command" >/dev/null 2>&1 || fail "required command not found: $command" done @@ -69,55 +74,76 @@ fi if release_lookup=$(gh release view "$version" \ --repo "$REPOSITORY" 2>&1); then fail "release already exists: $version" -elif [[ $release_lookup != "release not found" ]]; then +elif [[ $release_lookup != *"release not found"* ]]; then fail "could not check for an existing release: $release_lookup" fi -latest_version=$(gh release view \ +latest_version=$(gh release list \ --repo "$REPOSITORY" \ + --exclude-drafts \ + --exclude-pre-releases \ + --limit 1 \ --json tagName \ - --jq .tagName) -if [[ ! $latest_version =~ ^v${RELEASE_MAJOR}\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + --jq '.[0].tagName // ""') +if [[ -n $latest_version && + ! $latest_version =~ ^v${RELEASE_MAJOR}\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then fail "latest release has an unexpected version: $latest_version" fi -latest_minor=${BASH_REMATCH[1]} -latest_patch=${BASH_REMATCH[2]} -[[ $version =~ ^v${RELEASE_MAJOR}\.([0-9]+)\.([0-9]+)$ ]] -candidate_minor=${BASH_REMATCH[1]} -candidate_patch=${BASH_REMATCH[2]} +if [[ -n $latest_version ]]; then + latest_minor=${BASH_REMATCH[1]} + latest_patch=${BASH_REMATCH[2]} + [[ $version =~ ^v${RELEASE_MAJOR}\.([0-9]+)\.([0-9]+)$ ]] + candidate_minor=${BASH_REMATCH[1]} + candidate_patch=${BASH_REMATCH[2]} -if ((candidate_minor < latest_minor || - candidate_minor == latest_minor && candidate_patch <= latest_patch)); then - fail "$version must be newer than the latest release, $latest_version" -fi + if ((candidate_minor < latest_minor || + candidate_minor == latest_minor && candidate_patch <= latest_patch)); then + fail "$version must be newer than the latest release, $latest_version" + fi -latest_sha=$(git rev-list -n 1 "$latest_version") -git merge-base --is-ancestor "$latest_sha" "$target_sha" || - fail "origin/${BRANCH} does not contain $latest_version" -[[ $latest_sha != "$target_sha" ]] || - fail "there are no commits since $latest_version" + latest_sha=$(git rev-list -n 1 "$latest_version") + git merge-base --is-ancestor "$latest_sha" "$target_sha" || + fail "origin/${BRANCH} does not contain $latest_version" + [[ $latest_sha != "$target_sha" ]] || + fail "there are no commits since $latest_version" +fi required_checks_file=$(mktemp) check_runs_file=$(mktemp) trap 'rm -f "$required_checks_file" "$check_runs_file"' EXIT gh api \ - "repos/$REPOSITORY/branches/$BRANCH/protection/required_status_checks" \ - --jq '.checks[] | [.context, (.app_id | tostring)] | @tsv' \ - >"$required_checks_file" -gh api \ + "repos/$REPOSITORY/rules/branches/$BRANCH" \ + --jq '.[] | select(.type == "required_status_checks") | + .parameters.required_status_checks[] | + [.context, ((.integration_id // -1) | tostring)] | @tsv' \ + >"$required_checks_file" || + fail "could not read rules for $BRANCH" +[[ -s $required_checks_file ]] || + fail "no required status checks are configured for $BRANCH" + +gh api --paginate \ "repos/$REPOSITORY/commits/$target_sha/check-runs?per_page=100" \ --jq '.check_runs[] | [.name, (.app.id | tostring), .status, (.conclusion // "")] | @tsv' \ - >"$check_runs_file" + >"$check_runs_file" || + fail "could not read check runs for $target_sha" failed_checks=() -while IFS=$'\t' read -r check_name app_id; do - [[ -n $check_name ]] || continue - if ! grep -Fqx \ - "$check_name"$'\t'"$app_id"$'\t'"completed"$'\t'"success" \ - "$check_runs_file"; then - failed_checks+=("$check_name") +while IFS=$'\t' read -r required_name required_app_id; do + check_succeeded=false + while IFS=$'\t' read -r check_name app_id status conclusion; do + if [[ $check_name == "$required_name" && + ($required_app_id == "-1" || $app_id == "$required_app_id") && + $status == "completed" && $conclusion == "success" ]]; then + check_succeeded=true + break + fi + done <"$check_runs_file" + + [[ -n $required_name ]] || continue + if [[ $check_succeeded != "true" ]]; then + failed_checks+=("$required_name") fi done <"$required_checks_file" @@ -134,6 +160,6 @@ remote_sha=$(git ls-remote origin "refs/heads/$BRANCH" | cut -f1) echo echo "Release: $version" -echo "Previous: $latest_version" +echo "Previous: ${latest_version:-none}" echo "Target: $target_sha" echo "Checks: all required checks succeeded" From fb2f52fca3a0aaff254285fa4ea721a6254919fd Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 2 Sep 2026 13:48:00 +0200 Subject: [PATCH 3/4] Explain release commit pinning Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0268640a-dbfd-4065-9112-eff95ca18d66 --- script/validate-release | 1 + 1 file changed, 1 insertion(+) diff --git a/script/validate-release b/script/validate-release index 6a228fb..d958f4a 100755 --- a/script/validate-release +++ b/script/validate-release @@ -61,6 +61,7 @@ git fetch --quiet origin "$BRANCH" --tags target_sha=$(git rev-parse "refs/remotes/origin/${BRANCH}^{commit}") if [[ -n $requested_target ]]; then + # Keep environment approval bound to the dispatched commit instead of a newer trunk tip. [[ $requested_target =~ ^[0-9a-f]{40}$ ]] || fail "target must be a full commit SHA" [[ $requested_target == "$target_sha" ]] || From ab443ff4c30cbcf8ccc9925bd505fc267fc5e96a Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 2 Sep 2026 14:01:54 +0200 Subject: [PATCH 4/4] Remove release push preflight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0268640a-dbfd-4065-9112-eff95ca18d66 --- script/release | 4 ---- 1 file changed, 4 deletions(-) diff --git a/script/release b/script/release index 3bea37e..cbe7cd6 100755 --- a/script/release +++ b/script/release @@ -55,10 +55,6 @@ actual_repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) "$SCRIPT_DIR/validate-release" "$version" -can_push=$(gh api "repos/$REPOSITORY" --jq '.permissions.push') -[[ $can_push == "true" ]] || - fail "your GitHub account cannot create releases in $REPOSITORY" - echo read -r -p "Dispatch the release workflow for $version? [y/N] " confirmation || fail "confirmation is required to dispatch the release workflow"