Skip to content

Commit 0ff527a

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

14 files changed

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

0 commit comments

Comments
 (0)