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..576452f --- /dev/null +++ b/script/publish-release @@ -0,0 +1,73 @@ +#!/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 + exit 1 +} + +usage() { + cat <&2 + exit 1 +} + +version=$1 +target_sha=$2 + +[[ ${GITHUB_ACTIONS:-} == "true" && + ${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 + 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" + +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..cbe7cd6 --- /dev/null +++ b/script/release @@ -0,0 +1,71 @@ +#!/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" + +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" + +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" + +gh workflow run release.yml \ + --repo "$REPOSITORY" \ + --ref "$BRANCH" \ + --raw-field "version=$version" + +echo +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 new file mode 100755 index 0000000..d958f4a --- /dev/null +++ b/script/validate-release @@ -0,0 +1,166 @@ +#!/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" +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 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 + # 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" ]] || + 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 list \ + --repo "$REPOSITORY" \ + --exclude-drafts \ + --exclude-pre-releases \ + --limit 1 \ + --json tagName \ + --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 + +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 + + 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/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" || + fail "could not read check runs for $target_sha" + +failed_checks=() +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" + +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:-none}" +echo "Target: $target_sha" +echo "Checks: all required checks succeeded"