Skip to content
Closed
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
23 changes: 17 additions & 6 deletions .github/workflows/desktop-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,12 @@ jobs:
"${CHANNEL}" "${VERSION}"

git fetch origin main --tags --force
CHECKOUT_SHA="$(git rev-parse "${CHECKOUT_REF}^{commit}")"
CHECKOUT_SHA="$(node scripts/resolve-release-source.mjs "${CHECKOUT_REF}" "${TAG}")"
if [[ "${GITHUB_REPOSITORY}" == "GCWing/OpenBitFun" ]] && \
! git merge-base --is-ancestor "${CHECKOUT_SHA}" origin/main; then
echo "Ref ${CHECKOUT_REF} (${CHECKOUT_SHA}) is not part of the protected main history." >&2
exit 1
fi
TAG_SHA="$(git rev-parse --verify --quiet "${TAG}^{commit}" || true)"
if [[ -n "${TAG_SHA}" && "${TAG_SHA}" != "${CHECKOUT_SHA}" ]]; then
echo "Existing tag ${TAG} points to ${TAG_SHA}, not requested commit ${CHECKOUT_SHA}." >&2
exit 1
fi
CHECKOUT_REF="${CHECKOUT_SHA}"

echo "version=$VERSION" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -322,6 +317,7 @@ jobs:
run: bash scripts/ci/verify-appimage-fcitx.sh "${{ matrix.platform.target }}"

- name: Upload bundles
id: bundles
uses: actions/upload-artifact@v6
with:
name: openbitfun-${{ needs.prepare.outputs.release_tag }}-${{ matrix.platform.name }}-bundle
Expand All @@ -332,6 +328,21 @@ jobs:
src/apps/desktop/target/release/bundle
OpenBitFun-Installer/src-tauri/target/release/openbitfun-installer.exe

- name: Record bundle download
shell: bash
env:
BUNDLE_URL: ${{ steps.bundles.outputs.artifact-url }}
BUNDLE_PLATFORM: ${{ matrix.platform.name }}
BUNDLE_TAG: ${{ needs.prepare.outputs.release_tag }}
BUNDLE_COMMIT: ${{ needs.prepare.outputs.checkout_ref }}
run: |
{
printf '### %s package\n\n' "$BUNDLE_PLATFORM"
printf '[Download %s bundle](%s)\n\n' "$BUNDLE_TAG" "$BUNDLE_URL"
printf 'Source commit: `%s`\n\n' "$BUNDLE_COMMIT"
printf 'This artifact is available independently of other platform jobs. Release publication requires all platforms to succeed.\n'
} >> "$GITHUB_STEP_SUMMARY"

linux-binaries:
name: Linux CLI and Relay Server
needs: prepare
Expand Down
22 changes: 22 additions & 0 deletions docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ than the current beta. This lets beta users move from `0.2.18-beta.N` to
Beta and stable currently share the same bundle identity and data directories.
Installing beta replaces stable; side-by-side installation is not supported.

## Recovering a failed package run

Each successful platform uploads its own bundle to the run's **Artifacts** list
and records the download link and source SHA in its job summary. Another
platform failing prevents Release publication, but does not remove those
already uploaded bundles.

Re-run failed jobs only while the successful jobs' artifacts still exist. If
artifacts were removed, start a full `Desktop Package` run to recreate the
complete set. If an old run cannot find a reusable workflow after a history
rewrite, dispatch a new run from the current workflow branch instead of
re-running the old workflow snapshot.

For an existing release, use its tag as both `tag_name` and `checkout_ref`. For
different source code, select a new release tag; do not move an existing tag to
make a retry pass. The prepare job fetches a requested ref when it is absent
locally, pins the resolved commit, and rejects a tag/source mismatch before
starting platform builds. Unavailable refs fail with recovery instructions.

Verify source selection with `node --test scripts/release-channel.test.mjs` and
workflow wiring with `pnpm run check:github-config`.

## Mirror

The mirror script defaults to stable. Run a separate beta sync with:
Expand Down
6 changes: 5 additions & 1 deletion scripts/check-github-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,11 @@ test('Desktop packaging keeps beta identity explicit and stable-safe', () => {
);
assert.match(prepareStep.run, /GITHUB_REPOSITORY.*GCWing\/OpenBitFun/);
assert.match(prepareStep.run, /merge-base --is-ancestor/);
assert.match(prepareStep.run, /rev-parse --verify --quiet/);
assert.match(
prepareStep.run,
/CHECKOUT_SHA="\$\(node scripts\/resolve-release-source\.mjs "\$\{CHECKOUT_REF\}" "\$\{TAG\}"\)"/,
'source resolution must enforce the release tag before package jobs start',
);

const packageJob = workflow.jobs.package;
assert.equal(
Expand Down
71 changes: 70 additions & 1 deletion scripts/release-channel.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
Expand All @@ -11,6 +11,7 @@ import {
} from './release-channel.mjs';
import { setBuildVersion } from './set-build-version.mjs';
import { decodeMinisignPublicKey } from './write-minisign-public-key.mjs';
import { resolveReleaseSource } from './resolve-release-source.mjs';

const RAW_PUBLIC_KEY = `untrusted comment: minisign public key E3E0874CEC1C22C3
RWTDIhzsTIfg41w2Gwiei0zNDKaLYm9dQVpEWNQ/Ulpyt2mbS2JE1U2M`;
Expand Down Expand Up @@ -168,3 +169,71 @@ function writeFixture(root, relative, content) {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, content);
}

function releaseRepositoryFixture(t) {
const temporaryRoot = tmpdir();
const root = mkdtempSync(path.join(temporaryRoot, 'openbitfun-release-source-'));
t.after(() => {
assert.match(path.relative(temporaryRoot, root), /^openbitfun-release-source-[^\\/]+$/);
rmSync(root, { recursive: true, force: true });
});
const origin = path.join(root, 'origin');
const checkout = path.join(root, 'checkout');
mkdirSync(origin);
const git = (cwd, ...args) => {
const result = spawnSync('git', ['-c', 'commit.gpgsign=false', ...args], {
cwd, encoding: 'utf8', windowsHide: true,
});
assert.equal(result.status, 0, result.stderr);
return result.stdout.trim();
};
git(origin, 'init', '--quiet', '--initial-branch=main');
git(origin, 'config', 'user.name', 'Release Source Test');
git(origin, 'config', 'user.email', 'release-source@example.invalid');
writeFileSync(path.join(origin, 'source.txt'), 'initial\n');
git(origin, 'add', 'source.txt');
git(origin, 'commit', '--quiet', '-m', 'Initial source');
const base = git(origin, 'rev-parse', 'HEAD');
git(origin, 'tag', '-a', 'v1.0.0-beta.1', '-m', 'Existing release');
git(root, 'clone', '--quiet', '--no-local', '--single-branch', origin, checkout);
writeFileSync(path.join(origin, 'source.txt'), 'release candidate\n');
git(origin, 'commit', '--quiet', '-am', 'Candidate source');
const candidate = git(origin, 'rev-parse', 'HEAD');
// Leave this commit outside advertised branch/tag history, as after a rewrite.
git(origin, 'update-ref', 'refs/heads/main', base);
return { origin, checkout, base, candidate, git };
}

test('release source fetches an available SHA outside advertised history', (t) => {
const { checkout, candidate } = releaseRepositoryFixture(t);
assert.equal(resolveReleaseSource({
cwd: checkout, checkoutRef: candidate, releaseTag: 'v1.0.0-beta.2',
}), candidate);
});

test('release source fetches a branch absent from the Actions checkout', (t) => {
const { origin, checkout, candidate, git } = releaseRepositoryFixture(t);
git(origin, 'update-ref', 'refs/heads/release-candidate', candidate);
assert.equal(resolveReleaseSource({
cwd: checkout, checkoutRef: 'release-candidate', releaseTag: 'v1.0.0-beta.2',
}), candidate);
});

test('release source preserves the existing tag and rejects different source commits', (t) => {
const { checkout, base, candidate, git } = releaseRepositoryFixture(t);
assert.equal(resolveReleaseSource({
cwd: checkout, checkoutRef: 'v1.0.0-beta.1', releaseTag: 'v1.0.0-beta.1',
}), base);
assert.throws(() => resolveReleaseSource({
cwd: checkout, checkoutRef: candidate, releaseTag: 'v1.0.0-beta.1',
}), /Existing tag .* choose a new release tag/);
assert.equal(git(checkout, 'rev-parse', 'v1.0.0-beta.1^{commit}'), base);
});

test('release source reports an unavailable ref instead of falling back to HEAD', (t) => {
const { checkout, base, git } = releaseRepositoryFixture(t);
assert.throws(() => resolveReleaseSource({
cwd: checkout, checkoutRef: 'missing-release-ref', releaseTag: 'v1.0.0-beta.2',
}), /Cannot fetch checkout ref missing-release-ref .* Start a new Desktop Package run/);
assert.equal(git(checkout, 'rev-parse', 'HEAD'), base);
});
57 changes: 57 additions & 0 deletions scripts/resolve-release-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';

export function resolveReleaseSource({ checkoutRef, releaseTag, cwd = process.cwd() }) {
if (!checkoutRef || checkoutRef.startsWith('-') || !releaseTag) {
throw new Error('A checkout ref and release tag are required.');
}

const git = (...args) => spawnSync('git', args, {
cwd,
encoding: 'utf8',
windowsHide: true,
});
const resolveCommit = (ref) => {
const result = git('rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`);
return result.status === 0 ? result.stdout.trim() : null;
};

let checkoutSha = resolveCommit(checkoutRef);
if (!checkoutSha) {
// Full branch history does not include a SHA left behind by a history
// rewrite, or an arbitrary branch name in a detached Actions checkout.
const fetched = git('fetch', '--no-tags', '--', 'origin', checkoutRef);
if (fetched.status !== 0) {
throw new Error(
`Cannot fetch checkout ref ${checkoutRef} from origin. Start a new Desktop Package run `
+ 'from a current workflow branch and choose an available commit, branch, or tag.\n'
+ (fetched.stderr || fetched.error?.message || ''),
);
}
checkoutSha = resolveCommit('FETCH_HEAD');
if (!checkoutSha) {
throw new Error(`Checkout ref ${checkoutRef} did not resolve to a commit.`);
}
}

const tagSha = resolveCommit(`refs/tags/${releaseTag}`);
if (tagSha && tagSha !== checkoutSha) {
throw new Error(
`Existing tag ${releaseTag} points to ${tagSha}, not requested commit ${checkoutSha}. `
+ 'Build the existing tag, or choose a new release tag for the requested commit.',
);
}
return checkoutSha;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
console.log(resolveReleaseSource({
checkoutRef: process.argv[2],
releaseTag: process.argv[3],
}));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
Loading