Skip to content

Commit 4c22691

Browse files
ci: add custom source-ref desktop builds (#96)
* chore(version): sync desktop version to v4.20.1 * ci: add custom source-ref desktop builds * fix(ci): sync Cargo.lock during version bumps * fix(ci): relax Cargo.lock version sync parsing * fix(ci): harden version sync test fixtures * fix(ci): tolerate missing Cargo.lock package * refactor(ci): simplify Cargo.lock version sync * fix(ci): keep custom releases out of prerelease * fix(ci): tighten Cargo.lock version matching * refactor(ci): simplify version sync status handling * test(ci): reuse temp desktop project setup --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 1b25467 commit 4c22691

7 files changed

Lines changed: 482 additions & 25 deletions

File tree

.github/workflows/build-desktop-tauri.yml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
required: false
99
default: https://github.com/AstrBotDevs/AstrBot.git
1010
source_git_ref:
11-
description: Optional source ref override for `tag-poll` (branch/tag/commit SHA). Ignored in `nightly`.
11+
description: Optional source ref override for `tag-poll` or required explicit source ref for `custom` (branch/tag/commit SHA).
1212
required: false
1313
default: ""
1414
publish_release:
@@ -18,14 +18,15 @@ on:
1818
default: true
1919
build_mode:
2020
description: >-
21-
Build mode (`tag-poll` | `nightly`): `nightly` (default) always builds latest upstream commit,
22-
`tag-poll` builds latest upstream tag (or `source_git_ref` override)
21+
Build mode (`tag-poll` | `nightly` | `custom`): `nightly` (default) always builds latest upstream commit,
22+
`tag-poll` builds latest upstream tag (or `source_git_ref` override), `custom` builds the explicit `source_git_ref`
2323
required: false
2424
type: choice
2525
default: nightly
2626
options:
2727
- tag-poll
2828
- nightly
29+
- custom
2930
schedule:
3031
# Hourly tag-poll (exclude the dedicated nightly window at 03:xx UTC).
3132
- cron: '0 0-2,4-23 * * *'
@@ -130,15 +131,15 @@ jobs:
130131
run: |
131132
set -euo pipefail
132133
133-
changed_files="$(git status --porcelain -- package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json)"
134+
changed_files="$(git status --porcelain -- package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json)"
134135
if [ -z "${changed_files}" ]; then
135136
echo "Version files are already up to date. Nothing to commit."
136137
exit 0
137138
fi
138139
139140
git config user.name "github-actions[bot]"
140141
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
141-
git add package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
142+
git add package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
142143
git commit -m "chore(version): sync desktop version to v${ASTRBOT_VERSION}"
143144
144145
git fetch origin "${TARGET_REF_NAME}"
@@ -551,6 +552,7 @@ jobs:
551552
python3 scripts/ci/validate-release-artifacts.py release-artifacts
552553
553554
- name: Generate Tauri updater manifest
555+
if: ${{ needs.resolve_build_context.outputs.build_mode != 'custom' }}
554556
env:
555557
RELEASE_TAG: ${{ needs.resolve_build_context.outputs.release_tag }}
556558
RELEASE_VERSION: ${{ needs.resolve_build_context.outputs.astrbot_version }}
@@ -600,7 +602,7 @@ jobs:
600602
fail_on_unmatched_files: true
601603

602604
- name: Demote previous prerelease marker
603-
if: ${{ needs.resolve_build_context.outputs.release_prerelease == 'true' }}
605+
if: ${{ needs.resolve_build_context.outputs.release_prerelease == 'true' && needs.resolve_build_context.outputs.build_mode == 'nightly' }}
604606
env:
605607
GH_TOKEN: ${{ github.token }}
606608
CURRENT_RELEASE_TAG: ${{ needs.resolve_build_context.outputs.release_tag }}

scripts/ci/fixtures/fake-git.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ case "${command_name}" in
5151
version = "${ASTRBOT_TEST_FETCHED_VERSION:-4.19.0}"
5252
EOF
5353
;;
54+
rev-parse)
55+
if [ -z "${1-}" ]; then
56+
printf 'git rev-parse expected a ref argument\n' >&2
57+
exit 1
58+
fi
59+
printf '%s\n' "${ASTRBOT_TEST_FETCHED_SHA:-3333333333333333333333333333333333333333}"
60+
;;
5461
*)
5562
printf 'unexpected git command: %s %s\n' "${command_name}" "$*" >&2
5663
exit 1

scripts/ci/resolve-build-context.sh

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ resolve_latest_upstream_tag() {
149149
printf '%s\n' "${latest_tag}"
150150
}
151151

152+
custom_build_mode_supported_for_event() {
153+
[ "$1" = "workflow_dispatch" ]
154+
}
155+
152156
source_git_url="${ASTRBOT_SOURCE_GIT_URL}"
153157
source_git_ref="${ASTRBOT_SOURCE_GIT_REF}"
154158
nightly_source_git_ref="${ASTRBOT_NIGHTLY_SOURCE_GIT_REF:-master}"
@@ -179,10 +183,10 @@ workflow_source_git_ref_provided="false"
179183
latest_upstream_tag=""
180184

181185
case "${requested_build_mode}" in
182-
auto|tag-poll|nightly) ;;
186+
auto|tag-poll|nightly|custom) ;;
183187
*)
184188
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
185-
echo "::error::invalid build_mode input '${requested_build_mode}'; expected tag-poll/nightly (auto is deprecated but still accepted and normalized to tag-poll for backward compatibility)."
189+
echo "::error::invalid build_mode input '${requested_build_mode}'; expected tag-poll/nightly/custom (auto is deprecated but still accepted and normalized to tag-poll for backward compatibility)."
186190
else
187191
echo "::error::invalid build_mode input '${requested_build_mode}'; expected auto/tag-poll/nightly."
188192
fi
@@ -221,6 +225,11 @@ if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
221225
fi
222226
fi
223227

228+
if [ "${requested_build_mode}" = "custom" ] && ! custom_build_mode_supported_for_event "${GITHUB_EVENT_NAME}"; then
229+
echo "::error::${GITHUB_EVENT_NAME} runs do not support build_mode=custom." >&2
230+
exit 1
231+
fi
232+
224233
# Normalize build mode in one place to keep behavior explicit and predictable.
225234
case "${GITHUB_EVENT_NAME}" in
226235
workflow_dispatch)
@@ -234,8 +243,14 @@ case "${GITHUB_EVENT_NAME}" in
234243
else
235244
build_mode="${requested_build_mode}"
236245
fi
246+
if [ "${build_mode}" = "custom" ] && [ "${workflow_source_git_ref_provided}" != "true" ]; then
247+
echo "::error::workflow_dispatch custom mode requires source_git_ref." >&2
248+
exit 1
249+
fi
237250
if [ "${build_mode}" = "tag-poll" ]; then
238251
echo "::notice::workflow_dispatch tag-poll selected. Prefer schedule runs for routine tag polling."
252+
elif [ "${build_mode}" = "custom" ]; then
253+
echo "::notice::workflow_dispatch custom mode selected. Build will use the explicit source ref override."
239254
fi
240255
;;
241256
schedule)
@@ -378,6 +393,15 @@ if [ "${should_build}" = "true" ]; then
378393
git -C "${repo_dir}" remote add origin "${source_git_url}"
379394
git -C "${repo_dir}" fetch --depth 1 origin "${source_git_ref}"
380395
git -C "${repo_dir}" checkout --detach FETCH_HEAD
396+
if [ "${build_mode}" = "custom" ]; then
397+
resolved_source_sha="$(git -C "${repo_dir}" rev-parse HEAD)"
398+
if [ -z "${resolved_source_sha}" ]; then
399+
echo "Unable to resolve a pinned commit SHA for custom source ref '${source_git_ref}'." >&2
400+
exit 1
401+
fi
402+
echo "Custom source ref ${source_git_ref} resolved to ${resolved_source_sha}."
403+
source_git_ref="${resolved_source_sha}"
404+
fi
381405
version="$(python3 scripts/ci/read-project-version.py "${repo_dir}/pyproject.toml")"
382406
fi
383407
else
@@ -396,6 +420,16 @@ if [ "${build_mode}" = "nightly" ] && [ "${should_build}" = "true" ]; then
396420
release_tag="nightly"
397421
release_name="AstrBot Desktop v${base_version}-nightly-${short_sha}"
398422
release_prerelease="true"
423+
elif [ "${build_mode}" = "custom" ] && [ "${should_build}" = "true" ]; then
424+
base_version="${version}"
425+
custom_date="$(date -u +%Y%m%d)"
426+
short_sha="$(printf '%s' "${source_git_ref}" | cut -c1-8)"
427+
version="${version}-custom.${custom_date}.${short_sha}"
428+
if [ "${publish_release}" = "true" ]; then
429+
release_tag="custom-${custom_date}-${short_sha}"
430+
release_name="AstrBot Desktop v${base_version}-custom-${short_sha}"
431+
release_prerelease="false"
432+
fi
399433
elif [ "${publish_release}" = "true" ] && [ "${should_build}" = "true" ]; then
400434
release_tag="v${version}"
401435
release_name="AstrBot Desktop v${version}"

scripts/ci/resolve-build-context.test.mjs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ const makeNightlyEnv = (overrides = {}) => ({
4848
...overrides,
4949
});
5050

51+
const makeCustomEnv = (overrides = {}) => ({
52+
...baseEnv,
53+
WORKFLOW_BUILD_MODE: 'custom',
54+
WORKFLOW_PUBLISH_RELEASE: 'true',
55+
ASTRBOT_TEST_FETCHED_VERSION: '4.19.0',
56+
ASTRBOT_TEST_FETCHED_SHA: '4444444444444444444444444444444444444444',
57+
...overrides,
58+
});
59+
5160
const parseGithubOutput = async (outputPath) => {
5261
const raw = await readFile(outputPath, 'utf8');
5362
const entries = raw
@@ -197,3 +206,71 @@ test('workflow_dispatch nightly never marks latest', async () => {
197206
assert.equal(outputs.release_prerelease, 'true');
198207
assert.equal(outputs.release_make_latest, 'false');
199208
});
209+
210+
test('workflow_dispatch custom resolves explicit source ref to a pinned commit SHA', async () => {
211+
const { result, outputs } = await runResolveBuildContext(makeCustomEnv({
212+
WORKFLOW_SOURCE_GIT_REF: 'fix/windows-packaged-pip-build-env',
213+
}));
214+
215+
assert.equal(result.status, 0, result.stderr);
216+
assert.equal(outputs.build_mode, 'custom');
217+
assert.equal(outputs.source_git_ref, '4444444444444444444444444444444444444444');
218+
assert.match(
219+
outputs.astrbot_version,
220+
/^4\.19\.0-custom\.\d{8}\.44444444$/,
221+
);
222+
assert.equal(outputs.release_prerelease, 'false');
223+
assert.equal(outputs.release_make_latest, 'false');
224+
assert.match(outputs.release_tag, /^custom-\d{8}-44444444$/);
225+
});
226+
227+
test('workflow_dispatch custom requires an explicit source ref', async () => {
228+
const { result } = await runResolveBuildContext(makeCustomEnv({
229+
WORKFLOW_SOURCE_GIT_REF: '',
230+
}));
231+
232+
assert.notEqual(result.status, 0);
233+
assert.match(result.stderr, /workflow_dispatch custom mode requires source_git_ref/i);
234+
});
235+
236+
test('schedule runs reject build_mode=custom', async () => {
237+
const { result } = await runResolveBuildContext({
238+
...baseEnv,
239+
GITHUB_EVENT_NAME: 'schedule',
240+
WORKFLOW_BUILD_MODE: 'custom',
241+
});
242+
243+
assert.notEqual(result.status, 0);
244+
assert.match(result.stderr, /schedule runs do not support build_mode=custom/i);
245+
});
246+
247+
test('non-workflow_dispatch runs reject build_mode=custom', async () => {
248+
const { result } = await runResolveBuildContext({
249+
...baseEnv,
250+
GITHUB_EVENT_NAME: 'push',
251+
WORKFLOW_BUILD_MODE: 'custom',
252+
});
253+
254+
assert.notEqual(result.status, 0);
255+
assert.match(result.stderr, /push runs do not support build_mode=custom/i);
256+
});
257+
258+
test('fake git rev-parse returns the configured SHA for arbitrary refs', async () => {
259+
await withSandbox(
260+
{
261+
...baseEnv,
262+
ASTRBOT_TEST_FETCHED_SHA: '5555555555555555555555555555555555555555',
263+
},
264+
async (sandbox) => {
265+
const gitPath = path.join(sandbox.tempDir, 'bin', 'git');
266+
const repoDir = path.join(sandbox.tempDir, 'repo');
267+
const result = spawnSync(gitPath, ['-C', repoDir, 'rev-parse', 'FETCH_HEAD'], {
268+
encoding: 'utf8',
269+
env: sandbox.env,
270+
});
271+
272+
assert.equal(result.status, 0, result.stderr);
273+
assert.equal(result.stdout.trim(), '5555555555555555555555555555555555555555');
274+
},
275+
);
276+
});

scripts/prepare-resources/version-sync.mjs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { existsSync } from 'node:fs';
22
import { readFile, writeFile } from 'node:fs/promises';
33
import path from 'node:path';
44

5+
export const DESKTOP_TAURI_CRATE_NAME = 'astrbot-desktop-tauri';
6+
57
export const normalizeDesktopVersionOverride = (version) => {
68
const trimmed = typeof version === 'string' ? version.trim() : '';
79
if (!trimmed) {
@@ -47,10 +49,112 @@ export const readAstrbotVersionFromPyproject = async ({ sourceDir }) => {
4749
throw new Error(`Cannot resolve [project].version from ${pyprojectPath}`);
4850
};
4951

52+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53+
const CARGO_LOCK_PACKAGE_HEADER = /^\s*\[\[package\]\]\s*(?:#.*)?$/;
54+
const CARGO_LOCK_ANY_HEADER = /^\s*\[\[/;
55+
const CARGO_LOCK_VERSION_LINE = /^\s*version\s*=/;
56+
const escapeTomlBasicString = (value) => String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
57+
58+
const updateVersionLine = (line, version) => {
59+
const commentIndex = line.indexOf('#');
60+
const beforeComment = commentIndex === -1 ? line : line.slice(0, commentIndex);
61+
const comment = commentIndex === -1 ? '' : line.slice(commentIndex);
62+
const separatorIndex = beforeComment.indexOf('=');
63+
64+
if (separatorIndex === -1) {
65+
return null;
66+
}
67+
68+
const left = beforeComment.slice(0, separatorIndex).trimEnd();
69+
const right = beforeComment.slice(separatorIndex + 1);
70+
if (!right.trim()) {
71+
return null;
72+
}
73+
74+
const trailingWhitespace = beforeComment.match(/\s*$/u)?.[0] ?? '';
75+
const updatedLine = `${left} = "${escapeTomlBasicString(version)}"`;
76+
77+
if (!comment) {
78+
return `${updatedLine}${trailingWhitespace}`;
79+
}
80+
81+
return `${updatedLine}${trailingWhitespace}${comment}`;
82+
};
83+
84+
const updateCargoLockPackageVersion = ({ cargoLock, packageName, version }) => {
85+
const lines = cargoLock.split(/\r?\n/);
86+
const newline = cargoLock.includes('\r\n') ? '\r\n' : '\n';
87+
const packageNameLinePattern = new RegExp(
88+
`^\\s*name\\s*=\\s*"${escapeRegExp(packageName)}"\\s*(?:#.*)?$`,
89+
);
90+
91+
let inPackageBlock = false;
92+
let inTargetPackage = false;
93+
let foundTargetPackage = false;
94+
95+
for (let index = 0; index < lines.length; index += 1) {
96+
const line = lines[index];
97+
98+
if (CARGO_LOCK_PACKAGE_HEADER.test(line)) {
99+
if (inTargetPackage) {
100+
throw new Error(
101+
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
102+
);
103+
}
104+
inPackageBlock = true;
105+
inTargetPackage = false;
106+
continue;
107+
}
108+
109+
if (inPackageBlock && CARGO_LOCK_ANY_HEADER.test(line)) {
110+
if (inTargetPackage) {
111+
throw new Error(
112+
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
113+
);
114+
}
115+
inPackageBlock = false;
116+
inTargetPackage = false;
117+
}
118+
119+
if (!inPackageBlock) {
120+
continue;
121+
}
122+
123+
if (!inTargetPackage && packageNameLinePattern.test(line)) {
124+
inTargetPackage = true;
125+
foundTargetPackage = true;
126+
continue;
127+
}
128+
129+
if (!inTargetPackage || !CARGO_LOCK_VERSION_LINE.test(line)) {
130+
continue;
131+
}
132+
133+
const updatedLine = updateVersionLine(line, version);
134+
if (updatedLine === null) {
135+
throw new Error(
136+
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
137+
);
138+
}
139+
140+
lines[index] = updatedLine;
141+
return { content: lines.join(newline), updated: true, foundTargetPackage: true };
142+
}
143+
144+
if (inTargetPackage) {
145+
throw new Error(
146+
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
147+
);
148+
}
149+
150+
return { content: cargoLock, updated: false, foundTargetPackage };
151+
};
152+
50153
export const syncDesktopVersionFiles = async ({ projectRoot, version }) => {
51154
const packageJsonPath = path.join(projectRoot, 'package.json');
52155
const tauriConfigPath = path.join(projectRoot, 'src-tauri', 'tauri.conf.json');
53156
const cargoTomlPath = path.join(projectRoot, 'src-tauri', 'Cargo.toml');
157+
const cargoLockPath = path.join(projectRoot, 'src-tauri', 'Cargo.lock');
54158

55159
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
56160
if (packageJson.version !== version) {
@@ -73,4 +177,21 @@ export const syncDesktopVersionFiles = async ({ projectRoot, version }) => {
73177
if (updatedCargoToml !== cargoToml) {
74178
await writeFile(cargoTomlPath, updatedCargoToml, 'utf8');
75179
}
180+
181+
if (existsSync(cargoLockPath)) {
182+
const cargoLock = await readFile(cargoLockPath, 'utf8');
183+
const { content: updatedCargoLock, updated, foundTargetPackage } = updateCargoLockPackageVersion({
184+
cargoLock,
185+
packageName: DESKTOP_TAURI_CRATE_NAME,
186+
version,
187+
});
188+
189+
if (!foundTargetPackage) {
190+
console.warn(
191+
`${cargoLockPath}: package "${DESKTOP_TAURI_CRATE_NAME}" not found. Skipping Cargo.lock version sync.`,
192+
);
193+
} else if (updated && updatedCargoLock !== cargoLock) {
194+
await writeFile(cargoLockPath, updatedCargoLock, 'utf8');
195+
}
196+
}
76197
};

0 commit comments

Comments
 (0)