fix(release): add fast guarded recovery path
This commit is contained in:
+644
-91
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,12 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
### Changed
|
||||
|
||||
- **Personal event structured output is now flat** — `event consume` projects NDJSON/JSON/pretty/compact output into event-specific top-level DTOs, so consumers read fields such as `content`, `sender`, and `conversation_id` directly instead of parsing `.data | fromjson`. This is a breaking change for scripts using the former transport envelope; the original server payload remains available through `-f raw`, while `--debug-raw-events` preserves the full diagnostic envelope.
|
||||
- **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated.
|
||||
- **Protected existing-tag recovery** — `dws-release recover <version>` can resume a failed, unpublished annotated tag through the normal contract, build, Developer ID signing, immutable GitHub Release, Homebrew, npm, and OSS jobs. Recovery requires the exact tag object, peeled commit, failed tag-push run, typed version confirmation, and the protected `release-recovery` environment; successful runs are accepted as future beta/stable delivery evidence.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
|
||||
|
||||
## [1.0.53-beta.3] - 2026-07-17
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ rebuild:
|
||||
@./scripts/dev/build.sh
|
||||
|
||||
test:
|
||||
@./test/scripts/run_all_tests.sh
|
||||
@./test/scripts/run_all_tests.sh --timeout 5m
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -80,6 +80,25 @@ built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
|
||||
## Release Governance and Recovery
|
||||
|
||||
Store `RELEASE_GOVERNANCE_TOKEN` as a dedicated Actions secret with only
|
||||
repository `Administration: read`. The immutable-releases REST endpoint is an
|
||||
administration setting and cannot be read by the workflow's built-in
|
||||
`GITHUB_TOKEN`. Both the default-branch governance preflight and the tag
|
||||
contract use this same credential so a missing or expired identity is detected
|
||||
before an irreversible tag is created.
|
||||
|
||||
Create a protected `release-recovery` environment limited to protected
|
||||
branches, with a required reviewer, self-review disabled, and administrator
|
||||
bypass disabled. The workflow reads the environment through the GitHub API and
|
||||
fails closed unless the required-reviewer, prevent-self-review, and protected-
|
||||
branch rules are present.
|
||||
Recovery is restricted to an existing annotated tag whose exact tag object,
|
||||
commit, and failed tag-push run all match; it then reuses the normal release
|
||||
jobs. Do not put publication secrets in temporary branches or create ad-hoc
|
||||
recovery workflows.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
Before handoff, include:
|
||||
|
||||
+23
-3
@@ -45,13 +45,13 @@ dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查 `CI Gate` 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
|
||||
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -99,15 +99,35 @@ npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_versio
|
||||
|
||||
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
|
||||
|
||||
## 既有 tag 的紧急恢复
|
||||
|
||||
tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
|
||||
|
||||
```bash
|
||||
dws-release recover v1.2.3-beta.1
|
||||
```
|
||||
|
||||
命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
|
||||
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。
|
||||
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
|
||||
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
|
||||
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
|
||||
|
||||
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
|
||||
|
||||
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
|
||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -16,6 +16,7 @@ usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: dws-release [version] [options]
|
||||
dws-release config [--remote <name>]
|
||||
dws-release recover <version> [--failed-run <run-id>] [--remote <name>]
|
||||
|
||||
With no arguments, starts an interactive release guide. For a version that has
|
||||
no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the
|
||||
@@ -34,6 +35,7 @@ Examples:
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
dws-release recover v1.2.3-beta.1
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -191,9 +193,46 @@ configure_remote() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
recover_release() {
|
||||
shift
|
||||
recovery_version="${1:-}"
|
||||
[ -n "$recovery_version" ] || die_usage 'recover requires a release version'
|
||||
case "$recovery_version" in -h|--help) usage; exit 0 ;; esac
|
||||
shift
|
||||
recovery_failed_run=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--failed-run)
|
||||
[ "$#" -ge 2 ] || die_usage '--failed-run requires a workflow run ID'
|
||||
recovery_failed_run="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remote)
|
||||
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
|
||||
REMOTE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die_usage "unknown recovery argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$ROOT"
|
||||
require_remote
|
||||
sync_main_if_safe
|
||||
set -- "$recovery_version" --remote "$REMOTE"
|
||||
if [ -n "$recovery_failed_run" ]; then
|
||||
set -- "$@" --failed-run "$recovery_failed_run"
|
||||
fi
|
||||
exec "$SCRIPT_DIR/recover-release.sh" "$@"
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "config" ]; then
|
||||
configure_remote "$@"
|
||||
fi
|
||||
if [ "${1:-}" = "recover" ]; then
|
||||
recover_release "$@"
|
||||
fi
|
||||
|
||||
[ "$#" -gt 0 ] || WIZARD=1
|
||||
while [ "$#" -gt 0 ]; do
|
||||
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
VERSION="${1:-}"
|
||||
REMOTE=""
|
||||
FAILED_RUN_ID=""
|
||||
EXPECTED_REPOSITORY="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>]
|
||||
|
||||
Recovers one failed existing release tag through the protected default-branch
|
||||
Release workflow. It never creates, moves, or deletes a tag.
|
||||
EOF
|
||||
}
|
||||
|
||||
[ -n "$VERSION" ] || { usage; exit 2; }
|
||||
shift
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
|
||||
--failed-run) [ "$#" -ge 2 ] || { usage; exit 2; }; FAILED_RUN_ID="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown recovery argument: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
channel="$(release_channel_for_version "$VERSION")" || exit 2
|
||||
release_validate_version_channel "$channel" "$VERSION"
|
||||
[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; }
|
||||
if [ -n "$FAILED_RUN_ID" ]; then
|
||||
printf '%s\n' "$FAILED_RUN_ID" | grep -Eq '^[1-9][0-9]*$' || {
|
||||
printf 'invalid failed Release run ID: %s\n' "$FAILED_RUN_ID" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
command -v gh >/dev/null 2>&1 || { printf '%s\n' 'gh is required for release recovery' >&2; exit 1; }
|
||||
|
||||
cd "$ROOT"
|
||||
[ "$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" = "main" ] || {
|
||||
printf '%s\n' 'release recovery must run from the main worktree' >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
|
||||
printf '%s\n' 'release recovery requires a clean main worktree' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
push_url="$(git remote get-url --push "$REMOTE" 2>/dev/null)" || {
|
||||
printf 'unknown release remote: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$push_url" in
|
||||
https://github.com/*) repository_path="${push_url#https://github.com/}" ;;
|
||||
git@github.com:*) repository_path="${push_url#git@github.com:}" ;;
|
||||
ssh://git@github.com/*) repository_path="${push_url#ssh://git@github.com/}" ;;
|
||||
*) printf 'release recovery requires the official GitHub remote, got: %s\n' "$push_url" >&2; exit 1 ;;
|
||||
esac
|
||||
repository_path="${repository_path%/}"
|
||||
repository_path="${repository_path%.git}"
|
||||
[ "$repository_path" = "$EXPECTED_REPOSITORY" ] || {
|
||||
printf 'release recovery is restricted to %s, got %s\n' "$EXPECTED_REPOSITORY" "$repository_path" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf '==> Refreshing main and exact recovery tag %s\n' "$VERSION"
|
||||
git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main"
|
||||
recovery_ref="refs/dws-release-recovery/$VERSION"
|
||||
cleanup() { git update-ref -d "$recovery_ref" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref"
|
||||
[ "$(git cat-file -t "$recovery_ref")" = "tag" ] || {
|
||||
printf '%s must be an annotated tag\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
tag_object="$(git rev-parse "$recovery_ref")"
|
||||
commit="$(git rev-parse "$recovery_ref^{commit}")"
|
||||
git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || {
|
||||
printf '%s commit %s is not contained in %s/main\n' "$VERSION" "$commit" "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -z "$FAILED_RUN_ID" ]; then
|
||||
failed_runs="$(
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
"repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?branch=$VERSION&event=push&status=completed&per_page=100" \
|
||||
--jq ".workflow_runs[] | select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\" and (.conclusion as \\$c | [\"failure\", \"cancelled\", \"timed_out\", \"startup_failure\", \"stale\"] | index(\\$c))) | .id"
|
||||
)" || {
|
||||
printf 'could not query failed Release runs for %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
FAILED_RUN_ID="$(printf '%s\n' "$failed_runs" | sed -n '1p')"
|
||||
[ -n "$FAILED_RUN_ID" ] || {
|
||||
printf 'no failed exact-tag Release run found for %s at %s\n' "$VERSION" "$commit" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
printf 'Recovery target:\n'
|
||||
printf ' version: %s\n' "$VERSION"
|
||||
printf ' tag object: %s\n' "$tag_object"
|
||||
printf ' commit: %s\n' "$commit"
|
||||
printf ' failed run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$FAILED_RUN_ID"
|
||||
[ -t 0 ] || { printf '%s\n' 'interactive recovery confirmation is required' >&2; exit 1; }
|
||||
printf 'Type %s to request protected recovery: ' "$VERSION"
|
||||
IFS= read -r confirmation
|
||||
[ "$confirmation" = "$VERSION" ] || { printf '%s\n' 'release recovery cancelled' >&2; exit 1; }
|
||||
|
||||
nonce="${commit}-$(date +%s)-$$"
|
||||
workflow_sha="$(git rev-parse "refs/remotes/$REMOTE/main")"
|
||||
gh workflow run release.yml \
|
||||
--repo "$EXPECTED_REPOSITORY" \
|
||||
--ref main \
|
||||
-f "recover_release_version=$VERSION" \
|
||||
-f "recover_release_tag_object=$tag_object" \
|
||||
-f "recover_release_commit=$commit" \
|
||||
-f "recover_failed_run_id=$FAILED_RUN_ID" \
|
||||
-f "recover_release_nonce=$nonce" \
|
||||
-f "recover_release_confirmation=$VERSION"
|
||||
|
||||
expected_title="Release recovery $VERSION at $commit $nonce"
|
||||
started_at="$(date +%s)"
|
||||
run_id=""
|
||||
while :; do
|
||||
run_id="$(
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
"repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?event=workflow_dispatch&branch=main&per_page=100" \
|
||||
--jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$workflow_sha\") | .id" \
|
||||
| sed -n '1p'
|
||||
)" || exit 1
|
||||
[ -z "$run_id" ] || break
|
||||
now="$(date +%s)"
|
||||
[ $((now - started_at)) -lt 60 ] || {
|
||||
printf 'recovery was dispatched but its run could not be located: %s\n' "$expected_title" >&2
|
||||
exit 1
|
||||
}
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf 'Protected recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id"
|
||||
printf '%s\n' 'Approve the release-recovery environment when prompted; this command will follow the run.'
|
||||
if ! gh run watch "$run_id" --repo "$EXPECTED_REPOSITORY" --exit-status; then
|
||||
gh run view "$run_id" --repo "$EXPECTED_REPOSITORY" --log-failed >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
printf 'Release recovery completed: %s\n' "$VERSION"
|
||||
+158
-68
@@ -120,21 +120,6 @@ require_github_publication_authority() {
|
||||
return 1
|
||||
}
|
||||
|
||||
immutable_enabled="$(
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$github_repository/immutable-releases" \
|
||||
--jq '.enabled'
|
||||
)" || {
|
||||
printf 'immutable releases are not enabled for %s; enable them before allocating a tag\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$immutable_enabled" = "true" ] || {
|
||||
printf 'immutable releases are not enabled for %s\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
active_runs="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \
|
||||
@@ -195,18 +180,82 @@ require_github_publication_authority() {
|
||||
}
|
||||
done
|
||||
beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
|
||||
beta_runs="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/actions/workflows/release.yml/runs?branch=$FROM_BETA&event=push&status=completed&per_page=100" \
|
||||
--jq '.workflow_runs[] | [.head_sha, .head_branch, .conclusion] | @tsv'
|
||||
)" || return 1
|
||||
printf '%s\n' "$beta_runs" | awk -F '\t' -v sha="$beta_commit" -v tag="$FROM_BETA" '
|
||||
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
' || {
|
||||
printf 'Release workflow did not succeed for %s at %s\n' "$FROM_BETA" "$beta_commit" >&2
|
||||
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
|
||||
}
|
||||
|
||||
@@ -251,37 +300,45 @@ 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
|
||||
|
||||
if [ "$PUBLISH" -eq 1 ]; then
|
||||
printf '==> Verifying GitHub publication authority before local gates\n'
|
||||
require_github_publication_authority
|
||||
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 command tree with %s\n' "$previous_stable"
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
--base-ref "$REMOTE/$BRANCH" \
|
||||
--stable-ref "$previous_stable"
|
||||
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
|
||||
|
||||
printf '==> Running repository test and policy gates\n'
|
||||
make test
|
||||
make policy
|
||||
|
||||
if [ -n "$previous_stable" ]; then
|
||||
printf '==> Comparing command tree with %s\n' "$previous_stable"
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
--base-ref "$REMOTE/$BRANCH" \
|
||||
--stable-ref "$previous_stable"
|
||||
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
|
||||
|
||||
# Collect human confirmation before the final authority refresh. Nothing may
|
||||
# block between that refresh and tag creation.
|
||||
# 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
|
||||
@@ -292,34 +349,65 @@ if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
|
||||
[ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# Re-check after every local gate so tag creation cannot race with a modified
|
||||
# source tree. dist/ is ignored and therefore does not make the tree dirty.
|
||||
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)"
|
||||
if [ "$final_previous_stable" != "$previous_stable" ]; then
|
||||
[ -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 command compatibility\n' \
|
||||
"${previous_stable:-none}" "${final_previous_stable:-none}"
|
||||
[ -n "$final_previous_stable" ] || {
|
||||
printf 'latest stable authority unexpectedly became empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
"${previous_stable_before_refresh:-none}" "$previous_stable"
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
--base-ref "$REMOTE/$BRANCH" \
|
||||
--stable-ref "$final_previous_stable"
|
||||
--stable-ref "$previous_stable"
|
||||
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. The atomic push advertises main with the tag, so
|
||||
# an already-advanced remote main rejects the whole transaction; a later main
|
||||
# advance is safe because the sealed commit remains in protected main history.
|
||||
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 ]; then
|
||||
write_preflight_proof "$proof_path"
|
||||
fi
|
||||
|
||||
if [ "$PUBLISH" -ne 1 ]; then
|
||||
printf '\nPreflight passed. No tag was created.\n'
|
||||
printf 'Publish with the same command plus --publish.\n'
|
||||
printf 'Publish within six hours with the same command plus --publish to reuse this exact proof.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '==> Reconfirming GitHub publication authority immediately before tag creation\n'
|
||||
require_github_publication_authority
|
||||
|
||||
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"
|
||||
@@ -327,7 +415,8 @@ else
|
||||
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
|
||||
fi
|
||||
|
||||
if ! git push "$push_url" "refs/tags/$VERSION"; then
|
||||
if ! git push --atomic "$push_url" \
|
||||
"HEAD:refs/heads/$BRANCH" "refs/tags/$VERSION"; then
|
||||
set +e
|
||||
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
|
||||
query_status=$?
|
||||
@@ -351,3 +440,4 @@ if ! git push "$push_url" "refs/tags/$VERSION"; then
|
||||
fi
|
||||
|
||||
printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url"
|
||||
rm -f "$proof_path"
|
||||
|
||||
@@ -79,6 +79,14 @@ printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TA
|
||||
' && delivered_by_push=1 || delivered_by_push=0
|
||||
|
||||
if [ "$delivered_by_push" -ne 1 ]; then
|
||||
if DWS_RELEASE_OFFICIAL_REPOSITORY="$REPOSITORY" \
|
||||
DWS_RELEASE_GITHUB_TOKEN="$API_TOKEN" \
|
||||
"$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$TAG" "$EXPECTED_COMMIT" 2>/dev/null; then
|
||||
printf 'Delivered stable baseline verified through protected default-branch recovery: %s -> %s\n' \
|
||||
"$TAG" "$EXPECTED_COMMIT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
recovery_proof="$(
|
||||
python3 - "$RECOVERY_MANIFEST" "$TAG" "$EXPECTED_COMMIT" <<'PY'
|
||||
import json
|
||||
|
||||
@@ -3,15 +3,21 @@ set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_COMMIT="${2:-}"
|
||||
EXPECTED_TAG_OBJECT="${3:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit>\n' >&2
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit> [tag-object]\n' >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||
|
||||
local_object="$(git rev-parse "refs/tags/$TAG")"
|
||||
if [ -n "$EXPECTED_TAG_OBJECT" ] && [ "$local_object" != "$EXPECTED_TAG_OBJECT" ]; then
|
||||
printf 'local tag object for %s changed (expected=%s actual=%s)\n' \
|
||||
"$TAG" "$EXPECTED_TAG_OBJECT" "$local_object" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_ref="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/git/ref/tags/$TAG" \
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REPOSITORY="${1:-}"
|
||||
COMMIT="${2:-}"
|
||||
WORKFLOW="release.yml"
|
||||
DEFAULT_BRANCH="main"
|
||||
TIMEOUT_SECONDS="${DWS_RELEASE_GOVERNANCE_TIMEOUT_SECONDS:-180}"
|
||||
POLL_SECONDS="${DWS_RELEASE_GOVERNANCE_POLL_SECONDS:-2}"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' 'usage: verify-release-governance-ci.sh <owner/repository> <commit-sha>' >&2
|
||||
}
|
||||
|
||||
[ -n "$REPOSITORY" ] && [ -n "$COMMIT" ] || { usage; exit 2; }
|
||||
printf '%s\n' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$' || {
|
||||
printf 'invalid release commit: %s\n' "$COMMIT" >&2
|
||||
exit 2
|
||||
}
|
||||
printf '%s\n' "$TIMEOUT_SECONDS" | grep -Eq '^[1-9][0-9]*$' || {
|
||||
printf 'invalid governance timeout: %s\n' "$TIMEOUT_SECONDS" >&2
|
||||
exit 2
|
||||
}
|
||||
printf '%s\n' "$POLL_SECONDS" | grep -Eq '^[1-9][0-9]*$' || {
|
||||
printf 'invalid governance poll interval: %s\n' "$POLL_SECONDS" >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || {
|
||||
printf '%s\n' 'gh is required to run the Release governance preflight' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
nonce="${COMMIT}-$(date +%s)-$$"
|
||||
expected_title="Release governance preflight $nonce"
|
||||
|
||||
printf '==> Dispatching Release governance preflight for %s\n' "$COMMIT"
|
||||
gh workflow run "$WORKFLOW" \
|
||||
--repo "$REPOSITORY" \
|
||||
--ref "$DEFAULT_BRANCH" \
|
||||
-f "governance_preflight_commit=$COMMIT" \
|
||||
-f "governance_preflight_nonce=$nonce"
|
||||
|
||||
started_at="$(date +%s)"
|
||||
run_id=""
|
||||
while :; do
|
||||
# nonce is restricted to a commit SHA, decimal timestamp, PID, and hyphens,
|
||||
# so it is safe to embed in this jq string literal.
|
||||
run_id="$(
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/actions/workflows/$WORKFLOW/runs?event=workflow_dispatch&branch=$DEFAULT_BRANCH&per_page=100" \
|
||||
--jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$COMMIT\") | .id" \
|
||||
| sed -n '1p'
|
||||
)" || {
|
||||
printf '%s\n' 'could not query the dispatched Release governance preflight' >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "$run_id" ] || break
|
||||
|
||||
now="$(date +%s)"
|
||||
if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then
|
||||
printf 'timed out waiting for Release governance preflight: %s\n' "$expected_title" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
|
||||
run_state=""
|
||||
while :; do
|
||||
run_state="$(
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/actions/runs/$run_id" \
|
||||
--jq '[.display_title, .event, .status, .conclusion, .head_branch, .head_sha, .path, .repository.full_name] | @tsv'
|
||||
)" || {
|
||||
printf 'could not verify Release governance preflight run %s\n' "$run_id" >&2
|
||||
exit 1
|
||||
}
|
||||
run_status="$(printf '%s\n' "$run_state" | cut -f3)"
|
||||
[ "$run_status" != "completed" ] || break
|
||||
now="$(date +%s)"
|
||||
if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then
|
||||
printf 'timed out waiting for Release governance preflight run %s\n' "$run_id" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
|
||||
expected_state="$(printf '%s\tworkflow_dispatch\tcompleted\tsuccess\t%s\t%s\t.github/workflows/release.yml\t%s' \
|
||||
"$expected_title" "$DEFAULT_BRANCH" "$COMMIT" "$REPOSITORY")"
|
||||
[ "$run_state" = "$expected_state" ] || {
|
||||
printf 'Release governance preflight run identity mismatch:\n expected: %s\n actual: %s\n' \
|
||||
"$expected_state" "$run_state" >&2
|
||||
gh run view "$run_id" --repo "$REPOSITORY" --log-failed >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'Release governance preflight passed: https://github.com/%s/actions/runs/%s\n' \
|
||||
"$REPOSITORY" "$run_id"
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_COMMIT="${2:-}"
|
||||
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
|
||||
printf 'usage: verify-release-workflow-delivery.sh <tag> <commit>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then
|
||||
printf 'invalid delivered release tag: %s\n' "$TAG" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '%s\n' "$EXPECTED_COMMIT" | grep -Eq '^[0-9a-f]{40}$' || {
|
||||
printf 'invalid delivered release commit: %s\n' "$EXPECTED_COMMIT" >&2
|
||||
exit 2
|
||||
}
|
||||
command -v curl >/dev/null 2>&1 || { printf '%s\n' 'curl is required to verify release delivery' >&2; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { printf '%s\n' 'python3 is required to verify release delivery' >&2; exit 1; }
|
||||
|
||||
API_TOKEN="${DWS_RELEASE_GITHUB_TOKEN:-}"
|
||||
if [ -z "$API_TOKEN" ] && [ "${GITHUB_ACTIONS:-false}" != "true" ] && command -v gh >/dev/null 2>&1; then
|
||||
API_TOKEN="$(gh auth token 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$API_TOKEN" ]; then API_TOKEN="${GITHUB_TOKEN:-}"; fi
|
||||
|
||||
github_get() {
|
||||
if [ -n "$API_TOKEN" ]; then
|
||||
curl -fsSL \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
-H "Authorization: Bearer $API_TOKEN" \
|
||||
"https://api.github.com/$1" 2>/dev/null && return 0
|
||||
fi
|
||||
curl -fsSL \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"https://api.github.com/$1"
|
||||
}
|
||||
|
||||
find_push_delivery() {
|
||||
page=1
|
||||
while :; do
|
||||
page_result="$(
|
||||
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100&page=$page" \
|
||||
| python3 -c 'import json,sys
|
||||
tag,commit=sys.argv[1:]
|
||||
runs=json.load(sys.stdin).get("workflow_runs", [])
|
||||
print(len(runs))
|
||||
for run in runs:
|
||||
if run.get("head_sha") == commit and run.get("head_branch") == tag and run.get("conclusion") == "success":
|
||||
print(run.get("id", ""))
|
||||
break' "$TAG" "$EXPECTED_COMMIT"
|
||||
)" || return 1
|
||||
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
|
||||
page_match="$(printf '%s\n' "$page_result" | sed -n '2p')"
|
||||
if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi
|
||||
[ "$page_count" -eq 100 ] || return 1
|
||||
page=$((page + 1))
|
||||
done
|
||||
}
|
||||
|
||||
push_delivery="$(find_push_delivery || true)"
|
||||
if [ -n "$push_delivery" ]; then
|
||||
printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \
|
||||
"$push_delivery" "$TAG" "$EXPECTED_COMMIT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
find_recovery_identity() {
|
||||
page=1
|
||||
while :; do
|
||||
page_result="$(
|
||||
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=main&event=workflow_dispatch&status=completed&per_page=100&page=$page" \
|
||||
| python3 -c 'import json,sys
|
||||
tag,commit,repository=sys.argv[1:]
|
||||
title=f"Release recovery {tag} at {commit}"
|
||||
runs=json.load(sys.stdin).get("workflow_runs", [])
|
||||
print(len(runs))
|
||||
for run in runs:
|
||||
display=run.get("display_title", "")
|
||||
nonce=display[len(title) + 1:] if display.startswith(title + " ") else ""
|
||||
if (__import__("re").fullmatch(__import__("re").escape(commit) + r"-[0-9]+-[0-9]+", nonce)
|
||||
and run.get("event") == "workflow_dispatch"
|
||||
and run.get("status") == "completed" and run.get("conclusion") == "success"
|
||||
and run.get("head_branch") == "main" and run.get("path") == ".github/workflows/release.yml"
|
||||
and run.get("repository", {}).get("full_name") == repository):
|
||||
print("%s\t%s" % (run.get("id", ""), run.get("head_sha", "")))
|
||||
break' "$TAG" "$EXPECTED_COMMIT" "$REPOSITORY"
|
||||
)" || return 1
|
||||
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
|
||||
page_match="$(printf '%s\n' "$page_result" | sed -n '2p')"
|
||||
if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi
|
||||
[ "$page_count" -eq 100 ] || return 1
|
||||
page=$((page + 1))
|
||||
done
|
||||
}
|
||||
|
||||
recovery_identity="$(find_recovery_identity || true)"
|
||||
[ -n "$recovery_identity" ] || {
|
||||
printf 'Release workflow did not deliver %s at %s through a tag push or protected recovery\n' \
|
||||
"$TAG" "$EXPECTED_COMMIT" >&2
|
||||
exit 1
|
||||
}
|
||||
recovery_run_id="$(printf '%s\n' "$recovery_identity" | cut -f1)"
|
||||
recovery_workflow_sha="$(printf '%s\n' "$recovery_identity" | cut -f2)"
|
||||
|
||||
workflow_status="$({
|
||||
github_get "repos/$REPOSITORY/compare/$recovery_workflow_sha...main" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", ""))'
|
||||
} || true)"
|
||||
case "$workflow_status" in ahead|identical) ;; *)
|
||||
printf 'protected recovery workflow %s is not contained in current main\n' \
|
||||
"$recovery_workflow_sha" >&2
|
||||
exit 1
|
||||
esac
|
||||
|
||||
passed_jobs=""
|
||||
page=1
|
||||
while :; do
|
||||
page_result="$(
|
||||
github_get "repos/$REPOSITORY/actions/runs/$recovery_run_id/jobs?filter=all&per_page=100&page=$page" \
|
||||
| python3 -c 'import json,sys
|
||||
workflow_sha=sys.argv[1]
|
||||
jobs=json.load(sys.stdin).get("jobs", [])
|
||||
print(len(jobs))
|
||||
for job in jobs:
|
||||
if (job.get("status") == "completed" and job.get("conclusion") == "success"
|
||||
and job.get("head_sha") == workflow_sha):
|
||||
print(job.get("name", ""))' "$recovery_workflow_sha"
|
||||
)" || exit 1
|
||||
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
|
||||
page_jobs="$(printf '%s\n' "$page_result" | sed '1d')"
|
||||
passed_jobs="$(printf '%s\n%s\n' "$passed_jobs" "$page_jobs" | sed '/^$/d')"
|
||||
[ "$page_count" -eq 100 ] || break
|
||||
page=$((page + 1))
|
||||
done
|
||||
for required_job in \
|
||||
"Build signed release artifacts" \
|
||||
"Verify Apple Developer ID signatures" \
|
||||
"Publish immutable GitHub Release" \
|
||||
"Publish npm and mirrors"; do
|
||||
printf '%s\n' "$passed_jobs" | grep -Fqx "$required_job" || {
|
||||
printf 'protected recovery run %s did not complete the shared release job graph for %s\n' \
|
||||
"$recovery_run_id" "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
printf 'Release workflow delivery verified through protected recovery run %s: %s -> %s\n' \
|
||||
"$recovery_run_id" "$TAG" "$EXPECTED_COMMIT"
|
||||
@@ -17,21 +17,36 @@ var expectedHomeSkillTargets = []string{
|
||||
".cursor/skills/dws",
|
||||
}
|
||||
|
||||
func TestInstallScriptSourceModeInstallsBinary(t *testing.T) {
|
||||
t.Parallel()
|
||||
type installSourceFixture struct {
|
||||
root string
|
||||
scriptPath string
|
||||
stubRoot string
|
||||
fakeHome string
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
installDir := filepath.Join(root, "bin")
|
||||
func newInstallSourceFixture(t *testing.T) *installSourceFixture {
|
||||
t.Helper()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
repoScript, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(install.sh) error = %v", err)
|
||||
}
|
||||
scriptData, err := os.ReadFile(repoScript)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", repoScript, err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
scriptPath := filepath.Join(root, "scripts", "install.sh")
|
||||
mustWriteFile(t, scriptPath, scriptData, 0o755)
|
||||
// resolve_source_root requires both go.mod and cmd/. The source installer
|
||||
// tests need only that layout plus small representative skill trees.
|
||||
mustWriteFile(t, filepath.Join(root, "go.mod"), []byte("module example.com/dws-install-test\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "cmd", ".keep"), nil, 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "skills", "mono", "SKILL.md"), []byte("# Test skill\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "skills", "multi", "dingtalk-test", "SKILL.md"), []byte("# Test split skill\n"), 0o644)
|
||||
|
||||
// Stub make: when invoked as "make -C <dir> build", create a fake dws binary
|
||||
// in the project root (the -C target directory).
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
|
||||
makeStub := `#!/bin/sh
|
||||
set -eu
|
||||
dir=""
|
||||
@@ -44,27 +59,48 @@ done
|
||||
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
|
||||
// Also need a stub go so need_cmd check passes
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
return &installSourceFixture{
|
||||
root: root,
|
||||
scriptPath: scriptPath,
|
||||
stubRoot: stubRoot,
|
||||
fakeHome: filepath.Join(root, "home"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *installSourceFixture) env(extra ...string) []string {
|
||||
env := append(os.Environ(),
|
||||
"HOME="+f.fakeHome,
|
||||
"PATH="+f.stubRoot+":"+os.Getenv("PATH"),
|
||||
"DWS_VERSION=latest",
|
||||
"DWS_SKILLS_ONLY=0",
|
||||
"DWS_SKILL_MODE=mono",
|
||||
)
|
||||
return append(env, extra...)
|
||||
}
|
||||
|
||||
func TestInstallScriptSourceModeInstallsBinary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
cmd := exec.Command("sh", fixture.scriptPath)
|
||||
cmd.Env = fixture.env(
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_INSTALL_NAME=dws-test",
|
||||
"DWS_NO_SKILLS=1",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the fake binary created in the real repo root
|
||||
_ = os.Remove(filepath.Join(repoRoot, "dws"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
for _, want := range []string{
|
||||
"Installing dws from source checkout",
|
||||
"Installing dws from source checkout: " + fixture.root,
|
||||
"Install dir: " + installDir,
|
||||
"Binary installed:",
|
||||
installDir + "/dws-test",
|
||||
@@ -87,53 +123,27 @@ done
|
||||
func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
fakeHome := filepath.Join(root, "home")
|
||||
installDir := filepath.Join(root, "bin")
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(install.sh) error = %v", err)
|
||||
}
|
||||
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
|
||||
makeStub := `#!/bin/sh
|
||||
set -eu
|
||||
dir=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C) dir="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
// Gate for index>0 agent dirs (matches build/npm/install.js): parent must exist.
|
||||
if err := os.MkdirAll(filepath.Join(fakeHome, ".cursor"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(fixture.fakeHome, ".cursor"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(.cursor) error = %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+fakeHome,
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
cmd := exec.Command("sh", fixture.scriptPath)
|
||||
cmd.Env = fixture.env(
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_NO_SKILLS=0",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the fake binary created in the real repo root
|
||||
_ = os.Remove(filepath.Join(repoRoot, "dws"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
for _, rel := range expectedHomeSkillTargets {
|
||||
skillPath := filepath.Join(fakeHome, filepath.FromSlash(rel), "SKILL.md")
|
||||
skillPath := filepath.Join(fixture.fakeHome, filepath.FromSlash(rel), "SKILL.md")
|
||||
if _, err := os.Stat(skillPath); err != nil {
|
||||
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output))
|
||||
}
|
||||
@@ -694,48 +704,22 @@ func TestInstallScriptsCacheMultiSkills(t *testing.T) {
|
||||
func TestInstallScriptCachesMultiEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
fakeHome := filepath.Join(root, "home")
|
||||
installDir := filepath.Join(root, "bin")
|
||||
fixture := newInstallSourceFixture(t)
|
||||
installDir := filepath.Join(fixture.root, "bin")
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(install.sh) error = %v", err)
|
||||
}
|
||||
|
||||
stubRoot := filepath.Join(root, "stubs")
|
||||
repoRoot, _ := filepath.Abs(filepath.Join("..", ".."))
|
||||
makeStub := `#!/bin/sh
|
||||
set -eu
|
||||
dir=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C) dir="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+fakeHome,
|
||||
"PATH="+stubRoot+":"+os.Getenv("PATH"),
|
||||
cmd := exec.Command("sh", fixture.scriptPath)
|
||||
cmd.Env = fixture.env(
|
||||
"DWS_INSTALL_DIR="+installDir,
|
||||
"DWS_NO_SKILLS=0",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Clean up the fake binary created in the real repo root
|
||||
_ = os.Remove(filepath.Join(repoRoot, "dws"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
|
||||
// Verify multi cache was populated. We expect dingtalk-* subdirs.
|
||||
multiCache := filepath.Join(fakeHome, ".dws", "skills", "multi")
|
||||
multiCache := filepath.Join(fixture.fakeHome, ".dws", "skills", "multi")
|
||||
entries, err := os.ReadDir(multiCache)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(%s) error = %v\noutput:\n%s", multiCache, err, string(output))
|
||||
@@ -751,7 +735,7 @@ done
|
||||
}
|
||||
|
||||
// And verify mono cache.
|
||||
monoCacheSkill := filepath.Join(fakeHome, ".dws", "skills", "mono", "SKILL.md")
|
||||
monoCacheSkill := filepath.Join(fixture.fakeHome, ".dws", "skills", "mono", "SKILL.md")
|
||||
if _, err := os.Stat(monoCacheSkill); err != nil {
|
||||
t.Fatalf("missing mono cache SKILL.md at %s: %v", monoCacheSkill, err)
|
||||
}
|
||||
|
||||
@@ -598,6 +598,196 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func readReleaseWorkflow(t *testing.T) string {
|
||||
t.Helper()
|
||||
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(release.yml) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func releaseWorkflowSection(t *testing.T, workflow, startMarker, endMarker string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(workflow, startMarker)
|
||||
if start == -1 {
|
||||
t.Fatalf("release workflow is missing section marker %q", startMarker)
|
||||
}
|
||||
end := strings.Index(workflow[start+len(startMarker):], endMarker)
|
||||
if end == -1 {
|
||||
t.Fatalf("release workflow section %q is missing end marker %q", startMarker, endMarker)
|
||||
}
|
||||
return workflow[start : start+len(startMarker)+end]
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
|
||||
const (
|
||||
checksCall = "github.rest.checks.listForRef"
|
||||
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
|
||||
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
|
||||
)
|
||||
if got := strings.Count(workflow, checksCall); got != 2 {
|
||||
t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight check", got)
|
||||
}
|
||||
if got := strings.Count(workflow, immutableCall); got != 2 {
|
||||
t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got)
|
||||
}
|
||||
if got := strings.Count(workflow, governanceID); got != 2 {
|
||||
t.Fatalf("release workflow dedicated governance identity count = %d, want one per immutable check", got)
|
||||
}
|
||||
|
||||
sections := map[string]string{
|
||||
"preflight": releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n"),
|
||||
"tag": releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n"),
|
||||
}
|
||||
for name, section := range sections {
|
||||
for _, required := range []string{
|
||||
"checks: read",
|
||||
checksCall,
|
||||
immutableCall,
|
||||
governanceID,
|
||||
"RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required",
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("%s governance path is missing %q", name, required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(section, "contents: write") {
|
||||
t.Errorf("%s governance path must not grant contents write permission", name)
|
||||
}
|
||||
if strings.Contains(section, `github-token: ${{ secrets.GITHUB_TOKEN }}`) {
|
||||
t.Errorf("%s immutable governance path must not fall back to GITHUB_TOKEN", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
preflight := releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n")
|
||||
|
||||
for _, required := range []string{
|
||||
"governance_preflight_commit:",
|
||||
"governance_preflight_nonce:",
|
||||
`format('Release governance preflight {0}', inputs.governance_preflight_nonce)`,
|
||||
"name: Release governance preflight",
|
||||
"github.event_name == 'workflow_dispatch'",
|
||||
"EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
`DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`,
|
||||
`test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`,
|
||||
"governance preflight cannot be combined with npm repair",
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("governance preflight contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout",
|
||||
"contents: write",
|
||||
"goreleaser",
|
||||
"gh release",
|
||||
"npm publish",
|
||||
"sync-to-oss",
|
||||
"sync-to-gitee",
|
||||
} {
|
||||
if strings.Contains(preflight, forbidden) {
|
||||
t.Errorf("governance preflight must not contain publishing behavior %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
|
||||
if !strings.Contains(mirror, "needs: [release-contract, release, publish-channels]") {
|
||||
t.Error("Gitee publication must remain downstream of the verified release target and channel jobs")
|
||||
}
|
||||
repair := workflow[strings.Index(workflow, " repair-npm:\n"):]
|
||||
for _, required := range []string{
|
||||
"needs: dispatch-contract",
|
||||
"needs.dispatch-contract.outputs.mode == 'repair_npm'",
|
||||
} {
|
||||
if !strings.Contains(repair, required) {
|
||||
t.Errorf("npm repair dispatch contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
|
||||
for _, required := range []string{
|
||||
"recover_release_version:",
|
||||
"recover_release_tag_object:",
|
||||
"recover_release_commit:",
|
||||
"recover_failed_run_id:",
|
||||
"recover_release_nonce:",
|
||||
"recover_release_confirmation:",
|
||||
`format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce)`,
|
||||
"workflow_dispatch must select exactly one release mode",
|
||||
"release recovery confirmation must equal the exact version",
|
||||
"recover_release_nonce must be bound to the release commit",
|
||||
"environment: release-recovery",
|
||||
"prevent_self_review !== true",
|
||||
"protected_branches !== true",
|
||||
"can_admins_bypass !== false",
|
||||
`run.path !== ".github/workflows/release.yml"`,
|
||||
`run.event !== "push"`,
|
||||
`["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`,
|
||||
`run.head_branch !== version`,
|
||||
`run.head_sha !== commit`,
|
||||
`tagObject !== expectedTagObject`,
|
||||
`["ahead", "identical"].includes(comparison.data.status)`,
|
||||
"already has a public release; use a channel repair instead",
|
||||
"Bind recovery publication to this workflow run",
|
||||
"dws-release-recovery run=%s tag-object=%s commit=%s",
|
||||
"Public release is not bound to this exact recovery run.",
|
||||
"Public recovery asset differs from this run's sealed artifact",
|
||||
`ref: process.env.RELEASE_COMMIT`,
|
||||
`path: tmp/trusted-release-tooling`,
|
||||
`ref: ${{ github.sha }}`,
|
||||
`step.name === "Require immutable published GitHub Release"`,
|
||||
"Require a clean sealed source before GoReleaser",
|
||||
`git status --porcelain --untracked-files=all`,
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("release recovery contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
sections := map[string]string{
|
||||
"release": releaseWorkflowSection(t, workflow, " release:\n", "\n verify-darwin-signatures:\n"),
|
||||
"publish-release": releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n"),
|
||||
"publish-channels": releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n"),
|
||||
}
|
||||
for name, section := range sections {
|
||||
for _, required := range []string{
|
||||
"needs.release-contract.outputs.release_version",
|
||||
"ref: ${{ needs.release-contract.outputs.release_commit }}",
|
||||
"persist-credentials: false",
|
||||
`tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh`,
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("%s does not consume the verified recovery target %q", name, required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(section, "github.event_name == 'workflow_dispatch'") {
|
||||
t.Errorf("%s must not fork into a recovery-specific publisher", name)
|
||||
}
|
||||
}
|
||||
if strings.Count(workflow, "name: Build signed release artifacts") != 1 ||
|
||||
strings.Count(workflow, "name: Verify Apple Developer ID signatures") != 1 ||
|
||||
strings.Count(workflow, "name: Publish immutable GitHub Release") != 1 ||
|
||||
strings.Count(workflow, "name: Publish npm and mirrors") != 1 {
|
||||
t.Fatal("normal and recovery publication must share one build/sign/publish job graph")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -762,7 +952,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
||||
}
|
||||
|
||||
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
|
||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --draft=false`)
|
||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$RELEASE_VERSION" --draft=false`)
|
||||
if codesign == -1 || publish == -1 {
|
||||
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
||||
}
|
||||
@@ -848,7 +1038,7 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
|
||||
"./scripts/release/publish-homebrew-formula.sh",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"DWS_TAP_PR_REPOSITORY",
|
||||
"automation/homebrew-${{ github.ref_name }}",
|
||||
"automation/homebrew-${{ needs.release-contract.outputs.release_version }}",
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("Homebrew publication step is missing %q", required)
|
||||
@@ -891,7 +1081,7 @@ func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) {
|
||||
"dist/homebrew/dingtalk-workspace-cli-beta.rb",
|
||||
"Formula/dingtalk-workspace-cli-beta.rb",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"automation/homebrew-beta-${{ github.ref_name }}",
|
||||
"automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}",
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("beta Homebrew PR step is missing %q", required)
|
||||
|
||||
@@ -30,10 +30,24 @@ func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), fakeDelegate("prepare"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "release.sh"), fakeDelegate("release"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "recover-release.sh"), fakeDelegate("recover"), 0o755)
|
||||
r.commitAndPush(t, "install unified release entry fixture")
|
||||
return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRoutesProtectedRecovery(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("dws-release recovery route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\n"
|
||||
if got := f.callLog(t); got != want {
|
||||
t.Fatalf("recovery delegate calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", append([]string{f.entry}, args...)...)
|
||||
|
||||
@@ -392,6 +392,72 @@ esac
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowDeliveryAcceptsOnlySharedProtectedRecovery(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeCurl := filepath.Join(binDir, "curl")
|
||||
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
for argument in "$@"; do endpoint="$argument"; done
|
||||
case "$endpoint" in
|
||||
*event=push*)
|
||||
printf '{"workflow_runs":[]}\n'
|
||||
;;
|
||||
*event=workflow_dispatch*)
|
||||
printf '{"workflow_runs":[{"id":42,"display_title":"Release recovery %s at %s %s-1700000000-42","event":"workflow_dispatch","status":"completed","conclusion":"success","head_branch":"main","head_sha":"%s","path":".github/workflows/release.yml","repository":{"full_name":"owner/repo"}}]}\n' "$TAG" "$RELEASE_COMMIT" "$RELEASE_COMMIT" "$WORKFLOW_SHA"
|
||||
;;
|
||||
*/compare/*...main)
|
||||
printf '{"status":"ahead"}\n'
|
||||
;;
|
||||
*/actions/runs/42/jobs*)
|
||||
printf '{"jobs":['
|
||||
printf '{"name":"Build signed release artifacts","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA"
|
||||
printf '{"name":"Verify Apple Developer ID signatures","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA"
|
||||
printf '{"name":"Publish immutable GitHub Release","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA"
|
||||
if [ "${MISSING_CHANNELS:-0}" != 1 ]; then
|
||||
printf ',{"name":"Publish npm and mirrors","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA"
|
||||
fi
|
||||
printf ']}\n'
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh")
|
||||
tag := "v1.2.3-beta.1"
|
||||
commit := strings.Repeat("a", 40)
|
||||
workflowSHA := strings.Repeat("b", 40)
|
||||
run := func(missingChannels bool) (string, error) {
|
||||
cmd := exec.Command("sh", script, tag, commit)
|
||||
missing := "0"
|
||||
if missingChannels {
|
||||
missing = "1"
|
||||
}
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
|
||||
"TAG=" + tag,
|
||||
"RELEASE_COMMIT=" + commit,
|
||||
"WORKFLOW_SHA=" + workflowSHA,
|
||||
"MISSING_CHANNELS=" + missing,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
output, err := run(false)
|
||||
if err != nil || !strings.Contains(output, "protected recovery run 42") {
|
||||
t.Fatalf("protected shared recovery was rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
output, err = run(true)
|
||||
if err == nil || !strings.Contains(output, "did not complete the shared release job graph") {
|
||||
t.Fatalf("incomplete recovery job graph passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseChangelog(sections ...string) string {
|
||||
text := "# Changelog\n\n## [Unreleased]\n\n"
|
||||
for _, section := range sections {
|
||||
@@ -1002,6 +1068,178 @@ func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandReusesExactPreflightProof(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, ".gitignore"), []byte("/dws\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte(`test:
|
||||
@rm -f dws
|
||||
@printf 'test\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
build:
|
||||
@printf '#!/bin/sh\nexit 0\n' > dws
|
||||
@chmod +x dws
|
||||
@printf 'build\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
policy:
|
||||
@test -x dws
|
||||
@printf 'policy\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
package:
|
||||
@printf 'package\n' >> "$$(git rev-parse --git-path release-phases)"
|
||||
`), 0o644)
|
||||
r.commitAndPush(t, "install proof-aware release automation")
|
||||
|
||||
command := filepath.Join(r.root, "scripts", "release", "release.sh")
|
||||
output, err := runReleaseScript(t, r.root, command,
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Publish within six hours") {
|
||||
t.Fatalf("validation output missing reusable-proof guidance:\n%s", output)
|
||||
}
|
||||
phasePath := filepath.Join(r.root, ".git", "release-phases")
|
||||
phaseData, err := os.ReadFile(phasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(release phases) error = %v", err)
|
||||
}
|
||||
wantPhases := "test\nbuild\npolicy\npackage\n"
|
||||
if string(phaseData) != wantPhases {
|
||||
t.Fatalf("release phase order = %q, want %q", phaseData, wantPhases)
|
||||
}
|
||||
proofPath := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "--git-path", "dws-release-preflight/v1.0.1-beta.1.proof"))
|
||||
if !filepath.IsAbs(proofPath) {
|
||||
proofPath = filepath.Join(r.root, proofPath)
|
||||
}
|
||||
proofBefore, err := os.ReadFile(proofPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(preflight proof) error = %v", err)
|
||||
}
|
||||
hook := filepath.Join(r.remote, "hooks", "pre-receive")
|
||||
mustWriteFile(t, hook, []byte("#!/bin/sh\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\nexit 0\n"), 0o755)
|
||||
failedOutput, failedErr := runReleaseScript(t, r.root, command,
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if failedErr == nil || !strings.Contains(failedOutput, "new local tag was removed") {
|
||||
t.Fatalf("rejected proof-based publish did not fail safely: err=%v\noutput:\n%s", failedErr, failedOutput)
|
||||
}
|
||||
proofAfterFailure, err := os.ReadFile(proofPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(preflight proof after failure) error = %v", err)
|
||||
}
|
||||
if string(proofAfterFailure) != string(proofBefore) {
|
||||
t.Fatalf("failed publish refreshed reusable proof:\nbefore:\n%s\nafter:\n%s", proofBefore, proofAfterFailure)
|
||||
}
|
||||
if err := os.Remove(hook); err != nil {
|
||||
t.Fatalf("Remove(rejecting hook) error = %v", err)
|
||||
}
|
||||
|
||||
output, err = runReleaseScript(t, r.root, command,
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release publish error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Reusing exact preflight proof") {
|
||||
t.Fatalf("publish did not reuse the exact preflight proof:\n%s", output)
|
||||
}
|
||||
phaseData, err = os.ReadFile(phasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(release phases after publish) error = %v", err)
|
||||
}
|
||||
if string(phaseData) != wantPhases {
|
||||
t.Fatalf("publish reran expensive phases: %q", phaseData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGovernanceCIDispatchBindsExactRunIdentity(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
stateDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
command="$1"
|
||||
shift
|
||||
case "$command" in
|
||||
workflow)
|
||||
test "$1" = run
|
||||
shift
|
||||
printf '%s\n' "$@" > "$GH_STATE/workflow-args"
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
governance_preflight_nonce=*)
|
||||
printf 'Release governance preflight %s\n' "${argument#governance_preflight_nonce=}" > "$GH_STATE/title"
|
||||
;;
|
||||
governance_preflight_commit=*)
|
||||
printf '%s\n' "${argument#governance_preflight_commit=}" > "$GH_STATE/commit"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
api)
|
||||
for argument in "$@"; do
|
||||
case "$argument" in repos/*/actions/*) endpoint="$argument" ;; esac
|
||||
done
|
||||
case "$endpoint" in
|
||||
*/actions/workflows/release.yml/runs*) printf '42\n' ;;
|
||||
*/actions/runs/42)
|
||||
title="$(cat "$GH_STATE/title")"
|
||||
commit="$(cat "$GH_STATE/commit")"
|
||||
repository=owner/repo
|
||||
if [ "${GH_BAD_STATE:-0}" = 1 ]; then repository=attacker/repo; fi
|
||||
printf '%s\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s\t.github/workflows/release.yml\t%s\n' "$title" "$commit" "$repository"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
run)
|
||||
test "$1" = watch
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-governance-ci.sh")
|
||||
commit := strings.Repeat("a", 40)
|
||||
run := func(badState bool) (string, error) {
|
||||
cmd := exec.Command("sh", script, "owner/repo", commit)
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GH_STATE=" + stateDir,
|
||||
fmt.Sprintf("GH_BAD_STATE=%d", map[bool]int{false: 0, true: 1}[badState]),
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
output, err := run(false)
|
||||
if err != nil || !strings.Contains(output, "Release governance preflight passed") {
|
||||
t.Fatalf("governance CI preflight error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
args, err := os.ReadFile(filepath.Join(stateDir, "workflow-args"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(workflow args) error = %v", err)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"--ref\nmain\n",
|
||||
"governance_preflight_commit=" + commit,
|
||||
"governance_preflight_nonce=" + commit + "-",
|
||||
} {
|
||||
if !strings.Contains(string(args), required) {
|
||||
t.Errorf("workflow dispatch args are missing %q:\n%s", required, args)
|
||||
}
|
||||
}
|
||||
|
||||
output, err = run(true)
|
||||
if err == nil || !strings.Contains(output, "run identity mismatch") {
|
||||
t.Fatalf("mismatched governance run identity passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
@@ -1048,7 +1286,7 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
r.commitAndPush(t, "install advancing release fixture")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
@@ -1071,7 +1309,7 @@ func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
}
|
||||
|
||||
func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) {
|
||||
|
||||
Reference in New Issue
Block a user