From a3128832224e6db6956af519d20f7a4a322f6144 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sun, 6 Sep 2026 00:07:19 +0800 Subject: [PATCH] fix(ci): recover desktop packaging refs and expose artifact links --- .github/workflows/desktop-package.yml | 23 ++++++--- docs/development/releasing.md | 22 +++++++++ scripts/check-github-config.test.mjs | 6 ++- scripts/release-channel.test.mjs | 71 ++++++++++++++++++++++++++- scripts/resolve-release-source.mjs | 57 +++++++++++++++++++++ 5 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 scripts/resolve-release-source.mjs diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 034fbeec6b..cc1e19e235 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -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" @@ -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 @@ -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 diff --git a/docs/development/releasing.md b/docs/development/releasing.md index e21c6d44b4..e9e1ac0b2b 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -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: diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index a2a186c506..79fe3cc64b 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -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( diff --git a/scripts/release-channel.test.mjs b/scripts/release-channel.test.mjs index 85d9c549ba..37636bfc21 100644 --- a/scripts/release-channel.test.mjs +++ b/scripts/release-channel.test.mjs @@ -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'; @@ -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`; @@ -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); +}); diff --git a/scripts/resolve-release-source.mjs b/scripts/resolve-release-source.mjs new file mode 100644 index 0000000000..f00d974c95 --- /dev/null +++ b/scripts/resolve-release-source.mjs @@ -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; + } +}