Repository navigation
Release (patch) #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Build and publish a One CLI release with an automatic semantic-version bump. | |
| # No version files or release branches are created on master. | |
| name: Build and Release | |
| run-name: Release (${{ inputs.bump_type }}) | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| bump_type: | |
| description: "Semantic-version component to increment" | |
| required: true | |
| type: choice | |
| default: patch | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| concurrency: | |
| group: build-and-release | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| env: | |
| GOPROXY: https://proxy.golang.org,direct | |
| GOTOOLCHAIN: local | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| steps: | |
| - name: Check out dispatch commit | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Validate master | |
| run: | | |
| set -euo pipefail | |
| if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then | |
| echo "Run this workflow from the master branch." >&2 | |
| exit 1 | |
| fi | |
| git fetch origin master --tags | |
| if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/master)" ]]; then | |
| echo "master advanced after this run was dispatched; start a fresh run." >&2 | |
| exit 1 | |
| fi | |
| - name: Calculate release version | |
| id: version | |
| env: | |
| BUMP_TYPE: ${{ inputs.bump_type }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| stable_tag_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' | |
| latest_tag="" | |
| while IFS= read -r candidate; do | |
| if [[ "$candidate" =~ $stable_tag_re ]]; then | |
| latest_tag="$candidate" | |
| break | |
| fi | |
| done < <(git tag --list 'v*' --sort=-version:refname) | |
| expected_assets="$(printf '%s\n' \ | |
| checksums.txt \ | |
| one-cli_darwin_amd64.tar.gz \ | |
| one-cli_darwin_arm64.tar.gz \ | |
| one-cli_linux_amd64.tar.gz \ | |
| one-cli_linux_arm64.tar.gz \ | |
| one-cli_windows_amd64.zip | sort)" | |
| mode="bump" | |
| latest_tag_object="" | |
| decision="bump $BUMP_TYPE from no tag" | |
| if [[ -n "$latest_tag" ]]; then | |
| latest_sha="$(git rev-list -n 1 "$latest_tag")" | |
| latest_tag_object="$(git rev-parse "$latest_tag")" | |
| latest_on_master=false | |
| git merge-base --is-ancestor "$latest_sha" origin/master && latest_on_master=true | |
| releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" | |
| latest_release="$(jq -c --arg tag "$latest_tag" \ | |
| '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" | |
| if [[ -z "$latest_release" ]]; then | |
| if [[ "$latest_on_master" == "true" ]]; then | |
| mode="resume" | |
| decision="resume $latest_tag (release missing)" | |
| else | |
| echo "Highest stable tag $latest_tag is off master and has no release; clean it up first." >&2 | |
| exit 1 | |
| fi | |
| elif [[ "$(jq -r '.prerelease' <<< "$latest_release")" == "true" ]]; then | |
| echo "Stable tag $latest_tag is marked as a prerelease; resolve that state first." >&2 | |
| exit 1 | |
| elif [[ "$(jq -r '.draft' <<< "$latest_release")" == "true" ]]; then | |
| if [[ "$latest_on_master" == "true" ]]; then | |
| mode="resume" | |
| decision="resume draft $latest_tag" | |
| else | |
| decision="bump $BUMP_TYPE from off-master draft $latest_tag" | |
| fi | |
| else | |
| actual_assets="$(jq -r '.assets[].name' <<< "$latest_release" | sort)" | |
| if [[ "$actual_assets" != "$expected_assets" ]]; then | |
| echo "Published release $latest_tag has an incomplete or unexpected asset set." >&2 | |
| diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true | |
| exit 1 | |
| fi | |
| if [[ "$latest_on_master" == "true" && "$latest_sha" == "$(git rev-parse HEAD)" ]]; then | |
| mode="idempotent" | |
| decision="current commit already published as $latest_tag" | |
| else | |
| decision="bump $BUMP_TYPE from $latest_tag" | |
| fi | |
| fi | |
| fi | |
| if [[ "$mode" == "resume" || "$mode" == "idempotent" ]]; then | |
| release_version="${latest_tag#v}" | |
| else | |
| base_version="${latest_tag#v}" | |
| [[ -n "$base_version" ]] || base_version="0.0.0" | |
| IFS=. read -r major minor patch <<< "$base_version" | |
| case "$BUMP_TYPE" in | |
| patch) patch=$((patch + 1)) ;; | |
| minor) minor=$((minor + 1)); patch=0 ;; | |
| major) major=$((major + 1)); minor=0; patch=0 ;; | |
| *) echo "Unsupported bump type: $BUMP_TYPE" >&2; exit 1 ;; | |
| esac | |
| release_version="${major}.${minor}.${patch}" | |
| fi | |
| echo "version=$release_version" >> "$GITHUB_OUTPUT" | |
| echo "base_tag=$latest_tag" >> "$GITHUB_OUTPUT" | |
| echo "base_tag_object=$latest_tag_object" >> "$GITHUB_OUTPUT" | |
| echo "mode=$mode" >> "$GITHUB_OUTPUT" | |
| echo "RELEASE_VERSION=$release_version" >> "$GITHUB_ENV" | |
| { | |
| echo "### Calculated version" | |
| echo "- Version: $release_version" | |
| echo "- Mode: $mode" | |
| echo "- Decision: $decision" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Resolve release state | |
| id: release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| tag="v$RELEASE_VERSION" | |
| source_sha="$(git rev-parse HEAD)" | |
| tag_exists=false | |
| already_published=false | |
| expected_assets="$(printf '%s\n' \ | |
| checksums.txt \ | |
| one-cli_darwin_amd64.tar.gz \ | |
| one-cli_darwin_arm64.tar.gz \ | |
| one-cli_linux_amd64.tar.gz \ | |
| one-cli_linux_arm64.tar.gz \ | |
| one-cli_windows_amd64.zip | sort)" | |
| releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" | |
| target_release="$(jq -c --arg tag "$tag" \ | |
| '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" | |
| if git show-ref --verify --quiet "refs/tags/$tag"; then | |
| tag_exists=true | |
| source_sha="$(git rev-list -n 1 "$tag")" | |
| if ! git merge-base --is-ancestor "$source_sha" origin/master; then | |
| echo "Tag $tag does not point to a commit on master." >&2 | |
| exit 1 | |
| fi | |
| if [[ -n "$target_release" ]]; then | |
| if [[ "$(jq -r '.prerelease' <<< "$target_release")" == "true" ]]; then | |
| echo "Stable tag $tag is marked as a prerelease." >&2 | |
| exit 1 | |
| fi | |
| if [[ "$(jq -r '.draft' <<< "$target_release")" == "false" ]]; then | |
| actual_assets="$(jq -r '.assets[].name' <<< "$target_release" | sort)" | |
| if [[ "$actual_assets" != "$expected_assets" ]]; then | |
| echo "Published release $tag has an incomplete or unexpected asset set." >&2 | |
| diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true | |
| exit 1 | |
| fi | |
| already_published=true | |
| fi | |
| fi | |
| else | |
| if [[ -n "$target_release" ]]; then | |
| echo "Release $tag exists without a matching Git tag." >&2 | |
| exit 1 | |
| fi | |
| existing_stable_tags="$(git tag --points-at "$source_sha" | sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p')" | |
| if [[ -n "$existing_stable_tags" ]]; then | |
| echo "This commit already has a stable tag: $existing_stable_tags" >&2 | |
| exit 1 | |
| fi | |
| latest_published_tag="" | |
| while IFS= read -r candidate; do | |
| [[ "$candidate" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || continue | |
| candidate_release="$(jq -c --arg tag "$candidate" \ | |
| '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" | |
| if [[ -n "$candidate_release" ]]; then | |
| if [[ "$(jq -r '[.draft, .prerelease] | @tsv' <<< "$candidate_release")" == $'false\tfalse' ]]; then | |
| latest_published_tag="$candidate" | |
| break | |
| fi | |
| fi | |
| done < <(git tag --list 'v*' --sort=-version:refname) | |
| if [[ -n "$latest_published_tag" ]]; then | |
| latest_version="${latest_published_tag#v}" | |
| highest="$(printf '%s\n%s\n' "$latest_version" "$RELEASE_VERSION" | sort -V | tail -1)" | |
| if [[ "$highest" != "$RELEASE_VERSION" || "$latest_version" == "$RELEASE_VERSION" ]]; then | |
| echo "version must be newer than the latest published release $latest_published_tag." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| fi | |
| echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| echo "sha=$source_sha" >> "$GITHUB_OUTPUT" | |
| echo "tag_exists=$tag_exists" >> "$GITHUB_OUTPUT" | |
| echo "already_published=$already_published" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### Release state" | |
| echo "- Version: $RELEASE_VERSION" | |
| echo "- Tag: $tag" | |
| echo "- Commit: $source_sha" | |
| echo "- Existing tag: $tag_exists" | |
| echo "- Already published: $already_published" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Preserve release configuration | |
| if: steps.release.outputs.already_published != 'true' | |
| run: cp .goreleaser.yaml "$RUNNER_TEMP/one-cli-goreleaser.yaml" | |
| - name: Check out release commit | |
| if: steps.release.outputs.already_published != 'true' | |
| env: | |
| RELEASE_SHA: ${{ steps.release.outputs.sha }} | |
| run: git checkout --detach "$RELEASE_SHA" | |
| - name: Set up Go | |
| if: steps.release.outputs.already_published != 'true' | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version-file: packages/cli/go.mod | |
| cache: true | |
| cache-dependency-path: | | |
| go.work | |
| go.work.sum | |
| packages/cli/go.sum | |
| packages/kernel/go.mod | |
| - name: Set up Task | |
| if: steps.release.outputs.already_published != 'true' | |
| uses: go-task/setup-task@v2 | |
| with: | |
| version: 3.53.1 | |
| - name: Set up pnpm | |
| if: steps.release.outputs.already_published != 'true' | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| run_install: false | |
| - name: Set up Node | |
| if: steps.release.outputs.already_published != 'true' | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| cache-dependency-path: pnpm-lock.yaml | |
| - name: Download Go modules without mutating the workspace | |
| if: steps.release.outputs.already_published != 'true' | |
| run: | | |
| GOWORK=off go -C packages/kernel mod download | |
| GOWORK=off go -C packages/cli mod download | |
| - name: Verify dependency metadata is clean | |
| if: steps.release.outputs.already_published != 'true' | |
| run: | | |
| set -euo pipefail | |
| dependency_files=( | |
| go.work | |
| go.work.sum | |
| packages/kernel/go.mod | |
| packages/kernel/go.sum | |
| packages/cli/go.mod | |
| packages/cli/go.sum | |
| ) | |
| changes="$(git status --short -- "${dependency_files[@]}")" | |
| if [[ -n "$changes" ]]; then | |
| echo "Dependency setup changed dependency metadata." >&2 | |
| printf '%s\n' "$changes" >&2 | |
| git diff -- "${dependency_files[@]}" | |
| exit 1 | |
| fi | |
| - name: Run complete release gate | |
| if: steps.release.outputs.already_published != 'true' | |
| run: task pre-push | |
| - name: Verify release tree is clean | |
| if: steps.release.outputs.already_published != 'true' | |
| run: | | |
| set -euo pipefail | |
| git diff --exit-code | |
| if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then | |
| git status --short | |
| echo "Release checkout has tracked changes." >&2 | |
| exit 1 | |
| fi | |
| - name: Create local release tag | |
| if: steps.release.outputs.already_published != 'true' | |
| env: | |
| TAG: ${{ steps.release.outputs.tag }} | |
| RELEASE_SHA: ${{ steps.release.outputs.sha }} | |
| TAG_EXISTS: ${{ steps.release.outputs.tag_exists }} | |
| run: | | |
| set -euo pipefail | |
| if [[ "$TAG_EXISTS" == "true" ]]; then | |
| actual_sha="$(git rev-list -n 1 "$TAG")" | |
| if [[ "$actual_sha" != "$RELEASE_SHA" ]]; then | |
| echo "Tag $TAG points to $actual_sha, expected $RELEASE_SHA." >&2 | |
| exit 1 | |
| fi | |
| else | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git tag -a "$TAG" "$RELEASE_SHA" -m "$TAG" | |
| fi | |
| - name: Run GoReleaser preflight build | |
| if: steps.release.outputs.already_published != 'true' | |
| uses: goreleaser/goreleaser-action@v7 | |
| with: | |
| version: v2.18.0 | |
| args: release --config ${{ runner.temp }}/one-cli-goreleaser.yaml --skip=publish --clean | |
| env: | |
| GORELEASER_CURRENT_TAG: ${{ steps.release.outputs.tag }} | |
| - name: Verify packaged CLI version | |
| if: steps.release.outputs.already_published != 'true' | |
| run: | | |
| set -euo pipefail | |
| archive="dist/one-cli_linux_amd64.tar.gz" | |
| extract_dir="$RUNNER_TEMP/one-cli-version-check" | |
| test -f "$archive" | |
| mkdir -p "$extract_dir" | |
| tar -xzf "$archive" -C "$extract_dir" | |
| actual_version="$("$extract_dir/one" --version | tr -d '[:space:]')" | |
| if [[ "$actual_version" != "$RELEASE_VERSION" ]]; then | |
| echo "Packaged CLI reports $actual_version, expected $RELEASE_VERSION." >&2 | |
| exit 1 | |
| fi | |
| - name: Revalidate release state | |
| if: steps.release.outputs.already_published != 'true' | |
| env: | |
| BASE_TAG: ${{ steps.version.outputs.base_tag }} | |
| BASE_TAG_OBJECT: ${{ steps.version.outputs.base_tag_object }} | |
| GH_TOKEN: ${{ github.token }} | |
| MODE: ${{ steps.version.outputs.mode }} | |
| RELEASE_SHA: ${{ steps.release.outputs.sha }} | |
| TAG: ${{ steps.release.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| remote_latest_tag="$(git ls-remote --refs --tags origin 'refs/tags/v*' | | |
| awk '{sub("refs/tags/", "", $2); print $2}' | | |
| sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p' | | |
| sort -V | tail -n 1)" | |
| if [[ "$remote_latest_tag" != "$BASE_TAG" ]]; then | |
| echo "Highest stable tag changed from $BASE_TAG to $remote_latest_tag; restart the release." >&2 | |
| exit 1 | |
| fi | |
| if [[ -n "$BASE_TAG" ]]; then | |
| remote_base_object="$(git ls-remote --refs --tags origin "refs/tags/$BASE_TAG" | awk 'NR == 1 {print $1}')" | |
| if [[ "$remote_base_object" != "$BASE_TAG_OBJECT" ]]; then | |
| echo "Base tag $BASE_TAG moved during this run; restart the release." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" | |
| target_release="$(jq -c --arg tag "$TAG" \ | |
| '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" | |
| case "$MODE" in | |
| bump) | |
| current_master="$(git ls-remote origin 'refs/heads/master' | awk 'NR == 1 {print $1}')" | |
| if [[ "$current_master" != "$RELEASE_SHA" ]]; then | |
| echo "master advanced to $current_master after validation; restart the release." >&2 | |
| exit 1 | |
| fi | |
| if git ls-remote --exit-code --refs --tags origin "refs/tags/$TAG" >/dev/null 2>&1 || | |
| [[ -n "$target_release" ]]; then | |
| echo "Tag or release $TAG appeared during this run; restart to validate it." >&2 | |
| exit 1 | |
| fi | |
| ;; | |
| resume) | |
| if [[ -n "$target_release" ]]; then | |
| if [[ "$(jq -r '.prerelease' <<< "$target_release")" == "true" || | |
| "$(jq -r '.draft' <<< "$target_release")" != "true" ]]; then | |
| echo "Release $TAG changed while validation was running; restart the release." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| ;; | |
| *) | |
| echo "Unexpected release mode: $MODE" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Push release tag | |
| if: steps.release.outputs.already_published != 'true' && steps.release.outputs.tag_exists != 'true' | |
| env: | |
| BASE_TAG: ${{ steps.version.outputs.base_tag }} | |
| BASE_TAG_OBJECT: ${{ steps.version.outputs.base_tag_object }} | |
| GH_TOKEN: ${{ github.token }} | |
| TAG: ${{ steps.release.outputs.tag }} | |
| RELEASE_SHA: ${{ steps.release.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin master --tags | |
| current_master="$(git rev-parse origin/master)" | |
| if [[ "$current_master" != "$RELEASE_SHA" ]]; then | |
| echo "master advanced to $current_master after validation; restart the release." >&2 | |
| exit 1 | |
| fi | |
| remote_latest_tag="$(git ls-remote --refs --tags origin 'refs/tags/v*' | | |
| awk '{sub("refs/tags/", "", $2); print $2}' | | |
| sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p' | | |
| sort -V | tail -n 1)" | |
| if [[ "$remote_latest_tag" != "$BASE_TAG" ]]; then | |
| echo "Highest stable tag changed from $BASE_TAG to $remote_latest_tag; restart the release." >&2 | |
| exit 1 | |
| fi | |
| if [[ -n "$BASE_TAG" ]]; then | |
| remote_base_object="$(git ls-remote --refs --tags origin "refs/tags/$BASE_TAG" | awk 'NR == 1 {print $1}')" | |
| if [[ "$remote_base_object" != "$BASE_TAG_OBJECT" ]]; then | |
| echo "Base tag $BASE_TAG moved during this run; restart the release." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then | |
| echo "Remote tag $TAG appeared during this run; restart to validate it." >&2 | |
| exit 1 | |
| fi | |
| gh auth setup-git | |
| git push origin "refs/tags/$TAG" | |
| - name: Run GoReleaser | |
| if: steps.release.outputs.already_published != 'true' | |
| uses: goreleaser/goreleaser-action@v7 | |
| with: | |
| version: v2.18.0 | |
| args: release --config ${{ runner.temp }}/one-cli-goreleaser.yaml --clean | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GORELEASER_CURRENT_TAG: ${{ steps.release.outputs.tag }} | |
| - name: Verify draft release assets | |
| if: steps.release.outputs.already_published != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG: ${{ steps.release.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" | |
| if [[ "$is_draft" != "true" ]]; then | |
| echo "Release $TAG must remain a draft until its assets are verified." >&2 | |
| exit 1 | |
| fi | |
| expected_assets="$(printf '%s\n' \ | |
| checksums.txt \ | |
| one-cli_darwin_amd64.tar.gz \ | |
| one-cli_darwin_arm64.tar.gz \ | |
| one-cli_linux_amd64.tar.gz \ | |
| one-cli_linux_arm64.tar.gz \ | |
| one-cli_windows_amd64.zip | sort)" | |
| actual_assets="$(gh release view "$TAG" --json assets --jq '.assets[].name' | sort)" | |
| if [[ "$actual_assets" != "$expected_assets" ]]; then | |
| echo "Draft release $TAG has an incomplete or unexpected asset set." >&2 | |
| diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true | |
| exit 1 | |
| fi | |
| - name: Publish GitHub release | |
| if: steps.release.outputs.already_published != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG: ${{ steps.release.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| gh release edit "$TAG" --draft=false --latest | |
| is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" | |
| if [[ "$is_draft" != "false" ]]; then | |
| echo "Release $TAG is still a draft after publishing." >&2 | |
| exit 1 | |
| fi | |
| - name: Summarize release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG: ${{ steps.release.outputs.tag }} | |
| ALREADY_PUBLISHED: ${{ steps.release.outputs.already_published }} | |
| run: | | |
| set -euo pipefail | |
| release_url="$(gh release view "$TAG" --json url --jq '.url')" | |
| { | |
| echo "### Published" | |
| echo "- Version: $RELEASE_VERSION" | |
| echo "- Tag: $TAG" | |
| echo "- Release: $release_url" | |
| echo "- Already complete before this run: $ALREADY_PUBLISHED" | |
| } >> "$GITHUB_STEP_SUMMARY" |