Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
978ee6e636 |
+139
-12
@@ -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
|
||||
|
||||
@@ -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" "$@"
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user