Skip to content

Commit 03c531c

Browse files
committed
ci: publish runtime release assets
1 parent bc4220d commit 03c531c

2 files changed

Lines changed: 240 additions & 1 deletion

File tree

.github/workflows/runtime-build-win.yml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ name: runtime-build-windows
33
on:
44
workflow_dispatch:
55
inputs:
6+
version:
7+
description: "Release version, for example 1.0.0"
8+
required: true
69
notes:
710
description: "Release notes stored in latest.json"
811
required: false
@@ -15,9 +18,11 @@ jobs:
1518
build:
1619
runs-on: windows-latest
1720
permissions:
18-
contents: read
21+
contents: write
1922
env:
23+
VERSION: ${{ github.event.inputs.version || '' }}
2024
RELEASE_NOTES: ${{ github.event.inputs.notes || '' }}
25+
RELEASE_TAG: ${{ github.ref_name }}
2126
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
2227
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
2328
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
@@ -29,6 +34,9 @@ jobs:
2934
steps:
3035
- name: Checkout
3136
uses: actions/checkout@v4
37+
with:
38+
fetch-depth: 0
39+
fetch-tags: true
3240

3341
- name: Setup Node.js
3442
uses: actions/setup-node@v4
@@ -110,6 +118,16 @@ jobs:
110118
Write-Host "Uploading latest.json to R2: $destination"
111119
aws s3 cp "latest.json" $destination --endpoint-url $endpoint --region auto
112120
121+
- name: Publish GitHub release assets
122+
if: startsWith(github.ref, 'refs/tags/')
123+
shell: powershell
124+
env:
125+
GH_TOKEN: ${{ github.token }}
126+
GH_REPO: ${{ github.repository }}
127+
RELEASE_BINARY_PATH: target/release/simprint-runtime.exe
128+
RELEASE_LATEST_JSON_PATH: latest.json
129+
run: node .\scripts\publish-github-release.mjs
130+
113131
- name: Upload latest.json artifact
114132
if: github.event_name == 'workflow_dispatch'
115133
uses: actions/upload-artifact@v4

scripts/publish-github-release.mjs

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
5+
const repository = process.env.GH_REPO || process.env.GITHUB_REPOSITORY;
6+
const tag = process.env.RELEASE_TAG;
7+
const binaryPath = path.resolve(
8+
process.env.RELEASE_BINARY_PATH || 'target/release/simprint-runtime.exe'
9+
);
10+
const latestJsonPath = path.resolve(process.env.RELEASE_LATEST_JSON_PATH || 'latest.json');
11+
12+
if (!token) throw new Error('GH_TOKEN or GITHUB_TOKEN is not set');
13+
if (!repository) throw new Error('GH_REPO or GITHUB_REPOSITORY is not set');
14+
if (!tag) throw new Error('RELEASE_TAG is not set');
15+
16+
const [owner, repo] = repository.split('/');
17+
if (!owner || !repo) throw new Error(`Invalid repository: ${repository}`);
18+
19+
const tagNotes = await readTagNotes(tag);
20+
const release = await ensureRelease(tagNotes);
21+
22+
await uploadAsset(release, binaryPath, 'application/octet-stream');
23+
await uploadAsset(release, latestJsonPath, 'application/json');
24+
25+
console.log(`GitHub release publish finished for ${tag}`);
26+
27+
async function readTagNotes(tagName) {
28+
try {
29+
const refResponse = await githubApi(`/repos/${owner}/${repo}/git/ref/tags/${encodeURIComponent(tagName)}`, {
30+
okStatuses: [200, 404],
31+
});
32+
33+
if (refResponse.status === 404) {
34+
console.log(`Tag ref ${tagName} not found on GitHub, fallback to generated release notes`);
35+
return '';
36+
}
37+
38+
const target = refResponse.json?.object;
39+
if (!target || target.type !== 'tag' || !target.sha) {
40+
console.log(`Tag ${tagName} is not an annotated tag on GitHub, fallback to generated release notes`);
41+
return '';
42+
}
43+
44+
const tagObject = await githubApi(`/repos/${owner}/${repo}/git/tags/${target.sha}`, {
45+
okStatuses: [200, 404],
46+
});
47+
48+
if (tagObject.status === 404) {
49+
console.log(`Annotated tag object for ${tagName} not found on GitHub, fallback to generated release notes`);
50+
return '';
51+
}
52+
53+
return (tagObject.json?.message || '').trim();
54+
} catch (error) {
55+
console.warn(`Failed to read GitHub tag notes for ${tagName}: ${error.message}`);
56+
return '';
57+
}
58+
}
59+
60+
async function ensureRelease(tagNotes) {
61+
const existing = await githubApi(
62+
`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
63+
{ okStatuses: [200, 404] }
64+
);
65+
66+
if (existing.status === 200) {
67+
console.log(`Using existing GitHub release for ${tag}`);
68+
return await syncExistingRelease(existing.json, tagNotes);
69+
}
70+
71+
console.log(`Creating GitHub release for ${tag}`);
72+
const created = await githubApi(`/repos/${owner}/${repo}/releases`, {
73+
method: 'POST',
74+
json: buildReleasePayload(tagNotes),
75+
okStatuses: [201, 422],
76+
});
77+
78+
if (created.status === 201) {
79+
return created.json;
80+
}
81+
82+
const refetched = await githubApi(`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`);
83+
return refetched.json;
84+
}
85+
86+
async function syncExistingRelease(release, tagNotes) {
87+
const desiredBody = tagNotes || '';
88+
const currentBody = release.body || '';
89+
const shouldUpdateBody = Boolean(tagNotes) && currentBody !== desiredBody;
90+
const shouldEnableGeneratedNotes = !tagNotes && !currentBody;
91+
92+
if (!shouldUpdateBody && !shouldEnableGeneratedNotes) {
93+
return release;
94+
}
95+
96+
console.log(`Updating GitHub release metadata for ${tag}`);
97+
const updated = await githubApi(`/repos/${owner}/${repo}/releases/${release.id}`, {
98+
method: 'PATCH',
99+
json: buildReleasePayload(tagNotes),
100+
okStatuses: [200],
101+
});
102+
103+
return updated.json;
104+
}
105+
106+
function buildReleasePayload(tagNotes) {
107+
const payload = {
108+
tag_name: tag,
109+
name: tag,
110+
};
111+
112+
if (tagNotes) {
113+
payload.body = tagNotes;
114+
} else {
115+
payload.generate_release_notes = true;
116+
}
117+
118+
return payload;
119+
}
120+
121+
async function uploadAsset(release, filePath, contentType) {
122+
const fileName = path.basename(filePath);
123+
const fileBuffer = await fs.readFile(filePath);
124+
125+
await deleteExistingAsset(release, fileName);
126+
127+
const uploadUrl = release.upload_url.replace('{?name,label}', `?name=${encodeURIComponent(fileName)}`);
128+
console.log(`Uploading ${fileName}`);
129+
130+
await retry(`upload ${fileName}`, async () => {
131+
const response = await fetch(uploadUrl, {
132+
method: 'POST',
133+
headers: {
134+
Authorization: `Bearer ${token}`,
135+
Accept: 'application/vnd.github+json',
136+
'Content-Type': contentType,
137+
'Content-Length': String(fileBuffer.length),
138+
},
139+
body: fileBuffer,
140+
});
141+
142+
if (!response.ok) {
143+
const text = await response.text();
144+
throw new Error(`upload failed ${response.status}: ${text}`);
145+
}
146+
});
147+
}
148+
149+
async function deleteExistingAsset(release, fileName) {
150+
const asset = (release.assets || []).find((item) => item.name === fileName);
151+
if (!asset) return;
152+
153+
console.log(`Deleting existing asset ${fileName}`);
154+
await githubApi(`/repos/${owner}/${repo}/releases/assets/${asset.id}`, {
155+
method: 'DELETE',
156+
okStatuses: [204],
157+
});
158+
}
159+
160+
async function githubApi(apiPath, options = {}) {
161+
const url = apiPath.startsWith('http') ? apiPath : `https://api.github.com${apiPath}`;
162+
const headers = {
163+
Authorization: `Bearer ${token}`,
164+
Accept: 'application/vnd.github+json',
165+
'X-GitHub-Api-Version': '2022-11-28',
166+
...options.headers,
167+
};
168+
169+
let body;
170+
if (options.json !== undefined) {
171+
body = JSON.stringify(options.json);
172+
headers['Content-Type'] = 'application/json';
173+
} else if (options.body !== undefined) {
174+
body = options.body;
175+
}
176+
177+
const response = await fetch(url, {
178+
method: options.method || 'GET',
179+
headers,
180+
body,
181+
});
182+
183+
const okStatuses = options.okStatuses || [200];
184+
const contentType = response.headers.get('content-type') || '';
185+
const payload = contentType.includes('application/json')
186+
? await response.json().catch(() => null)
187+
: await response.text().catch(() => '');
188+
189+
if (!okStatuses.includes(response.status)) {
190+
throw new Error(`${options.method || 'GET'} ${url} failed ${response.status}: ${formatPayload(payload)}`);
191+
}
192+
193+
return { status: response.status, json: payload };
194+
}
195+
196+
async function retry(label, fn, attempts = 4) {
197+
let lastError;
198+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
199+
try {
200+
await fn();
201+
return;
202+
} catch (error) {
203+
lastError = error;
204+
if (attempt === attempts) break;
205+
const delayMs = attempt * 2000;
206+
console.warn(`${label} failed on attempt ${attempt}/${attempts}: ${error.message}`);
207+
console.warn(`Retrying in ${delayMs}ms`);
208+
await new Promise((resolve) => setTimeout(resolve, delayMs));
209+
}
210+
}
211+
throw lastError;
212+
}
213+
214+
function formatPayload(payload) {
215+
if (typeof payload === 'string') return payload;
216+
try {
217+
return JSON.stringify(payload);
218+
} catch {
219+
return String(payload);
220+
}
221+
}

0 commit comments

Comments
 (0)