Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/release.yml
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.

Comment thread
williammartin marked this conversation as resolved.
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This requires approval from an environment release but note that it doesn't block any user with write from just creating a tag. What we need to do is create a rule set that prevents tag creation, then register a specific github app that can bypass it and mint credentials for that app on 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"
73 changes: 73 additions & 0 deletions script/publish-release
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"
Comment thread
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"
71 changes: 71 additions & 0 deletions script/release
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"
166 changes: 166 additions & 0 deletions script/validate-release
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
Comment thread
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"