Skip to content

Commit 5fb4f0c

Browse files
CLI release-plan recovery cannot create a previously absent exact tag with its GitHub Actions token.
1 parent 46d4940 commit 5fb4f0c

4 files changed

Lines changed: 544 additions & 21 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
name: Release plan recovery
2+
3+
run-name: Recover CLI from ${{ inputs.plan_tag || 'latest public release plan' }}
4+
5+
on:
6+
schedule:
7+
- cron: '41 * * * *'
8+
workflow_dispatch:
9+
inputs:
10+
plan_tag:
11+
description: Immutable release-plan tag; empty selects the newest public plan
12+
required: false
13+
type: string
14+
default: ''
15+
16+
permissions:
17+
contents: read
18+
19+
concurrency:
20+
group: release-plan-recovery-cli-${{ inputs.plan_tag || 'latest' }}
21+
cancel-in-progress: false
22+
23+
jobs:
24+
discover:
25+
name: Discover exact CLI release
26+
runs-on: ubuntu-latest
27+
timeout-minutes: 10
28+
permissions:
29+
attestations: read
30+
contents: read
31+
outputs:
32+
action: ${{ steps.recovery.outputs.action }}
33+
plan: ${{ steps.recovery.outputs.plan }}
34+
plan_tag: ${{ steps.recovery.outputs.plan_tag }}
35+
version: ${{ steps.recovery.outputs.version }}
36+
commit: ${{ steps.recovery.outputs.commit }}
37+
steps:
38+
- uses: actions/checkout@v6
39+
40+
- name: Discover plan and verify upstream public artifacts
41+
id: recovery
42+
env:
43+
GITHUB_TOKEN: ${{ github.token }}
44+
REQUESTED_PLAN_TAG: ${{ inputs.plan_tag }}
45+
run: |
46+
arguments=(
47+
resolve
48+
--component cli
49+
--plan-output release-plan.json
50+
--preparation-output release-preparation.json
51+
--evidence release-recovery-evidence.json
52+
--github-output "$GITHUB_OUTPUT"
53+
)
54+
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
55+
arguments+=(--allow-empty)
56+
elif [ -n "$REQUESTED_PLAN_TAG" ]; then
57+
arguments+=(--plan-tag "$REQUESTED_PLAN_TAG")
58+
fi
59+
python scripts/ci/component-release-recovery.py "${arguments[@]}"
60+
61+
- name: Retain recovery evidence
62+
if: always()
63+
uses: actions/upload-artifact@v7
64+
with:
65+
name: cli-release-recovery-${{ steps.recovery.outputs.plan || github.run_id }}
66+
path: |
67+
release-plan.json
68+
release-preparation.json
69+
release-recovery-evidence.json
70+
if-no-files-found: warn
71+
72+
publish:
73+
name: Publish exact CLI release
74+
needs: discover
75+
if: needs.discover.outputs.action == 'publish'
76+
runs-on: ubuntu-latest
77+
timeout-minutes: 45
78+
environment: release-plan-publication
79+
permissions:
80+
actions: write
81+
attestations: read
82+
contents: read
83+
steps:
84+
- name: Require repository publication credential
85+
env:
86+
CLI_RELEASE_DEPLOY_KEY: ${{ secrets.CLI_RELEASE_DEPLOY_KEY }}
87+
run: |
88+
set -euo pipefail
89+
if [ -z "$CLI_RELEASE_DEPLOY_KEY" ]; then
90+
printf 'The release-plan-publication environment has no repository write deploy key.\n' >&2
91+
exit 1
92+
fi
93+
94+
- uses: actions/checkout@v6
95+
with:
96+
fetch-depth: 0
97+
ssh-key: ${{ secrets.CLI_RELEASE_DEPLOY_KEY }}
98+
99+
- name: Restore the immutable release plan
100+
uses: actions/download-artifact@v8
101+
with:
102+
name: cli-release-recovery-${{ needs.discover.outputs.plan }}
103+
path: recovery-input
104+
105+
- name: Create or verify the exact planned source tag
106+
env:
107+
PLAN_TAG: ${{ needs.discover.outputs.plan_tag }}
108+
RELEASE_TAG: ${{ needs.discover.outputs.version }}
109+
RELEASE_COMMIT: ${{ needs.discover.outputs.commit }}
110+
run: |
111+
python scripts/ci/publish-planned-tag.py \
112+
--tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT" --plan-tag "$PLAN_TAG" \
113+
--plan recovery-input/release-plan.json \
114+
--evidence release-tag-publication-evidence.json
115+
116+
- name: Verify the protected source tag through GitHub
117+
env:
118+
GH_TOKEN: ${{ github.token }}
119+
RELEASE_TAG: ${{ needs.discover.outputs.version }}
120+
RELEASE_COMMIT: ${{ needs.discover.outputs.commit }}
121+
run: scripts/ci/verify-release-tag-source.sh
122+
123+
- name: Start or resume the exact repository-owned publication run
124+
env:
125+
GH_TOKEN: ${{ github.token }}
126+
PLAN_TAG: ${{ needs.discover.outputs.plan_tag }}
127+
RELEASE_TAG: ${{ needs.discover.outputs.version }}
128+
RELEASE_COMMIT: ${{ needs.discover.outputs.commit }}
129+
run: |
130+
set -euo pipefail
131+
if [ -z "$GH_TOKEN" ]; then
132+
printf 'The protected publication job has no GitHub Actions credential.\n' >&2
133+
exit 1
134+
fi
135+
publication_title="Release ${RELEASE_TAG} for ${PLAN_TAG}"
136+
137+
select_publication_run() {
138+
gh run list --workflow release.yml --event workflow_dispatch --branch main --limit 100 \
139+
--json databaseId,event,displayTitle,headBranch,headSha,status,conclusion,url \
140+
> publication-runs.json
141+
python scripts/ci/component-release-recovery.py select-publication-run \
142+
--release-tag "$RELEASE_TAG" --release-commit "$RELEASE_COMMIT" \
143+
--required-event workflow_dispatch --required-head-branch main \
144+
--required-display-title "$publication_title" --runs publication-runs.json
145+
}
146+
147+
retain_publication_run() {
148+
gh run view "$run_id" \
149+
--json databaseId,event,displayTitle,headBranch,headSha,status,conclusion,url,workflowName \
150+
> publication-run.json
151+
arguments=(
152+
retain-publication-run
153+
--repository "$GITHUB_REPOSITORY"
154+
--release-tag "$RELEASE_TAG"
155+
--release-commit "$RELEASE_COMMIT"
156+
--control-ref main
157+
--display-title "$publication_title"
158+
--run-id "$run_id"
159+
--run publication-run.json
160+
--evidence release-publication-run-evidence.json
161+
)
162+
if [ "${1:-}" = resumed ]; then
163+
arguments+=(--reject-completed-failure)
164+
elif [ "${1:-}" = success ]; then
165+
arguments+=(--require-success)
166+
fi
167+
python scripts/ci/component-release-recovery.py "${arguments[@]}"
168+
}
169+
170+
decision="$(select_publication_run)"
171+
IFS=$'\t' read -r publication_action run_id status conclusion <<< "$decision"
172+
if [ "$publication_action" = dispatch ]; then
173+
python - "$RELEASE_TAG" "$RELEASE_COMMIT" "$PLAN_TAG" > publication-dispatch-request.json <<'PY'
174+
import json
175+
import sys
176+
177+
tag, commit, plan = sys.argv[1:]
178+
json.dump(
179+
{
180+
"ref": "main",
181+
"inputs": {"tag": tag, "release_commit": commit, "release_plan": plan},
182+
},
183+
sys.stdout,
184+
separators=(",", ":"),
185+
)
186+
PY
187+
gh api --method POST \
188+
-H 'X-GitHub-Api-Version: 2026-03-10' \
189+
"repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/dispatches" \
190+
--input publication-dispatch-request.json > publication-dispatch.json
191+
run_id="$(python scripts/ci/component-release-recovery.py validate-dispatch-response \
192+
--repository "$GITHUB_REPOSITORY" --response publication-dispatch.json)"
193+
publication_action=wait
194+
elif [ "$publication_action" = rerun ]; then
195+
retain_publication_run
196+
gh run rerun "$run_id"
197+
publication_action=wait
198+
fi
199+
200+
retained=false
201+
for attempt in {1..12}; do
202+
if retain_publication_run resumed; then
203+
retained=true
204+
break
205+
fi
206+
[ "$attempt" -eq 12 ] || sleep 5
207+
done
208+
if [ "$retained" != true ]; then
209+
printf 'Exact publication run %s did not become observable with the planned identity.\n' \
210+
"$run_id" >&2
211+
exit 1
212+
fi
213+
214+
if [ "$publication_action" = wait ]; then
215+
gh run watch "$run_id" --exit-status --interval 10
216+
else
217+
printf 'Exact publication run %s is already completed successfully.\n' "$run_id"
218+
fi
219+
retain_publication_run success
220+
221+
- name: Verify installable public CLI artifacts
222+
env:
223+
GITHUB_TOKEN: ${{ github.token }}
224+
run: |
225+
python scripts/ci/component-release-recovery.py verify \
226+
--component cli --plan recovery-input/release-plan.json \
227+
--attempts 6 --sleep 10 --evidence release-completion-evidence.json
228+
229+
- name: Retain publication evidence
230+
if: always()
231+
uses: actions/upload-artifact@v7
232+
with:
233+
name: cli-release-publication-${{ needs.discover.outputs.plan }}
234+
path: |
235+
release-tag-publication-evidence.json
236+
publication-runs.json
237+
publication-dispatch-request.json
238+
publication-dispatch.json
239+
publication-run.json
240+
release-publication-run-evidence.json
241+
release-completion-evidence.json
242+
if-no-files-found: warn

0 commit comments

Comments
 (0)