Skip to content

Commit 708edb2

Browse files
committed
fix: validate submit run ids and publish release assets
1 parent 186170e commit 708edb2

14 files changed

Lines changed: 1013 additions & 208 deletions
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
name: Release Assets
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
tag_name:
7+
required: true
8+
type: string
9+
release_sha:
10+
required: false
11+
default: ""
12+
type: string
13+
ref:
14+
required: false
15+
default: ""
16+
type: string
17+
replace_existing:
18+
required: false
19+
default: false
20+
type: boolean
21+
workflow_dispatch:
22+
inputs:
23+
tag_name:
24+
description: Release tag to backfill or publish assets for.
25+
required: true
26+
default: v0.3.0
27+
type: string
28+
ref:
29+
description: Git ref to build. Leave empty to use tag_name.
30+
required: false
31+
default: ""
32+
type: string
33+
release_sha:
34+
description: Optional release commit SHA. Leave empty to resolve from tag_name.
35+
required: false
36+
default: ""
37+
type: string
38+
replace_existing:
39+
description: Delete and replace only the known Codencer release assets.
40+
required: false
41+
default: false
42+
type: boolean
43+
44+
permissions:
45+
contents: write
46+
actions: read
47+
48+
concurrency:
49+
group: release-assets-${{ inputs.tag_name }}
50+
cancel-in-progress: false
51+
52+
jobs:
53+
preflight:
54+
runs-on: ubuntu-latest
55+
timeout-minutes: 10
56+
outputs:
57+
tag_name: ${{ steps.resolve.outputs.tag_name }}
58+
ref: ${{ steps.resolve.outputs.ref }}
59+
release_sha: ${{ steps.resolve.outputs.release_sha }}
60+
built_at: ${{ steps.resolve.outputs.built_at }}
61+
62+
steps:
63+
- name: Check out repository metadata
64+
uses: actions/checkout@v4
65+
with:
66+
fetch-depth: 0
67+
68+
- name: Resolve release inputs
69+
id: resolve
70+
env:
71+
TAG_NAME: ${{ inputs.tag_name }}
72+
INPUT_REF: ${{ inputs.ref }}
73+
INPUT_RELEASE_SHA: ${{ inputs.release_sha }}
74+
GH_TOKEN: ${{ github.token }}
75+
run: |
76+
set -euo pipefail
77+
78+
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._-]+)?$ ]]; then
79+
echo "Invalid tag_name: $TAG_NAME" >&2
80+
exit 1
81+
fi
82+
83+
git fetch --tags --force
84+
git rev-parse "$TAG_NAME^{commit}" >/dev/null
85+
gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null
86+
87+
ref="${INPUT_REF:-$TAG_NAME}"
88+
if [[ -n "$INPUT_RELEASE_SHA" ]]; then
89+
git rev-parse "$INPUT_RELEASE_SHA^{commit}" >/dev/null
90+
release_sha="$INPUT_RELEASE_SHA"
91+
else
92+
release_sha="$(git rev-list -n 1 "$TAG_NAME")"
93+
fi
94+
built_at="$(git show -s --format=%cI "$release_sha" | python3 -c 'from datetime import datetime, timezone; import sys; raw=sys.stdin.read().strip(); value=datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(timezone.utc); print(value.isoformat().replace("+00:00", "Z"))')"
95+
96+
{
97+
echo "tag_name=$TAG_NAME"
98+
echo "ref=$ref"
99+
echo "release_sha=$release_sha"
100+
echo "built_at=$built_at"
101+
} >> "$GITHUB_OUTPUT"
102+
103+
build-linux-amd64:
104+
needs: preflight
105+
runs-on: ubuntu-latest
106+
timeout-minutes: 45
107+
env:
108+
TAG_NAME: ${{ needs.preflight.outputs.tag_name }}
109+
110+
steps:
111+
- name: Check out release ref
112+
uses: actions/checkout@v4
113+
with:
114+
ref: ${{ needs.preflight.outputs.ref }}
115+
fetch-depth: 0
116+
117+
- name: Set up Go
118+
uses: actions/setup-go@v5
119+
with:
120+
go-version-file: go.mod
121+
122+
- name: Install Linux build prerequisites
123+
run: |
124+
sudo apt-get update
125+
sudo apt-get install -y --no-install-recommends build-essential
126+
127+
- name: Build linux/amd64 release snapshot
128+
run: make release-snapshot VERSION="${TAG_NAME}" TARGETS=linux/amd64 REQUIRE_TARGETS=linux/amd64
129+
130+
- name: Verify linux/amd64 self-host artifact
131+
run: make verify-release-artifact-selfhost VERSION="${TAG_NAME}" TARGETS=linux/amd64 REQUIRE_TARGETS=linux/amd64
132+
133+
- name: Upload linux/amd64 workflow artifact
134+
uses: actions/upload-artifact@v4
135+
with:
136+
name: codencer-linux-amd64
137+
path: dist/codencer_${{ env.TAG_NAME }}_linux_amd64.tar.gz
138+
if-no-files-found: error
139+
140+
build-macos-host:
141+
needs: preflight
142+
runs-on: macos-latest
143+
timeout-minutes: 45
144+
env:
145+
TAG_NAME: ${{ needs.preflight.outputs.tag_name }}
146+
147+
steps:
148+
- name: Check out release ref
149+
uses: actions/checkout@v4
150+
with:
151+
ref: ${{ needs.preflight.outputs.ref }}
152+
fetch-depth: 0
153+
154+
- name: Set up Go
155+
uses: actions/setup-go@v5
156+
with:
157+
go-version-file: go.mod
158+
159+
- name: Build macOS host release snapshot
160+
run: make release-snapshot VERSION="${TAG_NAME}" TARGETS=host REQUIRE_TARGETS=host
161+
162+
- name: Verify macOS host self-host artifact
163+
run: make verify-release-artifact-selfhost VERSION="${TAG_NAME}" TARGETS=host REQUIRE_TARGETS=host
164+
165+
- name: Upload macOS host workflow artifact
166+
uses: actions/upload-artifact@v4
167+
with:
168+
name: codencer-macos-host
169+
path: dist/codencer_${{ env.TAG_NAME }}_darwin_*.tar.gz
170+
if-no-files-found: error
171+
172+
publish-assets:
173+
needs:
174+
- preflight
175+
- build-linux-amd64
176+
- build-macos-host
177+
runs-on: ubuntu-latest
178+
timeout-minutes: 20
179+
env:
180+
TAG_NAME: ${{ needs.preflight.outputs.tag_name }}
181+
RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }}
182+
BUILT_AT: ${{ needs.preflight.outputs.built_at }}
183+
REPLACE_EXISTING: ${{ inputs.replace_existing }}
184+
GH_TOKEN: ${{ github.token }}
185+
186+
steps:
187+
- name: Download release workflow artifacts
188+
uses: actions/download-artifact@v4
189+
with:
190+
pattern: codencer-*
191+
path: release-assets
192+
merge-multiple: true
193+
194+
- name: Generate checksums and manifest
195+
run: |
196+
python3 <<'PY'
197+
import hashlib
198+
import json
199+
import os
200+
import re
201+
from pathlib import Path
202+
203+
root = Path("release-assets")
204+
tag = os.environ["TAG_NAME"]
205+
release_sha = os.environ["RELEASE_SHA"]
206+
built_at = os.environ["BUILT_AT"]
207+
archives = sorted(root.glob("codencer_*.tar.gz"))
208+
linux_name = f"codencer_{tag}_linux_amd64.tar.gz"
209+
linux = [path for path in archives if path.name == linux_name]
210+
darwin = [
211+
path
212+
for path in archives
213+
if re.fullmatch(rf"codencer_{re.escape(tag)}_darwin_[A-Za-z0-9]+\.tar\.gz", path.name)
214+
]
215+
216+
if len(linux) != 1:
217+
raise SystemExit(f"expected exactly one linux/amd64 archive named {linux_name}, got {len(linux)}")
218+
if len(darwin) != 1:
219+
raise SystemExit(f"expected exactly one darwin host archive for {tag}, got {len(darwin)}")
220+
221+
assets = []
222+
checksum_lines = []
223+
for path in linux + darwin:
224+
match = re.fullmatch(rf"codencer_{re.escape(tag)}_(linux|darwin)_([A-Za-z0-9]+)\.tar\.gz", path.name)
225+
if not match:
226+
raise SystemExit(f"unexpected archive name: {path.name}")
227+
data = path.read_bytes()
228+
digest = hashlib.sha256(data).hexdigest()
229+
os_name, arch = match.groups()
230+
runner = "ubuntu-latest" if os_name == "linux" else "macos-latest"
231+
checksum_lines.append(f"{digest} {path.name}")
232+
assets.append({
233+
"filename": path.name,
234+
"sha256": digest,
235+
"os": os_name,
236+
"arch": arch,
237+
"runner": runner,
238+
})
239+
240+
(root / "checksums.txt").write_text("\n".join(checksum_lines) + "\n", encoding="utf-8")
241+
manifest = {
242+
"version": tag,
243+
"tag_name": tag,
244+
"release_sha": release_sha,
245+
"built_at": built_at,
246+
"assets": assets,
247+
"note": "Artifacts were built by GitHub Actions from the Release Assets workflow.",
248+
}
249+
for key in ("version", "tag_name", "release_sha", "built_at", "assets"):
250+
if not manifest.get(key):
251+
raise SystemExit(f"manifest missing {key}")
252+
if len(manifest["assets"]) != 2:
253+
raise SystemExit("manifest must describe exactly two binary archives")
254+
for asset in manifest["assets"]:
255+
for key in ("filename", "sha256", "os", "arch", "runner"):
256+
if not asset.get(key):
257+
raise SystemExit(f"manifest asset missing {key}: {asset}")
258+
(root / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
259+
PY
260+
261+
- name: Publish missing release assets
262+
run: |
263+
set -euo pipefail
264+
265+
mapfile -t archives < <(find release-assets -maxdepth 1 -type f -name 'codencer_*.tar.gz' -printf '%f\n' | sort)
266+
linux="codencer_${TAG_NAME}_linux_amd64.tar.gz"
267+
mapfile -t darwin_matches < <(printf '%s\n' "${archives[@]}" | grep -E "^codencer_${TAG_NAME}_darwin_[A-Za-z0-9]+\\.tar\\.gz$" || true)
268+
if [[ ${#darwin_matches[@]} -ne 1 ]]; then
269+
echo "Expected exactly one darwin host archive, got ${#darwin_matches[@]}" >&2
270+
exit 1
271+
fi
272+
darwin="${darwin_matches[0]}"
273+
target_names=("$linux" "$darwin" "checksums.txt" "manifest.json")
274+
275+
release_assets_json="$(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" --json assets)"
276+
upload_files=()
277+
skipped=()
278+
replaced=()
279+
280+
rm -rf existing-assets
281+
mkdir -p existing-assets
282+
283+
for name in "${target_names[@]}"; do
284+
local_path="release-assets/$name"
285+
if [[ ! -f "$local_path" ]]; then
286+
echo "Expected release asset file missing: $local_path" >&2
287+
exit 1
288+
fi
289+
290+
exists="$(printf '%s' "$release_assets_json" | python3 -c 'import json,sys; name=sys.argv[1]; data=json.load(sys.stdin); print("yes" if any(asset.get("name") == name for asset in data.get("assets", [])) else "no")' "$name")"
291+
if [[ "$exists" == "yes" ]]; then
292+
if [[ "$REPLACE_EXISTING" == "true" ]]; then
293+
gh release delete-asset "$TAG_NAME" "$name" --repo "$GITHUB_REPOSITORY" -y
294+
upload_files+=("$local_path")
295+
replaced+=("$name")
296+
continue
297+
fi
298+
299+
rm -rf "existing-assets/$name"
300+
if ! gh release download "$TAG_NAME" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir existing-assets >/dev/null; then
301+
echo "Existing release asset cannot be downloaded for checksum verification: $name" >&2
302+
exit 1
303+
fi
304+
if [[ ! -f "existing-assets/$name" ]]; then
305+
echo "Existing release asset was not downloaded for checksum verification: $name" >&2
306+
exit 1
307+
fi
308+
local_sha="$(sha256sum "$local_path" | awk '{print $1}')"
309+
existing_sha="$(sha256sum "existing-assets/$name" | awk '{print $1}')"
310+
if [[ "$local_sha" != "$existing_sha" ]]; then
311+
echo "Release asset already exists with different checksum: $name" >&2
312+
exit 1
313+
fi
314+
skipped+=("$name")
315+
else
316+
upload_files+=("$local_path")
317+
fi
318+
done
319+
320+
if [[ ${#upload_files[@]} -gt 0 ]]; then
321+
gh release upload "$TAG_NAME" "${upload_files[@]}" --repo "$GITHUB_REPOSITORY"
322+
fi
323+
324+
{
325+
echo "## Release asset publish summary"
326+
echo
327+
echo "- Tag: \`$TAG_NAME\`"
328+
echo "- replace_existing: \`$REPLACE_EXISTING\`"
329+
echo "- uploaded: ${#upload_files[@]}"
330+
for file in "${upload_files[@]}"; do
331+
echo " - $(basename "$file")"
332+
done
333+
echo "- skipped_identical: ${#skipped[@]}"
334+
for name in "${skipped[@]}"; do
335+
echo " - $name"
336+
done
337+
echo "- replaced: ${#replaced[@]}"
338+
for name in "${replaced[@]}"; do
339+
echo " - $name"
340+
done
341+
} >> "$GITHUB_STEP_SUMMARY"
342+
343+
- name: Append release asset note
344+
run: |
345+
set -euo pipefail
346+
347+
marker="<!-- codencer-release-assets-note -->"
348+
gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" --json body -q '.body // ""' > release-body.md
349+
if grep -Fq "$marker" release-body.md; then
350+
echo "Release asset note already present."
351+
exit 0
352+
fi
353+
354+
cat >> release-body.md <<'EOF'
355+
356+
<!-- codencer-release-assets-note -->
357+
## Codencer self-host release assets
358+
359+
- Public self-host release.
360+
- Installable product artifacts are attached as `codencer_*.tar.gz`.
361+
- GitHub source archives are not installable Codencer binary releases.
362+
- Codex real executor proof: passed.
363+
- Claude Code real executor proof: passed.
364+
- Antigravity: optional/deferred, not release-proven.
365+
- Cloud/official connector: out of scope for this public self-host release.
366+
- Checksums are attached in `checksums.txt`.
367+
- Machine-readable artifact metadata is attached in `manifest.json`.
368+
EOF
369+
370+
gh release edit "$TAG_NAME" --repo "$GITHUB_REPOSITORY" --notes-file release-body.md

0 commit comments

Comments
 (0)