#!/bin/sh set -eu SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" . "$SCRIPT_DIR/release-lib.sh" ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" CLOUD_RELEASE_URL="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml" CHANNEL="${1:-}" VERSION="${2:-}" REMOTE="" BRANCH="main" FROM_BETA="" PUBLISH=0 YES=0 OFFICIAL_TAGS_URL="${DWS_RELEASE_OFFICIAL_TAGS_URL:-https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git}" usage() { cat >&2 <<'EOF' usage: release.sh [options] Runs the full test, authoritative CLI/Schema compatibility, package, and install preflight. The default is validation only. Official publication is cloud-only. Options: --remote Required tag destination --from-beta Required for stable releases --publish Create a tag only for a non-official test or fork remote --yes Skip the interactive version confirmation EOF } [ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; } shift 2 while [ "$#" -gt 0 ]; do case "$1" in --remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;; --from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;; --publish) PUBLISH=1; shift ;; --yes) YES=1; shift ;; -h|--help) usage; exit 0 ;; *) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;; esac done release_validate_version_channel "$CHANNEL" "$VERSION" [ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; } cd "$ROOT" fetch_url="$(git remote get-url "$REMOTE" 2>/dev/null)" || { printf 'unknown release remote: %s\n' "$REMOTE" >&2; exit 1; } push_urls="$(git remote get-url --push --all "$REMOTE" 2>/dev/null)" || { printf 'could not resolve push URL for release remote: %s\n' "$REMOTE" >&2 exit 1 } [ "$(printf '%s\n' "$push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { printf 'release remote must have exactly one push URL: %s\n' "$REMOTE" >&2 exit 1 } push_url="$(printf '%s\n' "$push_urls" | sed -n '1p')" repository_identity() { identity_url="$1" case "$identity_url" in https://github.com/*) identity_path="${identity_url#https://github.com/}" ;; http://github.com/*) identity_path="${identity_url#http://github.com/}" ;; git://github.com/*) identity_path="${identity_url#git://github.com/}" ;; git@github.com:*) identity_path="${identity_url#git@github.com:}" ;; ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;; *) printf '%s\n' "$identity_url"; return 0 ;; esac identity_path="${identity_path%/}" identity_path="${identity_path%.git}" printf 'github.com/%s\n' "$identity_path" } fetch_identity="$(repository_identity "$fetch_url")" push_identity="$(repository_identity "$push_url")" normalized_push_identity="$( printf '%s\n' "$push_identity" | tr '[:upper:]' '[:lower:]' )" [ "$fetch_identity" = "$push_identity" ] || { printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' "$fetch_url" "$push_url" >&2 exit 1 } if [ "$PUBLISH" -eq 1 ] && [ "$normalized_push_identity" = "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then printf '%s\n' 'Direct tag publication to the official repository is disabled.' >&2 printf 'Use Actions -> Release -> Run workflow: %s\n' "$CLOUD_RELEASE_URL" >&2 exit 1 fi printf 'Release target: %s\n fetch: %s\n push: %s\n' "$REMOTE" "$fetch_url" "$push_url" github_repository_from_url() { github_identity="$(repository_identity "$1")" case "$github_identity" in github.com/*) printf '%s\n' "${github_identity#github.com/}" ;; *) return 1 ;; esac } require_delivered_previous_stable() { github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)" if [ -z "$github_repository" ]; then [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || { printf 'release validation requires a github.com remote to prove the stable baseline was delivered\n' >&2 return 1 } return 0 fi [ -n "$previous_stable" ] || { printf 'a delivered previous stable baseline is required\n' >&2 return 1 } stable_commit="$(git rev-parse "$previous_stable^{commit}")" DWS_RELEASE_OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" \ "$SCRIPT_DIR/verify-delivered-stable.sh" "$previous_stable" "$stable_commit" } require_github_publication_authority() { github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)" if [ -z "$github_repository" ]; then [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || { printf 'publishing requires a github.com remote so CI and delivery authority can be verified\n' >&2 return 1 } printf 'warning: GitHub publication checks explicitly disabled for non-GitHub test remote\n' >&2 return 0 fi command -v gh >/dev/null 2>&1 || { printf 'gh is required to verify CI and release authority before publishing\n' >&2 return 1 } active_runs="$( gh api -H 'Accept: application/vnd.github+json' \ "repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \ --jq '.workflow_runs[] | select(.status != "completed") | [.id, .event, .status, .head_branch] | @tsv' )" || { printf 'could not query active Release workflow runs for %s\n' "$github_repository" >&2 return 1 } [ -z "$active_runs" ] || { printf 'another Release workflow is still active; wait for it before allocating a new tag:\n%s\n' "$active_runs" >&2 return 1 } sealed_commit="$(git rev-parse HEAD)" admission_check_runs="$( gh api -H 'Accept: application/vnd.github+json' \ "repos/$github_repository/commits/$sealed_commit/check-runs?filter=latest&per_page=100" \ --jq '.check_runs | group_by(.name) | map(max_by(.id)) | .[] | [.name, (.conclusion // .status // "unknown")] | @tsv' )" || { printf 'could not query Code Admission contexts for %s\n' "$sealed_commit" >&2 return 1 } missing_contexts="" non_success_contexts="" while IFS= read -r required_context; do context_state="$( printf '%s\n' "$admission_check_runs" | awk -F '\t' -v required="$required_context" '$1 == required { state = $2 } END { print state }' )" if [ -z "$context_state" ]; then missing_contexts="${missing_contexts}${missing_contexts:+, }$required_context" elif [ "$context_state" != "success" ]; then non_success_contexts="${non_success_contexts}${non_success_contexts:+, }$required_context=$context_state" fi done <<'ADMISSION_CONTEXTS' Lint Test Coverage Policy Edition Interface Integrity AI Behavior CLI Smoke Mock MCP ADMISSION_CONTEXTS [ -z "$missing_contexts" ] && [ -z "$non_success_contexts" ] || { printf 'Code Admission contexts are not all successful for sealed commit %s in %s; missing: %s; non-success: %s\n' \ "$sealed_commit" \ "$github_repository" \ "${missing_contexts:-none}" \ "${non_success_contexts:-none}" >&2 return 1 } if [ "$CHANNEL" = "stable" ]; then beta_state="$( gh api -H 'Accept: application/vnd.github+json' \ "repos/$github_repository/releases/tags/$FROM_BETA" \ --jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv' )" || { printf 'could not query beta release %s in %s\n' "$FROM_BETA" "$github_repository" >&2 return 1 } [ "$beta_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" ] || { printf '%s must be a public immutable prerelease before stable tag allocation (got: %s)\n' "$FROM_BETA" "$beta_state" >&2 return 1 } beta_assets="$( gh api -H 'Accept: application/vnd.github+json' \ "repos/$github_repository/releases/tags/$FROM_BETA" \ --jq '.assets[].name' )" || return 1 [ "$(printf '%s\n' "$beta_assets" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 8 ] || { printf 'beta release %s must contain exactly eight supported assets\n' "$FROM_BETA" >&2 return 1 } for beta_asset in \ dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz \ dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz \ dws-windows-amd64.zip dws-windows-arm64.zip \ dws-skills.zip checksums.txt; do [ "$(printf '%s\n' "$beta_assets" | grep -Fxc "$beta_asset")" -eq 1 ] || { printf 'beta release %s must contain %s exactly once\n' "$FROM_BETA" "$beta_asset" >&2 return 1 } done beta_commit="$(git rev-parse "$FROM_BETA^{commit}")" DWS_RELEASE_OFFICIAL_REPOSITORY="$github_repository" \ "$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$FROM_BETA" "$beta_commit" || return 1 fi } require_release_governance_ci() { governance_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)" if [ -z "$governance_repository" ]; then [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || { printf 'release governance preflight requires a github.com publication remote\n' >&2 return 1 } printf 'warning: Release governance CI preflight explicitly disabled for non-GitHub test remote\n' >&2 return 0 fi "$SCRIPT_DIR/verify-release-governance-ci.sh" \ "$governance_repository" \ "$(git rev-parse HEAD)" } preflight_proof_path() { proof_dir="$(git rev-parse --git-path dws-release-preflight)" printf '%s/%s.proof\n' "$proof_dir" "$VERSION" } preflight_proof_is_reusable() { proof_file="$1" [ -f "$proof_file" ] || return 1 [ "$(wc -l < "$proof_file" | tr -d '[:space:]')" -eq 9 ] || return 1 [ "$(sed -n 's/^format=//p' "$proof_file")" = "1" ] || return 1 [ "$(sed -n 's/^channel=//p' "$proof_file")" = "$CHANNEL" ] || return 1 [ "$(sed -n 's/^version=//p' "$proof_file")" = "$VERSION" ] || return 1 [ "$(sed -n 's/^commit=//p' "$proof_file")" = "$(git rev-parse HEAD)" ] || return 1 [ "$(sed -n 's/^repository=//p' "$proof_file")" = "$push_identity" ] || return 1 [ "$(sed -n 's/^branch=//p' "$proof_file")" = "$BRANCH" ] || return 1 [ "$(sed -n 's/^from_beta=//p' "$proof_file")" = "$FROM_BETA" ] || return 1 [ "$(sed -n 's/^previous_stable=//p' "$proof_file")" = "$previous_stable" ] || return 1 proof_created_at="$(sed -n 's/^created_at=//p' "$proof_file")" printf '%s\n' "$proof_created_at" | grep -Eq '^[0-9]+$' || return 1 proof_now="$(date +%s)" [ "$proof_created_at" -le "$proof_now" ] || return 1 [ $((proof_now - proof_created_at)) -le 21600 ] || return 1 } write_preflight_proof() { proof_file="$1" proof_dir="$(dirname "$proof_file")" umask 077 mkdir -p "$proof_dir" proof_tmp="${proof_file}.tmp.$$" { printf 'format=1\n' printf 'channel=%s\n' "$CHANNEL" printf 'version=%s\n' "$VERSION" printf 'commit=%s\n' "$(git rev-parse HEAD)" printf 'repository=%s\n' "$push_identity" printf 'branch=%s\n' "$BRANCH" printf 'from_beta=%s\n' "$FROM_BETA" printf 'previous_stable=%s\n' "$previous_stable" printf 'created_at=%s\n' "$(date +%s)" } > "$proof_tmp" mv "$proof_tmp" "$proof_file" } build_policy_binary() { policy_tmp="$ROOT/tmp/go-build" mkdir -p "$policy_tmp" GOTMPDIR="$policy_tmp" make build if [ "$(uname -s)" = "Darwin" ] && [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" != "1" ]; then command -v codesign >/dev/null 2>&1 || { printf '%s\n' 'codesign is required to prepare the macOS policy binary' >&2 return 1 } codesign --force --sign - "$ROOT/dws" codesign --verify --strict "$ROOT/dws" fi } fetch_release_tags() { git fetch --force "$REMOTE" \ '+refs/tags/v*:refs/tags/v*' \ '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' git fetch --force --no-tags "$OFFICIAL_TAGS_URL" \ '+refs/tags/v*:refs/tags/v*' \ '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' } printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH" git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH" fetch_release_tags metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-metadata.XXXXXX")" final_metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-final-metadata.XXXXXX")" cleanup() { rm -f "$metadata" "$final_metadata"; } trap cleanup EXIT HUP INT TERM run_contract() { if [ -n "$FROM_BETA" ]; then "$SCRIPT_DIR/release-contract.sh" \ --channel "$CHANNEL" \ --version "$VERSION" \ --context local \ --remote "$REMOTE" \ --branch "$BRANCH" \ --from-beta "$FROM_BETA" \ "$@" else "$SCRIPT_DIR/release-contract.sh" \ --channel "$CHANNEL" \ --version "$VERSION" \ --context local \ --remote "$REMOTE" \ --branch "$BRANCH" \ "$@" fi } run_contract --metadata-output "$metadata" previous_stable="$(sed -n 's/^previous_stable=//p' "$metadata" | tail -1)" printf '==> Verifying delivered stable baseline %s\n' "${previous_stable:-none}" require_delivered_previous_stable printf '==> Verifying GitHub publication authority before local gates\n' require_github_publication_authority proof_path="$(preflight_proof_path)" ran_expensive_gates=0 if [ "$PUBLISH" -eq 1 ] && preflight_proof_is_reusable "$proof_path"; then printf '==> Reusing exact preflight proof for %s at %s\n' "$VERSION" "$(git rev-parse HEAD)" else rm -f "$proof_path" printf '==> Running repository test, build, and policy gates\n' make test # Installer tests intentionally exercise source builds. Rebuild the release # binary before policy so policy never depends on a pre-existing ./dws. build_policy_binary make policy if [ -n "$previous_stable" ]; then printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable" "$SCRIPT_DIR/check-release-compatibility.sh" \ --repo-root "$ROOT" \ --base-ref HEAD \ --stable-ref "$previous_stable" \ --candidate-ref HEAD fi printf '==> Building local release artifacts for %s\n' "$VERSION" make package VERSION="$VERSION" printf '==> Verifying release artifact set and checksums\n' "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION" printf '==> Verifying npm package installation\n' "$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION" if command -v brew >/dev/null 2>&1; then printf '==> Verifying Homebrew package installation\n' "$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION" fi ran_expensive_gates=1 fi # Collect human confirmation before the final remote validations. if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then if [ ! -t 0 ]; then printf 'interactive confirmation is unavailable; pass --yes after reviewing the preflight\n' >&2 exit 1 fi printf 'Type %s to create and push the release tag: ' "$VERSION" IFS= read -r confirmation [ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; } fi printf '==> Verifying the Release workflow publication identity\n' require_release_governance_ci # Refresh after every local gate and the remote governance check. dist/ is # ignored and therefore does not make the tree dirty. printf '==> Refreshing %s/%s before sealing the tag\n' "$REMOTE" "$BRANCH" git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH" fetch_release_tags run_contract --metadata-output "$final_metadata" final_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)" [ -n "$final_previous_stable" ] || { printf 'latest stable authority unexpectedly became empty\n' >&2 exit 1 } previous_stable_before_refresh="$previous_stable" previous_stable="$final_previous_stable" printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable" require_delivered_previous_stable if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then printf '==> Stable authority advanced from %s to %s; rechecking authoritative CLI and Schema compatibility\n' \ "${previous_stable_before_refresh:-none}" "$previous_stable" "$SCRIPT_DIR/check-release-compatibility.sh" \ --repo-root "$ROOT" \ --base-ref HEAD \ --stable-ref "$previous_stable" \ --candidate-ref HEAD fi if [ "$PUBLISH" -eq 1 ]; then printf '==> Reconfirming GitHub publication authority on the settled baseline\n' require_github_publication_authority fi # Delivery, compatibility, and publication checks above may take long enough # for main or stable authority to move. This last refresh must be followed only # by local proof/tag creation. Only the tag is pushed: the sealed commit is # already contained in protected main history, so a later main advance never # invalidates the release. printf '==> Settling final %s/%s and stable authority\n' "$REMOTE" "$BRANCH" git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH" fetch_release_tags run_contract --metadata-output "$final_metadata" settled_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)" [ "$settled_previous_stable" = "$previous_stable" ] || { printf 'stable authority moved from %s to %s during final validation; rerun the release command\n' \ "$previous_stable" "$settled_previous_stable" >&2 exit 1 } if [ "$ran_expensive_gates" -eq 1 ] && [ "$normalized_push_identity" != "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then write_preflight_proof "$proof_path" fi if [ "$PUBLISH" -ne 1 ]; then printf '\nPreflight passed. No tag was created.\n' if [ "$normalized_push_identity" = "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then printf 'Publish from Actions -> Release -> Run workflow: %s\n' "$CLOUD_RELEASE_URL" else printf 'Publish within six hours with the same command plus --publish to reuse this exact proof.\n' fi exit 0 fi printf '==> Creating annotated tag %s\n' "$VERSION" if [ "$CHANNEL" = "stable" ]; then git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: stable' -m "From-Beta: $FROM_BETA" else git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease' fi if ! git push "$push_url" "refs/tags/$VERSION"; then set +e remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" query_status=$? set -e if [ "$query_status" -ne 0 ]; then printf 'tag push reported failure and remote state could not be verified; keeping local tag %s for investigation\n' "$VERSION" >&2 exit 1 fi remote_object="$(printf '%s\n' "$remote_refs" | awk '$2 !~ /\^\{\}$/ { print $1; exit }')" local_object="$(git rev-parse "refs/tags/$VERSION")" if [ -z "$remote_object" ]; then git tag -d "$VERSION" >/dev/null 2>&1 || true printf 'tag push failed; remote has no tag and the new local tag was removed: %s\n' "$VERSION" >&2 exit 1 fi if [ "$remote_object" != "$local_object" ]; then printf 'tag push failed and remote %s points elsewhere; keeping local tag for investigation\n' "$VERSION" >&2 exit 1 fi printf 'warning: push reported failure, but push target %s has the exact sealed tag; treating it as published\n' "$push_url" >&2 fi printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url" rm -f "$proof_path"