-
Notifications
You must be signed in to change notification settings - Fork 89
Add guarded release workflow #288
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This requires approval from an environment |
||
| 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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <<EOF | ||
| Usage: script/publish-release v${RELEASE_MAJOR}.MINOR.PATCH TARGET_SHA | ||
|
|
||
| Validate and publish a go-gh release. This command may only be run by the | ||
| workflow_dispatch release workflow. | ||
| EOF | ||
| } | ||
|
|
||
| if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then | ||
| usage | ||
| exit 0 | ||
| fi | ||
|
|
||
| [[ $# -eq 2 ]] || { | ||
| usage >&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" | ||
|
williammartin marked this conversation as resolved.
|
||
|
|
||
| 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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <<EOF | ||
| Usage: script/release v${RELEASE_MAJOR}.MINOR.PATCH | ||
|
|
||
| Validate a go-gh release and dispatch the release workflow on ${BRANCH}. | ||
| The workflow publishes the release after its release environment is approved. | ||
| EOF | ||
| } | ||
|
|
||
| if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then | ||
| usage | ||
| exit 0 | ||
| fi | ||
|
|
||
| [[ $# -eq 1 ]] || { | ||
| usage >&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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <<EOF | ||
| Usage: script/validate-release v${RELEASE_MAJOR}.MINOR.PATCH [TARGET_SHA] | ||
|
|
||
| Validate a go-gh release against the current origin/${BRANCH}. | ||
| When provided, TARGET_SHA must match the current origin/${BRANCH} commit. | ||
| EOF | ||
| } | ||
|
|
||
| if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then | ||
| usage | ||
| exit 0 | ||
| fi | ||
|
|
||
| [[ $# -ge 1 && $# -le 2 ]] || { | ||
| usage >&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 | ||
|
williammartin marked this conversation as resolved.
|
||
|
|
||
| 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" | ||
Uh oh!
There was an error while loading. Please reload this page.