Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .github/workflows/build-desktop-tauri.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
required: false
default: https://github.com/AstrBotDevs/AstrBot.git
source_git_ref:
description: Optional source ref override for `tag-poll` (branch/tag/commit SHA). Ignored in `nightly`.
description: Optional source ref override for `tag-poll` or required explicit source ref for `custom` (branch/tag/commit SHA).
required: false
default: ""
publish_release:
Expand All @@ -18,14 +18,15 @@ on:
default: true
build_mode:
description: >-
Build mode (`tag-poll` | `nightly`): `nightly` (default) always builds latest upstream commit,
`tag-poll` builds latest upstream tag (or `source_git_ref` override)
Build mode (`tag-poll` | `nightly` | `custom`): `nightly` (default) always builds latest upstream commit,
`tag-poll` builds latest upstream tag (or `source_git_ref` override), `custom` builds the explicit `source_git_ref`
required: false
type: choice
default: nightly
options:
- tag-poll
- nightly
- custom
schedule:
# Hourly tag-poll (exclude the dedicated nightly window at 03:xx UTC).
- cron: '0 0-2,4-23 * * *'
Expand Down Expand Up @@ -130,15 +131,15 @@ jobs:
run: |
set -euo pipefail

changed_files="$(git status --porcelain -- package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json)"
changed_files="$(git status --porcelain -- package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json)"
if [ -z "${changed_files}" ]; then
echo "Version files are already up to date. Nothing to commit."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
git add package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
git commit -m "chore(version): sync desktop version to v${ASTRBOT_VERSION}"

git fetch origin "${TARGET_REF_NAME}"
Expand Down Expand Up @@ -551,6 +552,7 @@ jobs:
python3 scripts/ci/validate-release-artifacts.py release-artifacts

- name: Generate Tauri updater manifest
if: ${{ needs.resolve_build_context.outputs.build_mode != 'custom' }}
env:
RELEASE_TAG: ${{ needs.resolve_build_context.outputs.release_tag }}
RELEASE_VERSION: ${{ needs.resolve_build_context.outputs.astrbot_version }}
Expand Down Expand Up @@ -600,7 +602,7 @@ jobs:
fail_on_unmatched_files: true

- name: Demote previous prerelease marker
if: ${{ needs.resolve_build_context.outputs.release_prerelease == 'true' }}
if: ${{ needs.resolve_build_context.outputs.release_prerelease == 'true' && needs.resolve_build_context.outputs.build_mode == 'nightly' }}
env:
GH_TOKEN: ${{ github.token }}
CURRENT_RELEASE_TAG: ${{ needs.resolve_build_context.outputs.release_tag }}
Expand Down
7 changes: 7 additions & 0 deletions scripts/ci/fixtures/fake-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ case "${command_name}" in
version = "${ASTRBOT_TEST_FETCHED_VERSION:-4.19.0}"
EOF
;;
rev-parse)
if [ -z "${1-}" ]; then
printf 'git rev-parse expected a ref argument\n' >&2
exit 1
fi
printf '%s\n' "${ASTRBOT_TEST_FETCHED_SHA:-3333333333333333333333333333333333333333}"
;;
*)
printf 'unexpected git command: %s %s\n' "${command_name}" "$*" >&2
exit 1
Expand Down
38 changes: 36 additions & 2 deletions scripts/ci/resolve-build-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ resolve_latest_upstream_tag() {
printf '%s\n' "${latest_tag}"
}

custom_build_mode_supported_for_event() {
[ "$1" = "workflow_dispatch" ]
}

source_git_url="${ASTRBOT_SOURCE_GIT_URL}"
source_git_ref="${ASTRBOT_SOURCE_GIT_REF}"
nightly_source_git_ref="${ASTRBOT_NIGHTLY_SOURCE_GIT_REF:-master}"
Expand Down Expand Up @@ -179,10 +183,10 @@ workflow_source_git_ref_provided="false"
latest_upstream_tag=""

case "${requested_build_mode}" in
auto|tag-poll|nightly) ;;
auto|tag-poll|nightly|custom) ;;
*)
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
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)."
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)."
else
echo "::error::invalid build_mode input '${requested_build_mode}'; expected auto/tag-poll/nightly."
fi
Expand Down Expand Up @@ -221,6 +225,11 @@ if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
fi
fi

if [ "${requested_build_mode}" = "custom" ] && ! custom_build_mode_supported_for_event "${GITHUB_EVENT_NAME}"; then
echo "::error::${GITHUB_EVENT_NAME} runs do not support build_mode=custom." >&2
exit 1
fi

# Normalize build mode in one place to keep behavior explicit and predictable.
case "${GITHUB_EVENT_NAME}" in
workflow_dispatch)
Expand All @@ -234,8 +243,14 @@ case "${GITHUB_EVENT_NAME}" in
else
build_mode="${requested_build_mode}"
fi
if [ "${build_mode}" = "custom" ] && [ "${workflow_source_git_ref_provided}" != "true" ]; then
echo "::error::workflow_dispatch custom mode requires source_git_ref." >&2
exit 1
fi
if [ "${build_mode}" = "tag-poll" ]; then
echo "::notice::workflow_dispatch tag-poll selected. Prefer schedule runs for routine tag polling."
elif [ "${build_mode}" = "custom" ]; then
echo "::notice::workflow_dispatch custom mode selected. Build will use the explicit source ref override."
fi
;;
schedule)
Expand Down Expand Up @@ -378,6 +393,15 @@ if [ "${should_build}" = "true" ]; then
git -C "${repo_dir}" remote add origin "${source_git_url}"
git -C "${repo_dir}" fetch --depth 1 origin "${source_git_ref}"
git -C "${repo_dir}" checkout --detach FETCH_HEAD
if [ "${build_mode}" = "custom" ]; then
resolved_source_sha="$(git -C "${repo_dir}" rev-parse HEAD)"
if [ -z "${resolved_source_sha}" ]; then
echo "Unable to resolve a pinned commit SHA for custom source ref '${source_git_ref}'." >&2
exit 1
fi
echo "Custom source ref ${source_git_ref} resolved to ${resolved_source_sha}."
source_git_ref="${resolved_source_sha}"
fi
version="$(python3 scripts/ci/read-project-version.py "${repo_dir}/pyproject.toml")"
fi
else
Expand All @@ -396,6 +420,16 @@ if [ "${build_mode}" = "nightly" ] && [ "${should_build}" = "true" ]; then
release_tag="nightly"
release_name="AstrBot Desktop v${base_version}-nightly-${short_sha}"
release_prerelease="true"
elif [ "${build_mode}" = "custom" ] && [ "${should_build}" = "true" ]; then
base_version="${version}"
custom_date="$(date -u +%Y%m%d)"
short_sha="$(printf '%s' "${source_git_ref}" | cut -c1-8)"
version="${version}-custom.${custom_date}.${short_sha}"
if [ "${publish_release}" = "true" ]; then
release_tag="custom-${custom_date}-${short_sha}"
release_name="AstrBot Desktop v${base_version}-custom-${short_sha}"
release_prerelease="false"
fi
elif [ "${publish_release}" = "true" ] && [ "${should_build}" = "true" ]; then
release_tag="v${version}"
release_name="AstrBot Desktop v${version}"
Expand Down
77 changes: 77 additions & 0 deletions scripts/ci/resolve-build-context.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ const makeNightlyEnv = (overrides = {}) => ({
...overrides,
});

const makeCustomEnv = (overrides = {}) => ({
...baseEnv,
WORKFLOW_BUILD_MODE: 'custom',
WORKFLOW_PUBLISH_RELEASE: 'true',
ASTRBOT_TEST_FETCHED_VERSION: '4.19.0',
ASTRBOT_TEST_FETCHED_SHA: '4444444444444444444444444444444444444444',
...overrides,
});

const parseGithubOutput = async (outputPath) => {
const raw = await readFile(outputPath, 'utf8');
const entries = raw
Expand Down Expand Up @@ -197,3 +206,71 @@ test('workflow_dispatch nightly never marks latest', async () => {
assert.equal(outputs.release_prerelease, 'true');
assert.equal(outputs.release_make_latest, 'false');
});

test('workflow_dispatch custom resolves explicit source ref to a pinned commit SHA', async () => {
const { result, outputs } = await runResolveBuildContext(makeCustomEnv({
WORKFLOW_SOURCE_GIT_REF: 'fix/windows-packaged-pip-build-env',
}));

assert.equal(result.status, 0, result.stderr);
assert.equal(outputs.build_mode, 'custom');
assert.equal(outputs.source_git_ref, '4444444444444444444444444444444444444444');
assert.match(
outputs.astrbot_version,
/^4\.19\.0-custom\.\d{8}\.44444444$/,
);
assert.equal(outputs.release_prerelease, 'false');
assert.equal(outputs.release_make_latest, 'false');
assert.match(outputs.release_tag, /^custom-\d{8}-44444444$/);
});

test('workflow_dispatch custom requires an explicit source ref', async () => {
const { result } = await runResolveBuildContext(makeCustomEnv({
WORKFLOW_SOURCE_GIT_REF: '',
}));

assert.notEqual(result.status, 0);
assert.match(result.stderr, /workflow_dispatch custom mode requires source_git_ref/i);
});

test('schedule runs reject build_mode=custom', async () => {
const { result } = await runResolveBuildContext({
...baseEnv,
GITHUB_EVENT_NAME: 'schedule',
WORKFLOW_BUILD_MODE: 'custom',
});

assert.notEqual(result.status, 0);
assert.match(result.stderr, /schedule runs do not support build_mode=custom/i);
});

test('non-workflow_dispatch runs reject build_mode=custom', async () => {
const { result } = await runResolveBuildContext({
...baseEnv,
GITHUB_EVENT_NAME: 'push',
WORKFLOW_BUILD_MODE: 'custom',
});

assert.notEqual(result.status, 0);
assert.match(result.stderr, /push runs do not support build_mode=custom/i);
});

test('fake git rev-parse returns the configured SHA for arbitrary refs', async () => {
await withSandbox(
{
...baseEnv,
ASTRBOT_TEST_FETCHED_SHA: '5555555555555555555555555555555555555555',
},
async (sandbox) => {
const gitPath = path.join(sandbox.tempDir, 'bin', 'git');
const repoDir = path.join(sandbox.tempDir, 'repo');
const result = spawnSync(gitPath, ['-C', repoDir, 'rev-parse', 'FETCH_HEAD'], {
encoding: 'utf8',
env: sandbox.env,
});

assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), '5555555555555555555555555555555555555555');
},
);
});
121 changes: 121 additions & 0 deletions scripts/prepare-resources/version-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';

export const DESKTOP_TAURI_CRATE_NAME = 'astrbot-desktop-tauri';

export const normalizeDesktopVersionOverride = (version) => {
const trimmed = typeof version === 'string' ? version.trim() : '';
if (!trimmed) {
Expand Down Expand Up @@ -47,10 +49,112 @@ export const readAstrbotVersionFromPyproject = async ({ sourceDir }) => {
throw new Error(`Cannot resolve [project].version from ${pyprojectPath}`);
};

const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const CARGO_LOCK_PACKAGE_HEADER = /^\s*\[\[package\]\]\s*(?:#.*)?$/;
const CARGO_LOCK_ANY_HEADER = /^\s*\[\[/;
const CARGO_LOCK_VERSION_LINE = /^\s*version\s*=/;
const escapeTomlBasicString = (value) => String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');

const updateVersionLine = (line, version) => {
const commentIndex = line.indexOf('#');
const beforeComment = commentIndex === -1 ? line : line.slice(0, commentIndex);
const comment = commentIndex === -1 ? '' : line.slice(commentIndex);
const separatorIndex = beforeComment.indexOf('=');

if (separatorIndex === -1) {
return null;
}

const left = beforeComment.slice(0, separatorIndex).trimEnd();
const right = beforeComment.slice(separatorIndex + 1);
if (!right.trim()) {
return null;
}

const trailingWhitespace = beforeComment.match(/\s*$/u)?.[0] ?? '';
const updatedLine = `${left} = "${escapeTomlBasicString(version)}"`;

if (!comment) {
return `${updatedLine}${trailingWhitespace}`;
}

return `${updatedLine}${trailingWhitespace}${comment}`;
};

const updateCargoLockPackageVersion = ({ cargoLock, packageName, version }) => {
const lines = cargoLock.split(/\r?\n/);
const newline = cargoLock.includes('\r\n') ? '\r\n' : '\n';
const packageNameLinePattern = new RegExp(
`^\\s*name\\s*=\\s*"${escapeRegExp(packageName)}"\\s*(?:#.*)?$`,
);

let inPackageBlock = false;
let inTargetPackage = false;
let foundTargetPackage = false;

for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];

if (CARGO_LOCK_PACKAGE_HEADER.test(line)) {
if (inTargetPackage) {
throw new Error(
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
);
}
inPackageBlock = true;
inTargetPackage = false;
continue;
}

if (inPackageBlock && CARGO_LOCK_ANY_HEADER.test(line)) {
if (inTargetPackage) {
throw new Error(
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
);
}
inPackageBlock = false;
inTargetPackage = false;
}

if (!inPackageBlock) {
continue;
}

if (!inTargetPackage && packageNameLinePattern.test(line)) {
inTargetPackage = true;
foundTargetPackage = true;
continue;
}

if (!inTargetPackage || !CARGO_LOCK_VERSION_LINE.test(line)) {
continue;
}

const updatedLine = updateVersionLine(line, version);
if (updatedLine === null) {
throw new Error(
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
);
}

lines[index] = updatedLine;
return { content: lines.join(newline), updated: true, foundTargetPackage: true };
}

if (inTargetPackage) {
throw new Error(
`Cannot update Cargo.lock: version entry for package "${packageName}" not found or has an unexpected layout`,
);
}

return { content: cargoLock, updated: false, foundTargetPackage };
};

export const syncDesktopVersionFiles = async ({ projectRoot, version }) => {
const packageJsonPath = path.join(projectRoot, 'package.json');
const tauriConfigPath = path.join(projectRoot, 'src-tauri', 'tauri.conf.json');
const cargoTomlPath = path.join(projectRoot, 'src-tauri', 'Cargo.toml');
const cargoLockPath = path.join(projectRoot, 'src-tauri', 'Cargo.lock');

const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
if (packageJson.version !== version) {
Expand All @@ -73,4 +177,21 @@ export const syncDesktopVersionFiles = async ({ projectRoot, version }) => {
if (updatedCargoToml !== cargoToml) {
await writeFile(cargoTomlPath, updatedCargoToml, 'utf8');
}

if (existsSync(cargoLockPath)) {
const cargoLock = await readFile(cargoLockPath, 'utf8');
const { content: updatedCargoLock, updated, foundTargetPackage } = updateCargoLockPackageVersion({
cargoLock,
packageName: DESKTOP_TAURI_CRATE_NAME,
version,
});

if (!foundTargetPackage) {
console.warn(
`${cargoLockPath}: package "${DESKTOP_TAURI_CRATE_NAME}" not found. Skipping Cargo.lock version sync.`,
);
} else if (updated && updatedCargoLock !== cargoLock) {
await writeFile(cargoLockPath, updatedCargoLock, 'utf8');
}
}
};
Loading