Compare commits

...
Author SHA1 Message Date
修雨 8eda235f62 fix: tolerate incomplete Gitee releases 2026-07-13 09:37:55 +08:00
修雨 390b6115bf fix(connect): harden daemon restart lifecycle (#599) 2026-07-12 23:11:08 +08:00
17 changed files with 594 additions and 37 deletions
@@ -0,0 +1,54 @@
name: Verify Gitee release
on:
schedule:
- cron: "30 2 * * *"
workflow_dispatch:
inputs:
version:
description: "Release tag to verify; defaults to the latest stable GitHub release"
required: false
type: string
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Resolve release version
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -eu
version="$INPUT_VERSION"
if [ -z "$version" ]; then
version="$(gh release view --repo "${{ github.repository }}" --json tagName --jq .tagName)"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Download GitHub release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ steps.release.outputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
- name: Verify matching Gitee release
run: ./scripts/release/verify-gitee-release.sh
env:
VERSION: ${{ steps.release.outputs.version }}
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
+1 -1
View File
@@ -13,7 +13,7 @@ require (
github.com/gorilla/websocket v1.5.0
github.com/itchyny/gojq v0.12.18
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
+2 -2
View File
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+53 -2
View File
@@ -113,6 +113,50 @@ func connectDaemonDir(dirKey string) (string, error) {
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
func daemonStatePath(dir string) string { return filepath.Join(dir, "daemon-state.json") }
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
func daemonExecutablePath(dir string) string {
return filepath.Join(dir, "dws-daemon")
}
// stageDaemonExecutable snapshots the current dws binary into the connector's
// persistent directory. One-click launchers may execute a temporary download
// (for example dws-latest) and remove it after connect --daemon returns; the
// supervisor must not depend on that transient path for future worker restarts.
func stageDaemonExecutable(src, dir string) (string, error) {
in, err := os.Open(src)
if err != nil {
return "", err
}
defer in.Close()
tmp, err := os.CreateTemp(dir, ".dws-daemon-*")
if err != nil {
return "", err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(daemonExecutablePerm); err != nil {
tmp.Close()
return "", err
}
if _, err := io.Copy(tmp, in); err != nil {
tmp.Close()
return "", err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return "", err
}
if err := tmp.Close(); err != nil {
return "", err
}
dst := daemonExecutablePath(dir)
if err := os.Rename(tmpPath, dst); err != nil {
return "", err
}
return dst, nil
}
// writeDaemonState atomically persists the daemon state to daemon-state.json
// (persistent, survives supervisor exit) so restart/list can recover config.
@@ -169,8 +213,9 @@ func backoffDelay(consecutiveFailures int, base, maxDelay time.Duration) time.Du
}
const (
daemonBackoffBase = time.Second
daemonBackoffCap = 60 * time.Second
daemonBackoffBase = time.Second
daemonBackoffCap = 60 * time.Second
daemonExecutablePerm os.FileMode = 0o700
// daemonMaxFastFailures is the consecutive fast-failure ceiling: after this
// many crashes that each happened within daemonHealthyAfter, the supervisor
// gives up rather than spin forever (e.g. bad credentials).
@@ -226,6 +271,10 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
if err != nil {
return apperrors.NewInternal("resolve executable: " + err.Error())
}
exe, err = stageDaemonExecutable(exe, dir)
if err != nil {
return apperrors.NewInternal("stage daemon executable: " + err.Error())
}
superviseArgs := buildSuperviseArgs(os.Args[1:])
logPath := daemonLogPath(dir)
@@ -386,6 +435,7 @@ func runSupervisor(cmd *cobra.Command) error {
worker.Stdout = out
worker.Stderr = out
worker.Env = os.Environ()
configureWorkerProcessGroup(worker)
if err := worker.Start(); err != nil {
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
failures++
@@ -398,6 +448,7 @@ func runSupervisor(cmd *cobra.Command) error {
fmt.Fprintf(out, "[daemon] worker started (pid %d)\n", worker.Process.Pid)
waitErr := superviseWait(ctx, worker)
cleanupWorkerProcessGroup(worker.Process.Pid)
ran := time.Since(started)
if ctx.Err() != nil {
+30
View File
@@ -68,6 +68,36 @@ func TestDaemonDirKey(t *testing.T) {
}
}
func TestStageDaemonExecutable(t *testing.T) {
dir := t.TempDir()
src := dir + "/source"
if err := os.WriteFile(src, []byte("test-binary"), 0o700); err != nil {
t.Fatal(err)
}
dst, err := stageDaemonExecutable(src, dir)
if err != nil {
t.Fatalf("stageDaemonExecutable: %v", err)
}
if dst != daemonExecutablePath(dir) {
t.Fatalf("path = %q, want %q", dst, daemonExecutablePath(dir))
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != "test-binary" {
t.Fatalf("content = %q, want test-binary", got)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != daemonExecutablePerm {
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
}
}
func TestBuildWorkerArgs(t *testing.T) {
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"}
got := buildWorkerArgs(in)
+14
View File
@@ -36,3 +36,17 @@ func detachSysProcAttr() *syscall.SysProcAttr {
func applyDetach(cmd *exec.Cmd) {
cmd.SysProcAttr = detachSysProcAttr()
}
// configureWorkerProcessGroup isolates the connector worker and every local
// agent process it starts (opencode serve, codex app-server, etc.) in one group.
func configureWorkerProcessGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// cleanupWorkerProcessGroup removes descendants left behind when the worker
// exits abruptly and cannot run its normal deferred cleanup.
func cleanupWorkerProcessGroup(pid int) {
if pid > 0 {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
}
@@ -0,0 +1,29 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package helpers
import (
"os/exec"
"testing"
)
func TestConfigureWorkerProcessGroup(t *testing.T) {
cmd := exec.Command("true")
configureWorkerProcessGroup(cmd)
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
t.Fatal("worker must start in its own process group")
}
}
@@ -25,3 +25,7 @@ const daemonDetachSupported = false
// applyDetach is a no-op on Windows; daemon mode is rejected earlier.
func applyDetach(_ *exec.Cmd) {}
func configureWorkerProcessGroup(_ *exec.Cmd) {}
func cleanupWorkerProcessGroup(_ int) {}
+1 -1
View File
@@ -1117,7 +1117,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
mediaCli = newAICardClient(clientID, clientSecret, "")
}
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30000)
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30*time.Second)
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
cli := client.NewStreamClient(
client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
+36 -8
View File
@@ -50,6 +50,40 @@ gitee_api() {
return 1
}
gitee_release_has_assets() {
_version="$1"
shift
_release="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${_version}" 2>/dev/null || true)"
[ -n "$_release" ] && [ "$_release" != "null" ] || return 1
_compact="$(printf '%s' "$_release" | tr -d '[:space:]')"
for _asset in "$@"; do
printf '%s' "$_compact" | grep -Fq "\"name\":\"${_asset}\"" || return 1
done
return 0
}
resolve_gitee_latest_version() {
_tags="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" 2>/dev/null || true)"
_candidates="$(printf '%s' "$_tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -Vr)"
_newest="$(printf '%s\n' "$_candidates" | head -1)"
[ -n "$_newest" ] || return 1
for _candidate in $_candidates; do
if gitee_release_has_assets "$_candidate" "$@"; then
VERSION="$_candidate"
if [ "$_candidate" != "$_newest" ]; then
printf ' ⚠ Gitee tag %s has no complete release assets; using %s.\n' "$_newest" "$_candidate"
fi
return 0
fi
done
return 1
}
pick_source() {
[ -n "$GITEE_REPO" ] && return 0
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
@@ -61,13 +95,7 @@ pick_source() {
resolve_version() {
if [ "$VERSION" = "latest" ]; then
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable, so
# resolve the newest vN.N.N tag from the git tags endpoint instead.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
resolve_gitee_latest_version "$@" || VERSION=""
else
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
@@ -210,7 +238,7 @@ install_skills_to_root() {
main() {
need_cmd curl
pick_source
resolve_version
resolve_version dws-skills.zip
printf '\n'
printf ' ┌──────────────────────────────────────┐\n'
+39 -10
View File
@@ -152,6 +152,24 @@ function Get-GiteeAssetUrl {
return ""
}
function Test-GiteeReleaseAssets {
param(
[string]$Candidate,
[string[]]$RequiredAssets
)
try {
$release = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Candidate"
} catch {
return $false
}
if (-not $release -or -not $release.assets) { return $false }
$assetNames = @($release.assets | ForEach-Object { $_.name })
foreach ($required in $RequiredAssets) {
if ($assetNames -notcontains $required) { return $false }
}
return $true
}
function Resolve-Source {
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
if ($GiteeRepo -ne "") { return }
@@ -167,18 +185,26 @@ function Resolve-Source {
}
function Resolve-LatestVersion {
param([string[]]$RequiredAssets = @())
if ($Version -eq "latest") {
if ($GiteeRepo -ne "") {
try {
# Gitee's /releases/latest and /releases endpoints are unreliable
# (404 / empty even when releases exist), so resolve the newest
# vN.N.N tag from the git tags endpoint instead.
# Tags can be mirrored before release attachments. Select the
# newest stable tag with all assets required by this install.
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
$latest = $tags.name |
$candidates = @($tags.name |
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
ForEach-Object { [version]($_.TrimStart('v')) } |
Sort-Object | Select-Object -Last 1
if ($latest) { $script:Version = "v$latest"; return }
Sort-Object { [version]($_.TrimStart('v')) } -Descending)
$newest = $candidates | Select-Object -First 1
foreach ($candidate in $candidates) {
if (Test-GiteeReleaseAssets $candidate $RequiredAssets) {
$script:Version = $candidate
if ($candidate -ne $newest) {
Write-Say "⚠ Gitee tag $newest has no complete release assets; using $candidate."
}
return
}
}
} catch {}
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
return
@@ -349,9 +375,12 @@ function Write-MultiModeNotice {
function Install-Binary {
$arch = Get-Arch
Resolve-LatestVersion
$archiveName = "${BinName}-windows-${arch}.zip"
$requiredAssets = @($archiveName, "checksums.txt")
if (!$NoSkills -and $SkillMode -ne "multi") {
$requiredAssets += "dws-skills.zip"
}
Resolve-LatestVersion $requiredAssets
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
@@ -545,7 +574,7 @@ function Install-BinaryFromSource {
function Install-Skills {
Write-Say ""
Write-Say "📦 Installing agent skills from GitHub Releases..."
Resolve-LatestVersion
Resolve-LatestVersion @("dws-skills.zip")
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
+44 -13
View File
@@ -105,6 +105,40 @@ gitee_api() {
return 1
}
gitee_release_has_assets() {
_version="$1"
shift
_release="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${_version}" 2>/dev/null || true)"
[ -n "$_release" ] && [ "$_release" != "null" ] || return 1
_compact="$(printf '%s' "$_release" | tr -d '[:space:]')"
for _asset in "$@"; do
printf '%s' "$_compact" | grep -Fq "\"name\":\"${_asset}\"" || return 1
done
return 0
}
resolve_gitee_latest_version() {
_tags="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" 2>/dev/null || true)"
_candidates="$(printf '%s' "$_tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -Vr)"
_newest="$(printf '%s\n' "$_candidates" | head -1)"
[ -n "$_newest" ] || return 1
for _candidate in $_candidates; do
if gitee_release_has_assets "$_candidate" "$@"; then
VERSION="$_candidate"
if [ "$_candidate" != "$_newest" ]; then
say "⚠ Gitee tag ${_newest} has no complete release assets; using ${_candidate}."
fi
return 0
fi
done
return 1
}
# Resolve the download URL of a release asset by file name.
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
# Gitee : query the release API and pick the asset whose name matches
@@ -173,19 +207,13 @@ pick_source() {
say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}"
}
# Resolve the latest version tag from GitHub
# Resolve the latest installable release from the selected source.
resolve_version() {
if [ "$VERSION" = "latest" ]; then
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable — they
# return 404 / an empty list even when releases exist — so resolve the
# newest version from the git tags endpoint instead: keep only vN.N.N
# tags and pick the highest by version order.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
# Tags can be mirrored before release attachments. Select the newest
# stable tag whose release contains every asset needed by this install.
resolve_gitee_latest_version "$@" || VERSION=""
elif need_cmd curl; then
# Follow the redirect from /releases/latest to get the tag
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
@@ -472,9 +500,12 @@ _copy_skill() {
install_binary() {
os="$(detect_os)"
arch="$(detect_arch)"
resolve_version
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
if [ "$NO_SKILLS" != "1" ] && [ "$SKILL_MODE" != "multi" ]; then
resolve_version "$archive_name" checksums.txt dws-skills.zip
else
resolve_version "$archive_name" checksums.txt
fi
download_url="$(asset_url "$archive_name")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
@@ -555,8 +586,8 @@ install_skills() {
say ""
say "📦 Installing agent skills from GitHub Releases..."
resolve_version
skills_archive="dws-skills.zip"
resolve_version "$skills_archive"
download_url="$(asset_url "$skills_archive")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
+3
View File
@@ -142,3 +142,6 @@ done
[ "$failed" -eq 0 ] || err "Gitee publish finished with ${failed} failed upload(s)"
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped}"
VERSION="$VERSION" GITEE_REPO="$GITEE_REPO" GITEE_API="$GITEE_API" \
GITEE_TOKEN="$GITEE_TOKEN" DIST_DIR="$DIST_DIR" \
./scripts/release/verify-gitee-release.sh
+3
View File
@@ -208,5 +208,8 @@ if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
exit 1
fi
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
VERSION="$VERSION" GITEE_REPO="$GITEE_REPO" GITEE_API="$GITEE_API" \
GITEE_TOKEN="$GITEE_TOKEN" DIST_DIR="$DIST_DIR" \
./scripts/release/verify-gitee-release.sh
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
# Verify that a Gitee release exposes every standard DWS release asset.
set -eu
VERSION="${VERSION:-}"
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
DIST_DIR="${DIST_DIR:-dist}"
GITEE_VERIFY_RETRIES="${GITEE_VERIFY_RETRIES:-6}"
GITEE_VERIFY_RETRY_DELAY="${GITEE_VERIFY_RETRY_DELAY:-5}"
REQUIRED_ASSETS="${GITEE_REQUIRED_ASSETS:-checksums.txt dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz dws-skills.zip dws-windows-amd64.zip dws-windows-arm64.zip}"
[ -n "$VERSION" ] || {
echo "error: VERSION is required" >&2
exit 2
}
for asset in $REQUIRED_ASSETS; do
[ -f "$DIST_DIR/$asset" ] || {
echo "error: local release asset is missing: $DIST_DIR/$asset" >&2
exit 1
}
done
endpoint="${GITEE_API}/repos/${GITEE_REPO}/releases/tags/${VERSION}"
if [ -n "${GITEE_TOKEN:-}" ]; then
endpoint="${endpoint}?access_token=${GITEE_TOKEN}"
fi
attempt=1
missing=""
while [ "$attempt" -le "$GITEE_VERIFY_RETRIES" ]; do
release_json="$(curl -fsSL "$endpoint" 2>/dev/null || true)"
compact="$(printf '%s' "$release_json" | tr -d '[:space:]')"
missing=""
for asset in $REQUIRED_ASSETS; do
count="$(printf '%s' "$compact" | grep -oF "\"name\":\"${asset}\"" | wc -l | tr -d ' ')"
if [ "$count" -ne 1 ]; then
missing="${missing} ${asset}(count=${count})"
fi
done
if [ -n "$release_json" ] && [ "$release_json" != "null" ] && [ -z "$missing" ]; then
echo "Gitee release ${VERSION} is complete (${GITEE_REPO})."
exit 0
fi
attempt=$((attempt + 1))
[ "$attempt" -le "$GITEE_VERIFY_RETRIES" ] && sleep "$GITEE_VERIFY_RETRY_DELAY"
done
echo "error: Gitee release ${VERSION} is incomplete:${missing:- release not found}" >&2
exit 1
+88
View File
@@ -0,0 +1,88 @@
package scripts_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestVerifyGiteeReleaseRequiresEveryAsset(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remote string
wantError bool
}{
{
name: "complete",
remote: `{"assets":[{"name":"checksums.txt"},{"name":"dws-linux-amd64.tar.gz"},{"name":"dws-skills.zip"}]}`,
},
{
name: "missing remote asset",
remote: `{"assets":[{"name":"checksums.txt"},{"name":"dws-linux-amd64.tar.gz"}]}`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
distDir := filepath.Join(root, "dist")
for _, name := range []string{"checksums.txt", "dws-linux-amd64.tar.gz", "dws-skills.zip"} {
mustWriteFile(t, filepath.Join(distDir, name), []byte(name), 0o644)
}
stubRoot := filepath.Join(root, "stubs")
mustWriteFile(t, filepath.Join(stubRoot, "curl"), []byte("#!/bin/sh\nprintf '%s' \"$FAKE_GITEE_RELEASE\"\n"), 0o755)
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-gitee-release.sh"))
if err != nil {
t.Fatalf("Abs(verify-gitee-release.sh) error = %v", err)
}
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+stubRoot+":"+os.Getenv("PATH"),
"VERSION=v1.2.3",
"DIST_DIR="+distDir,
"GITEE_REQUIRED_ASSETS=checksums.txt dws-linux-amd64.tar.gz dws-skills.zip",
"GITEE_VERIFY_RETRIES=1",
"FAKE_GITEE_RELEASE="+tt.remote,
)
output, err := cmd.CombinedOutput()
if tt.wantError {
if err == nil {
t.Fatalf("verify-gitee-release.sh error = nil, want failure\noutput:\n%s", string(output))
}
if !strings.Contains(string(output), "dws-skills.zip(count=0)") {
t.Fatalf("missing asset not reported:\n%s", string(output))
}
return
}
if err != nil {
t.Fatalf("verify-gitee-release.sh error = %v\noutput:\n%s", err, string(output))
}
if !strings.Contains(string(output), "Gitee release v1.2.3 is complete") {
t.Fatalf("success output missing completeness result:\n%s", string(output))
}
})
}
}
func TestGiteePublishersRunCompletenessVerification(t *testing.T) {
t.Parallel()
for _, relPath := range []string{
filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh"),
filepath.Join("..", "..", "scripts", "release", "publish-gitee-local.sh"),
filepath.Join("..", "..", ".github", "workflows", "verify-gitee-release.yml"),
} {
data, err := os.ReadFile(relPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", relPath, err)
}
if !strings.Contains(string(data), "verify-gitee-release.sh") {
t.Fatalf("%s does not run verify-gitee-release.sh", relPath)
}
}
}
+141
View File
@@ -4,6 +4,8 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -191,6 +193,104 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) {
}
}
func TestInstallScriptGiteeLatestUsesNewestCompleteRelease(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX shell installer test is for unix-like hosts")
}
root := t.TempDir()
installDir := filepath.Join(root, "bin")
stubRoot := filepath.Join(root, "stubs")
releaseDir := filepath.Join(root, "release")
archiveName := "dws-linux-amd64.tar.gz"
archivePath := filepath.Join(releaseDir, archiveName)
writeTarGz(t, archivePath, map[string]string{
"dws": "#!/bin/sh\nprintf 'Version: v1.0.49\\n'\n",
})
archiveData, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", archivePath, err)
}
checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(archiveData), archiveName)
mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(checksum), 0o644)
scriptData, err := os.ReadFile(filepath.Join("..", "..", "scripts", "install.sh"))
if err != nil {
t.Fatalf("ReadFile(install.sh) error = %v", err)
}
scriptPath := filepath.Join(root, "standalone", "install.sh")
mustWriteFile(t, scriptPath, scriptData, 0o755)
writeFakeGiteeCurl(t, filepath.Join(stubRoot, "curl"))
mustWriteFile(t, filepath.Join(stubRoot, "uname"), []byte(`#!/bin/sh
case "${1:-}" in
-s) echo Linux ;;
-m) echo x86_64 ;;
*) echo Linux ;;
esac
`), 0o755)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"HOME="+filepath.Join(root, "home"),
"PATH="+stubRoot+":"+os.Getenv("PATH"),
"DWS_INSTALL_DIR="+installDir,
"DWS_GITEE_REPO=example/dws",
"DWS_VERSION=latest",
"DWS_NO_SKILLS=1",
"FAKE_RELEASE_DIR="+releaseDir,
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
}
got := string(output)
if !strings.Contains(got, "Gitee tag v1.0.51 has no complete release assets; using v1.0.49") {
t.Fatalf("install output missing incomplete-release fallback:\n%s", got)
}
installed := filepath.Join(installDir, "dws")
versionOutput, err := exec.Command(installed).CombinedOutput()
if err != nil {
t.Fatalf("installed dws error = %v\noutput:\n%s", err, string(versionOutput))
}
if !strings.Contains(string(versionOutput), "Version: v1.0.49") {
t.Fatalf("installed version output = %q, want v1.0.49", string(versionOutput))
}
}
func TestInstallersRequireCompleteGiteeReleaseAssets(t *testing.T) {
t.Parallel()
checks := []struct {
relPath string
wants []string
}{
{
relPath: filepath.Join("..", "..", "scripts", "install.sh"),
wants: []string{"resolve_gitee_latest_version", `resolve_version "$archive_name" checksums.txt dws-skills.zip`, `resolve_version "$skills_archive"`},
},
{
relPath: filepath.Join("..", "..", "scripts", "install-skills.sh"),
wants: []string{"resolve_gitee_latest_version", "resolve_version dws-skills.zip"},
},
{
relPath: filepath.Join("..", "..", "scripts", "install.ps1"),
wants: []string{"Test-GiteeReleaseAssets", `$requiredAssets += "dws-skills.zip"`, `Resolve-LatestVersion @("dws-skills.zip")`},
},
}
for _, tc := range checks {
data, err := os.ReadFile(tc.relPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", tc.relPath, err)
}
for _, want := range tc.wants {
if !strings.Contains(string(data), want) {
t.Fatalf("%s missing %q", tc.relPath, want)
}
}
}
}
func TestInstallEventScriptStaticExpectations(t *testing.T) {
t.Parallel()
@@ -822,6 +922,47 @@ func writeFakeGH(t *testing.T, path, version string) {
mustWriteFile(t, path, []byte(script), 0o755)
}
func writeFakeGiteeCurl(t *testing.T, path string) {
t.Helper()
const script = `#!/bin/sh
set -eu
out=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
case "$url" in
*/tags)
printf '%s' '[{"name":"v1.0.49"},{"name":"v1.0.51"},{"name":"v1.0.50"}]'
;;
*/releases/tags/v1.0.51)
printf '%s' 'null'
;;
*/releases/tags/v1.0.50)
printf '%s' '{"tag_name":"v1.0.50","assets":[{"name":"checksums.txt","browser_download_url":"https://assets.invalid/checksums.txt"}]}'
;;
*/releases/tags/v1.0.49)
printf '%s' '{"tag_name":"v1.0.49","assets":[{"name":"dws-linux-amd64.tar.gz","browser_download_url":"https://assets.invalid/dws-linux-amd64.tar.gz"},{"name":"checksums.txt","browser_download_url":"https://assets.invalid/checksums.txt"}]}'
;;
https://assets.invalid/dws-linux-amd64.tar.gz)
cp "$FAKE_RELEASE_DIR/dws-linux-amd64.tar.gz" "$out"
;;
https://assets.invalid/checksums.txt)
cp "$FAKE_RELEASE_DIR/checksums.txt" "$out"
;;
*)
echo "fake curl: unexpected URL $url" >&2
exit 1
;;
esac
`
mustWriteFile(t, path, []byte(script), 0o755)
}
func mustWriteFile(t *testing.T, path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {