Compare commits

...
Author SHA1 Message Date
修雨 978ee6e636 fix(release): fail closed on skipped publication 2026-07-18 11:34:34 +08:00
5 changed files with 383 additions and 21 deletions
+139 -12
View File
@@ -35,6 +35,10 @@ on:
description: "Failed exact-tag Release workflow run ID"
required: false
type: string
recover_failed_run_attempt:
description: "Failed attempt number within the exact-tag Release workflow run"
required: false
type: string
recover_release_nonce:
description: "Unique identifier for this protected recovery request"
required: false
@@ -72,6 +76,7 @@ jobs:
RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }}
RECOVER_COMMIT: ${{ inputs.recover_release_commit }}
RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }}
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
RECOVER_NONCE: ${{ inputs.recover_release_nonce }}
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
run: |
@@ -83,6 +88,7 @@ jobs:
if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi
if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \
test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -n "$RECOVER_NONCE" || \
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
test $((repair + governance + recovery)) -eq 1 || {
@@ -100,6 +106,7 @@ jobs:
else
if test -z "$RECOVER_VERSION" || test -z "$RECOVER_TAG_OBJECT" || \
test -z "$RECOVER_COMMIT" || test -z "$RECOVER_FAILED_RUN_ID" || \
test -z "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -z "$RECOVER_NONCE" || test -z "$RECOVER_CONFIRMATION"; then
echo "release recovery requires every recovery input" >&2
exit 1
@@ -120,6 +127,10 @@ jobs:
echo "recover_failed_run_id must be a positive run ID" >&2
exit 1
}
printf '%s\n' "$RECOVER_FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
echo "recover_failed_run_attempt must be a positive attempt number" >&2
exit 1
}
printf '%s\n' "$RECOVER_NONCE" | grep -Eq "^${RECOVER_COMMIT}-[0-9]+-[0-9]+$" || {
echo "recover_release_nonce must be bound to the release commit" >&2
exit 1
@@ -290,7 +301,7 @@ jobs:
release-contract:
needs: [dispatch-contract, authorize-recovery]
if: ${{ always() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -317,6 +328,7 @@ jobs:
RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }}
RECOVER_COMMIT: ${{ inputs.recover_release_commit }}
RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }}
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
with:
script: |
const { owner, repo } = context.repo;
@@ -325,6 +337,7 @@ jobs:
let commit = context.sha;
let expectedTagObject = "";
let failedRunId = "";
let failedRunAttempt = "";
if (isRecovery) {
if (
context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" ||
@@ -337,6 +350,7 @@ jobs:
commit = process.env.RECOVER_COMMIT;
expectedTagObject = process.env.RECOVER_TAG_OBJECT;
failedRunId = process.env.RECOVER_FAILED_RUN_ID;
failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT;
if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version)) {
core.setFailed(`invalid recovery release version: ${version}`);
return;
@@ -349,6 +363,10 @@ jobs:
core.setFailed("recover_failed_run_id must be a positive workflow run ID");
return;
}
if (!/^[1-9]\d*$/.test(failedRunAttempt)) {
core.setFailed("recover_failed_run_attempt must be a positive run attempt");
return;
}
}
const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` });
@@ -383,13 +401,19 @@ jobs:
core.setFailed(`${version} commit is not contained in ${defaultBranch}`);
return;
}
const failedRun = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: Number(failedRunId),
});
const failedRun = await github.request(
"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}",
{
owner,
repo,
run_id: Number(failedRunId),
attempt_number: Number(failedRunAttempt),
},
);
const run = failedRun.data;
if (
run.id !== Number(failedRunId) ||
run.run_attempt !== Number(failedRunAttempt) ||
run.repository.full_name !== `${owner}/${repo}` ||
run.path !== ".github/workflows/release.yml" ||
run.event !== "push" ||
@@ -398,7 +422,9 @@ jobs:
run.head_branch !== version ||
run.head_sha !== commit
) {
core.setFailed(`run ${failedRunId} is not the failed exact-tag Release run for ${version}`);
core.setFailed(
`run ${failedRunId} attempt ${failedRunAttempt} is not the failed exact-tag Release attempt for ${version}`,
);
return;
}
try {
@@ -622,7 +648,7 @@ jobs:
release:
name: Build signed release artifacts
if: ${{ needs.release-contract.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' }}
needs: [release-contract]
runs-on: ubuntu-latest
timeout-minutes: 60
@@ -788,7 +814,7 @@ jobs:
verify-darwin-signatures:
name: Verify Apple Developer ID signatures
if: ${{ needs.release.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}
needs: [release-contract, release]
runs-on: macos-latest
timeout-minutes: 10
@@ -816,7 +842,7 @@ jobs:
publish-release:
name: Publish immutable GitHub Release
if: ${{ needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}
needs: [release-contract, release, verify-darwin-signatures]
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -1055,7 +1081,7 @@ jobs:
publish-channels:
name: Publish npm and mirrors
if: ${{ needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}
needs: [release-contract, publish-release]
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -1286,7 +1312,7 @@ jobs:
mirror-gitee-release:
name: Mirror immutable release to Gitee
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
needs: [release-contract, release, publish-channels]
runs-on: ubuntu-latest
timeout-minutes: 120
@@ -1621,3 +1647,104 @@ jobs:
run: npm publish "$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz" --access public --tag backfill --ignore-scripts --registry=https://registry.npmjs.org
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release-delivery-gate:
name: Release delivery gate
if: ${{ !cancelled() }}
needs:
- dispatch-contract
- authorize-recovery
- governance-preflight
- release-contract
- release
- verify-darwin-signatures
- publish-release
- publish-channels
- mirror-gitee-release
- repair-npm
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
steps:
- name: Require the selected release mode to finish
env:
EVENT_NAME: ${{ github.event_name }}
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
GITEE_FALLBACK_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK }}
DISPATCH_RESULT: ${{ needs.dispatch-contract.result }}
AUTHORIZE_RECOVERY_RESULT: ${{ needs.authorize-recovery.result }}
GOVERNANCE_PREFLIGHT_RESULT: ${{ needs.governance-preflight.result }}
RELEASE_CONTRACT_RESULT: ${{ needs.release-contract.result }}
RELEASE_RESULT: ${{ needs.release.result }}
DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }}
PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }}
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
run: |
set -eu
require_result() {
job="$1"
actual="$2"
expected="$3"
test "$actual" = "$expected" || {
echo "$job result is $actual, expected $expected" >&2
exit 1
}
}
require_publication() {
require_result release-contract "$RELEASE_CONTRACT_RESULT" success
require_result release "$RELEASE_RESULT" success
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success
require_result publish-release "$PUBLISH_RELEASE_RESULT" success
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success
if test "$GITEE_FALLBACK_ENABLED" = true; then
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success
else
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
fi
}
case "$EVENT_NAME:$DISPATCH_MODE" in
push:)
require_result dispatch-contract "$DISPATCH_RESULT" skipped
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_publication
;;
workflow_dispatch:recover_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_publication
;;
workflow_dispatch:governance_preflight)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
;;
workflow_dispatch:repair_npm)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-npm "$REPAIR_NPM_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
;;
*)
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
exit 1
;;
esac
+10 -1
View File
@@ -16,7 +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>]
dws-release recover <version> [--failed-run <run-id>] [--failed-attempt <attempt>] [--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
@@ -200,6 +200,7 @@ recover_release() {
case "$recovery_version" in -h|--help) usage; exit 0 ;; esac
shift
recovery_failed_run=""
recovery_failed_attempt=""
while [ "$#" -gt 0 ]; do
case "$1" in
--failed-run)
@@ -207,6 +208,11 @@ recover_release() {
recovery_failed_run="$2"
shift 2
;;
--failed-attempt)
[ "$#" -ge 2 ] || die_usage '--failed-attempt requires a run attempt'
recovery_failed_attempt="$2"
shift 2
;;
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
REMOTE="$2"
@@ -224,6 +230,9 @@ recover_release() {
if [ -n "$recovery_failed_run" ]; then
set -- "$@" --failed-run "$recovery_failed_run"
fi
if [ -n "$recovery_failed_attempt" ]; then
set -- "$@" --failed-attempt "$recovery_failed_attempt"
fi
exec "$SCRIPT_DIR/recover-release.sh" "$@"
}
+101 -6
View File
@@ -8,11 +8,12 @@ ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
VERSION="${1:-}"
REMOTE=""
FAILED_RUN_ID=""
FAILED_RUN_ATTEMPT=""
EXPECTED_REPOSITORY="DingTalk-Real-AI/dingtalk-workspace-cli"
usage() {
cat >&2 <<'EOF'
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>]
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>] [--failed-attempt <attempt>]
Recovers one failed existing release tag through the protected default-branch
Release workflow. It never creates, moves, or deletes a tag.
@@ -25,6 +26,7 @@ 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 ;;
--failed-attempt) [ "$#" -ge 2 ] || { usage; exit 2; }; FAILED_RUN_ATTEMPT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown recovery argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
@@ -39,8 +41,46 @@ if [ -n "$FAILED_RUN_ID" ]; then
exit 2
}
fi
if [ -n "$FAILED_RUN_ATTEMPT" ]; then
[ -n "$FAILED_RUN_ID" ] || {
printf '%s\n' '--failed-attempt requires --failed-run' >&2
exit 2
}
printf '%s\n' "$FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run attempt: %s\n' "$FAILED_RUN_ATTEMPT" >&2
exit 2
}
fi
command -v gh >/dev/null 2>&1 || { printf '%s\n' 'gh is required for release recovery' >&2; exit 1; }
find_latest_failed_attempt() {
find_run_id="$1"
find_latest="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$find_run_id" \
--jq .run_attempt
)" || return 1
printf '%s\n' "$find_latest" | grep -Eq '^[1-9][0-9]*$' || return 1
find_attempt="$find_latest"
while [ "$find_attempt" -ge 1 ]; do
find_conclusion="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$find_run_id/attempts/$find_attempt" \
--jq .conclusion
)" || return 1
case "$find_conclusion" in
failure|cancelled|timed_out|startup_failure|stale)
printf '%s\n' "$find_attempt"
return 0
;;
esac
find_attempt=$((find_attempt - 1))
done
return 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
@@ -86,27 +126,81 @@ git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || {
}
if [ -z "$FAILED_RUN_ID" ]; then
failed_runs="$(
candidate_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"
--jq ".workflow_runs[] | select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\") | .id"
)" || {
printf 'could not query failed Release runs for %s\n' "$VERSION" >&2
printf 'could not query Release runs for %s\n' "$VERSION" >&2
exit 1
}
FAILED_RUN_ID="$(printf '%s\n' "$failed_runs" | sed -n '1p')"
for candidate_run_id in $candidate_runs; do
if candidate_attempt="$(find_latest_failed_attempt "$candidate_run_id")"; then
FAILED_RUN_ID="$candidate_run_id"
FAILED_RUN_ATTEMPT="$candidate_attempt"
break
fi
done
[ -n "$FAILED_RUN_ID" ] || {
printf 'no failed exact-tag Release run found for %s at %s\n' "$VERSION" "$commit" >&2
exit 1
}
elif [ -z "$FAILED_RUN_ATTEMPT" ]; then
FAILED_RUN_ATTEMPT="$(find_latest_failed_attempt "$FAILED_RUN_ID")" || {
printf 'Release run %s has no failed attempt\n' "$FAILED_RUN_ID" >&2
exit 1
}
fi
printf '%s\n' "$FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run attempt: %s\n' "$FAILED_RUN_ATTEMPT" >&2
exit 1
}
attempt_record="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$FAILED_RUN_ID/attempts/$FAILED_RUN_ATTEMPT" \
--jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv'
)" || {
printf 'could not query Release run %s attempt %s\n' "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" >&2
exit 1
}
attempt_id="$(printf '%s\n' "$attempt_record" | cut -f1)"
attempt_number="$(printf '%s\n' "$attempt_record" | cut -f2)"
attempt_repository="$(printf '%s\n' "$attempt_record" | cut -f3)"
attempt_path="$(printf '%s\n' "$attempt_record" | cut -f4)"
attempt_event="$(printf '%s\n' "$attempt_record" | cut -f5)"
attempt_status="$(printf '%s\n' "$attempt_record" | cut -f6)"
attempt_conclusion="$(printf '%s\n' "$attempt_record" | cut -f7)"
attempt_branch="$(printf '%s\n' "$attempt_record" | cut -f8)"
attempt_commit="$(printf '%s\n' "$attempt_record" | cut -f9)"
case "$attempt_conclusion" in
failure|cancelled|timed_out|startup_failure|stale) ;;
*)
printf 'Release run %s attempt %s is not failed: %s\n' \
"$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$attempt_conclusion" >&2
exit 1
;;
esac
if [ "$attempt_id" != "$FAILED_RUN_ID" ] ||
[ "$attempt_number" != "$FAILED_RUN_ATTEMPT" ] ||
[ "$attempt_repository" != "$EXPECTED_REPOSITORY" ] ||
[ "$attempt_path" != ".github/workflows/release.yml" ] ||
[ "$attempt_event" != "push" ] ||
[ "$attempt_status" != "completed" ] ||
[ "$attempt_branch" != "$VERSION" ] ||
[ "$attempt_commit" != "$commit" ]; then
printf 'Release run %s attempt %s does not match %s at %s\n' \
"$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$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"
printf ' failed run: https://github.com/%s/actions/runs/%s/attempts/%s\n' \
"$EXPECTED_REPOSITORY" "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT"
[ -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
@@ -121,6 +215,7 @@ gh workflow run release.yml \
-f "recover_release_tag_object=$tag_object" \
-f "recover_release_commit=$commit" \
-f "recover_failed_run_id=$FAILED_RUN_ID" \
-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT" \
-f "recover_release_nonce=$nonce" \
-f "recover_release_confirmation=$VERSION"
+131
View File
@@ -733,6 +733,7 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
"recover_release_tag_object:",
"recover_release_commit:",
"recover_failed_run_id:",
"recover_failed_run_attempt:",
"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)`,
@@ -745,6 +746,9 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
"can_admins_bypass !== false",
`run.path !== ".github/workflows/release.yml"`,
`run.event !== "push"`,
`"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}"`,
"attempt_number: Number(failedRunAttempt)",
"run.run_attempt !== Number(failedRunAttempt)",
`["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`,
`run.head_branch !== version`,
`run.head_sha !== commit`,
@@ -795,6 +799,133 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
}
}
func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "recover-release.sh"))
if err != nil {
t.Fatalf("Abs(recover-release.sh) error = %v", err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
script := string(data)
for _, required := range []string{
"--failed-attempt <attempt>",
"--failed-attempt requires --failed-run",
"find_latest_failed_attempt",
`while [ "$find_attempt" -ge 1 ]`,
"actions/runs/$find_run_id/attempts/$find_attempt",
`select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\")`,
"Release run %s has no failed attempt",
`[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv`,
"actions/runs/%s/attempts/%s",
`-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT"`,
} {
if !strings.Contains(script, required) {
t.Errorf("release recovery script is missing attempt binding %q", required)
}
}
}
func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
tests := []struct {
name string
start string
end string
condition string
}{
{
name: "release contract",
start: " release-contract:\n",
end: "\n release:\n",
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}`,
},
{
name: "build",
start: " release:\n",
end: "\n verify-darwin-signatures:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' }}`,
},
{
name: "Darwin verification",
start: " verify-darwin-signatures:\n",
end: "\n publish-release:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}`,
},
{
name: "GitHub publication",
start: " publish-release:\n",
end: "\n publish-channels:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}`,
},
{
name: "channel publication",
start: " publish-channels:\n",
end: "\n mirror-gitee-release:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}`,
},
{
name: "Gitee mirror",
start: " mirror-gitee-release:\n",
end: "\n repair-npm:\n",
condition: `if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
section := releaseWorkflowSection(t, workflow, test.start, test.end)
if !strings.Contains(section, test.condition) {
t.Errorf("%s must override skipped dispatch ancestors while preserving cancellation and dependency gates", test.name)
}
})
}
}
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
start := strings.Index(workflow, " release-delivery-gate:\n")
if start == -1 {
t.Fatal("release workflow is missing the terminal delivery gate")
}
gate := workflow[start:]
for _, required := range []string{
"name: Release delivery gate",
`if: ${{ !cancelled() }}`,
"- dispatch-contract",
"- authorize-recovery",
"- governance-preflight",
"- release-contract",
"- release",
"- verify-darwin-signatures",
"- publish-release",
"- publish-channels",
"- mirror-gitee-release",
"- repair-npm",
"require_publication",
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
`require_result release "$RELEASE_RESULT" success`,
`require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success`,
`require_result publish-release "$PUBLISH_RELEASE_RESULT" success`,
`require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`,
"workflow_dispatch:recover_release",
"workflow_dispatch:governance_preflight",
"workflow_dispatch:repair_npm",
"unsupported release mode",
} {
if !strings.Contains(gate, required) {
t.Errorf("release delivery gate is missing %q", required)
}
}
}
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
t.Parallel()
+2 -2
View File
@@ -38,11 +38,11 @@ func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
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")
output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345", "--failed-attempt", "2")
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"
want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\t--failed-attempt\t2\n"
if got := f.callLog(t); got != want {
t.Fatalf("recovery delegate calls = %q, want %q", got, want)
}