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
92 changes: 34 additions & 58 deletions .github/workflows/desktop-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,7 @@ jobs:
name: Linux CLI and Relay Server
needs: prepare
if: >-
needs.prepare.outputs.relay_image_only != 'true' &&
needs.prepare.outputs.release_channel == 'stable'
needs.prepare.outputs.relay_image_only != 'true'
uses: ./.github/workflows/linux-binaries.yml
secrets:
release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
Expand All @@ -355,19 +354,19 @@ jobs:
needs: [prepare, linux-binaries]
if: >-
always() &&
((needs.prepare.outputs.upload_to_release == 'true' &&
needs.prepare.outputs.release_channel == 'stable') ||
(needs.prepare.outputs.upload_to_release == 'true' ||
needs.prepare.outputs.relay_image_only == 'true') &&
(needs.prepare.outputs.relay_image_only == 'true' ||
needs.linux-binaries.result == 'success')
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
env:
IMAGE: ghcr.io/gcwing/openbitfun-relay-server

steps:
- name: Resolve image repository
shell: bash
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/openbitfun-relay-server" >> "$GITHUB_ENV"

- name: Checkout
uses: actions/checkout@v5
with:
Expand All @@ -391,7 +390,7 @@ jobs:
set -euo pipefail
mkdir -p linux-release-assets
gh release download "${RELEASE_TAG}" \
--repo GCWing/OpenBitFun \
--repo "${GITHUB_REPOSITORY}" \
--dir linux-release-assets \
--pattern 'openbitfun-relay-server-*.tar.gz' \
--pattern 'openbitfun-relay-server-*.tar.gz.sha256'
Expand Down Expand Up @@ -436,10 +435,10 @@ jobs:
echo 'value<<EOF'
echo "${IMAGE}:${RELEASE_TAG}"
echo "${IMAGE}:${asset_version}"
if [[ "${IMAGE_ONLY}" == "true" ]]; then
if [[ "${IMAGE_ONLY}" == "true" && "${RELEASE_CHANNEL}" == "stable" ]]; then
# Backfilling an older release must not roll the floating tag
# backwards. GitHub's latest endpoint excludes prereleases.
latest_release="$(gh api repos/GCWing/OpenBitFun/releases/latest --jq .tag_name)"
latest_release="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
if [[ "${RELEASE_TAG}" == "${latest_release}" ]]; then
echo "${IMAGE}:latest"
fi
Expand Down Expand Up @@ -536,6 +535,8 @@ jobs:
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.prepare.outputs.release_tag }}
prerelease: ${{ needs.prepare.outputs.release_channel == 'beta' }}
make_latest: "false"
files: |
relay-image.json
relay-image.json.sig
Expand All @@ -549,9 +550,8 @@ jobs:
always() &&
needs.prepare.outputs.upload_to_release == 'true' &&
needs.package.result == 'success' &&
(needs.prepare.outputs.release_channel == 'beta' ||
(needs.linux-binaries.result == 'success' &&
needs.publish-relay-image.result == 'success'))
needs.linux-binaries.result == 'success' &&
needs.publish-relay-image.result == 'success'
runs-on: ubuntu-latest
env:
REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64
Expand All @@ -568,32 +568,26 @@ jobs:
merge-multiple: true

- name: Download Linux binary artifacts
if: needs.prepare.outputs.release_channel == 'stable'
uses: actions/download-artifact@v7
with:
pattern: openbitfun-linux-${{ needs.prepare.outputs.release_tag }}-*
path: linux-release-assets
merge-multiple: true

- name: Download Relay image descriptor
if: needs.prepare.outputs.release_channel == 'stable'
uses: actions/download-artifact@v7
with:
name: openbitfun-relay-image-${{ needs.prepare.outputs.release_tag }}
path: relay-image-assets

- name: List release assets
env:
RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }}
run: |
echo "Release assets:"
find release-assets -type f | sort
if [[ "${RELEASE_CHANNEL}" == "stable" ]]; then
echo "Linux CLI and Relay Server assets:"
find linux-release-assets -type f | sort
echo "Relay image descriptor:"
find relay-image-assets -type f | sort
fi
echo "Linux CLI and Relay Server assets:"
find linux-release-assets -type f | sort
echo "Relay image descriptor:"
find relay-image-assets -type f | sort

- name: Prepare versioned Windows installer
run: |
Expand Down Expand Up @@ -638,13 +632,12 @@ jobs:
--required-manual-platforms "windows-x86_64"

- name: Generate Linux binaries manifest
if: needs.prepare.outputs.release_channel == 'stable'
run: |
node scripts/generate-linux-binaries-manifest.mjs \
--assets-dir linux-release-assets \
--version "${{ needs.prepare.outputs.version }}" \
--tag "${{ needs.prepare.outputs.release_tag }}" \
--repo "GCWing/OpenBitFun" \
--repo "${{ github.repository }}" \
--out linux-release-assets/linux-binaries.json

# The Tauri bundler signs the five updater artifacts during `tauri build`,
Expand Down Expand Up @@ -677,8 +670,7 @@ jobs:
node scripts/write-minisign-public-key.mjs \
--out release-assets/minisign.pub

- name: Stage stable release assets
if: needs.prepare.outputs.release_channel == 'stable'
- name: Stage release assets
shell: bash
run: |
set -euo pipefail
Expand Down Expand Up @@ -707,27 +699,6 @@ jobs:
relay-image-assets/relay-image.json \
relay-image-assets/relay-image.json.sig

- name: Stage beta release assets
if: needs.prepare.outputs.release_channel == 'beta'
shell: bash
run: |
set -euo pipefail
shopt -s globstar
node scripts/stage-github-release-assets.mjs \
--out-dir release-upload-assets \
release-updater-assets/* \
release-manual-assets/*.exe \
release-manual-assets/*.exe.sig \
release-assets/**/*.AppImage \
release-assets/**/*.AppImage.sig \
release-assets/**/*.deb \
release-assets/**/*.deb.sig \
release-assets/**/*.dmg \
release-assets/**/*.dmg.sig \
release-assets/**/*.rpm \
release-assets/**/*.rpm.sig \
release-assets/minisign.pub

- name: Upload to release
uses: softprops/action-gh-release@v3
with:
Expand All @@ -752,28 +723,33 @@ jobs:
--check-urls true

- name: Verify published Linux binaries manifest
if: needs.prepare.outputs.release_channel == 'stable'
run: |
curl -fsSL --retry 5 --retry-delay 3 \
"https://github.com/GCWing/OpenBitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \
"https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \
-o linux-binaries.published.json
test "$(jq -r '.version' linux-binaries.published.json)" = "${{ needs.prepare.outputs.version }}"
while IFS= read -r cli_url; do
curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sig" -o /dev/null
curl -fsSL --retry 5 --retry-delay 3 "${cli_url}.sha256.sig" -o /dev/null
done < <(jq -r '.platforms[].cli.url' linux-binaries.published.json)
jq -e '.platforms | has("linux-x86_64") and has("linux-aarch64")' linux-binaries.published.json >/dev/null
while IFS= read -r archive_url; do
test -n "${archive_url}"
curl -fsSLI --retry 5 --retry-delay 3 "${archive_url}" -o /dev/null
curl -fsSL --retry 5 --retry-delay 3 "${archive_url}.sha256" -o /dev/null
curl -fsSL --retry 5 --retry-delay 3 "${archive_url}.sig" -o /dev/null
curl -fsSL --retry 5 --retry-delay 3 "${archive_url}.sha256.sig" -o /dev/null
done < <(jq -r '.platforms[] | .cli.url, .relay.url' linux-binaries.published.json)

- name: Verify published Relay image descriptor
if: needs.prepare.outputs.release_channel == 'stable'
shell: bash
run: |
curl -fsSL --retry 5 --retry-delay 3 \
"https://github.com/GCWing/OpenBitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json" \
"https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json" \
-o relay-image.published.json
test "$(jq -r '.tag' relay-image.published.json)" = "${{ needs.prepare.outputs.release_tag }}"
test "$(jq -r '.image' relay-image.published.json)" = "ghcr.io/gcwing/openbitfun-relay-server"
test "$(jq -r '.version' relay-image.published.json)" = "${{ needs.prepare.outputs.version }}"
test "$(jq -r '.image' relay-image.published.json)" = "ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/openbitfun-relay-server"
jq -e '.platforms | sort == ["linux/amd64", "linux/arm64"]' relay-image.published.json >/dev/null
jq -e '.digest | test("^sha256:[0-9a-f]{64}$")' relay-image.published.json >/dev/null
curl -fsSL --retry 5 --retry-delay 3 \
"https://github.com/GCWing/OpenBitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \
"https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \
-o /dev/null

- name: Resolve beta channel promotion
Expand Down
33 changes: 31 additions & 2 deletions docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,29 @@ Public beta assets are stored on the immutable version tag. After every asset
and signature is verified, the workflow updates only the `latest.json` asset on
the `channel-beta` pre-release. Beta Desktop builds read that pointer and fall
back to `https://openbitfun.com/release/beta/latest.json`.
The beta release contains Desktop and Installer assets only. CLI and Relay
floating releases remain stable-only.
Beta releases include Desktop and Installer assets, Linux CLI and Relay Server
archives for x86_64 and aarch64, and a multi-platform Relay image for linux/amd64
and linux/arm64. Release publication requires every producer to succeed. Archives
include checksums and signatures; `linux-binaries.json` and the signed
`relay-image.json` descriptor live on the immutable version release.

Relay images use `ghcr.io/<repository-owner>/openbitfun-relay-server` with the
version tags `v1.0.0-beta.N` and `1.0.0-beta.N`. Beta never updates the `latest`
image tag. Fork builds publish to the fork owner's image namespace and their
own GitHub Release URLs. GHCR credentials must permit package publication, and
the package must be publicly readable for the anonymous-pull verification to
pass.

With `upload_to_release` disabled, the workflow keeps CLI/Relay archives in
Actions artifacts and validates the runtime image build without pushing it.
The explicit `relay_image_only` backfill mode remains a publishing operation.

Install Beta CLI archives manually and deploy the Relay with an explicit Beta
image tag or its signed descriptor's digest. The default CLI install/update and
Relay one-click deployment paths stay on stable; Beta CLI builds do not run
stable-feed automatic update checks. This does not add a runtime channel switch
or a Beta option to one-click deployment. The stable CLI/Relay mirror manifests
and the Desktop-only `channel-beta/latest.json` pointer remain unchanged.

The selected ref must resolve to a commit in the protected `main` history. The
workflow pins that SHA before dispatching platform jobs and rejects an existing
Expand Down Expand Up @@ -61,3 +82,11 @@ stable-only CLI and Relay floating manifests.
Production cron must run this in-repo script from the OpenBitFun checkout. Do not
create a detached copy. Host paths, Nginx, and the rest of the origin restore
steps live in [`deploy/openbitfun-host/README.md`](../../deploy/openbitfun-host/README.md).

## Focused packaging checks

For release workflow and channel-isolation changes, run
`pnpm run check:github-config` and
`node --test scripts/relay/package-contract.test.mjs scripts/tauri-release-manifest.test.mjs`.
These checks exercise release conditions, image tag selection, Beta manifest
generation, and asset collection with fixtures; they do not build or publish packages.
109 changes: 104 additions & 5 deletions scripts/check-github-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1076,8 +1076,7 @@ test('stages unique release asset names before publishing', () => {
);
const steps = workflow.jobs['upload-release-assets'].steps;
const stagingIndexes = [
steps.findIndex((step) => step.name === 'Stage stable release assets'),
steps.findIndex((step) => step.name === 'Stage beta release assets'),
steps.findIndex((step) => step.name === 'Stage release assets'),
];
const uploadIndex = steps.findIndex((step) => step.name === 'Upload to release');

Expand Down Expand Up @@ -1182,10 +1181,10 @@ test('Desktop packaging keeps beta identity explicit and stable-safe', () => {
(step) => step.name === 'Publish beta channel manifest',
);
assert.ok(verifyIndexPublished >= 0 && verifyIndexPublished < promoteIndex);
assert.match(workflow.jobs['linux-binaries'].if, /release_channel == 'stable'/);
assert.doesNotMatch(workflow.jobs['linux-binaries'].if, /release_channel/);
assert.equal(
uploadSteps.find((step) => step.name === 'Stage beta release assets').if,
"needs.prepare.outputs.release_channel == 'beta'",
uploadSteps.find((step) => step.name === 'Stage release assets').if,
undefined,
);
assert.match(
uploadSteps.find((step) => step.name === 'Generate updater manifest').run,
Expand Down Expand Up @@ -1230,6 +1229,106 @@ test('beta publishing cannot advance the Relay latest image tag', () => {
assert.doesNotMatch(imageTags.run, /RELEASE_PRERELEASE/);
});

test('stable and beta publication require every producer, while artifact-only runs never publish', () => {
const { jobs } = yaml.parse(readFileSync(
path.join(repoRoot, '.github/workflows/desktop-package.yml'), 'utf8',
));
// These workflow conditions use the shared Boolean/string subset of Actions
// expressions and JavaScript. Exercise the actual conditions, not a copy.
const condition = (job, needs) => {
const expression = job.if.replace(/needs\.([\w-]+)/g, 'needs["$1"]');
return Function('needs', 'always', `return (${expression});`)(needs, () => true);
};
const results = ['success', 'failure', 'cancelled', 'skipped'];
for (const channel of ['stable', 'beta']) {
for (const upload of ['true', 'false']) {
for (const desktop of results) for (const linux of results) for (const image of results) {
const needs = {
prepare: { outputs: { release_channel: channel, upload_to_release: upload, relay_image_only: 'false' } },
package: { result: desktop },
'linux-binaries': { result: linux },
'publish-relay-image': { result: image },
};
assert.equal(condition(jobs['linux-binaries'], needs), true);
assert.equal(condition(jobs['publish-relay-image'], needs), upload === 'true' && linux === 'success');
assert.equal(condition(jobs['upload-release-assets'], needs),
upload === 'true' && [desktop, linux, image].every((result) => result === 'success'),
JSON.stringify(needs));
}
}
const backfill = {
prepare: { outputs: { release_channel: channel, upload_to_release: 'true', relay_image_only: 'true' } },
package: { result: 'skipped' },
'linux-binaries': { result: 'skipped' },
'publish-relay-image': { result: 'success' },
};
assert.equal(condition(jobs['linux-binaries'], backfill), false);
assert.equal(condition(jobs['publish-relay-image'], backfill), true);
assert.equal(condition(jobs['upload-release-assets'], backfill), false);
}
const backfillRelease = jobs['publish-relay-image'].steps.find(
(step) => step.name === 'Attach descriptor to an existing release (image-only backfill)',
);
assert.equal(backfillRelease.with.prerelease, "${{ needs.prepare.outputs.release_channel == 'beta' }}");
assert.equal(backfillRelease.with.make_latest, 'false');
const steps = jobs['upload-release-assets'].steps;
for (const name of [
'Download Linux binary artifacts', 'Download Relay image descriptor',
'Generate Linux binaries manifest', 'Stage release assets',
'Verify published Linux binaries manifest', 'Verify published Relay image descriptor',
]) {
assert.equal(steps.find((step) => step.name === name)?.if, undefined, name);
assert.ok(steps.some((step) => step.name === name), name);
}
const stage = steps.find((step) => step.name === 'Stage release assets');
for (const pattern of [
'linux-release-assets/openbitfun-cli-*.tar.gz',
'linux-release-assets/openbitfun-relay-server-*.tar.gz',
'linux-release-assets/*.tar.gz.sig', 'linux-release-assets/*.tar.gz.sha256.sig',
'linux-release-assets/linux-binaries.json', 'relay-image-assets/relay-image.json.sig',
]) assert.ok(stage.run.includes(pattern), pattern);
assert.match(steps.find((step) => step.name === 'Generate Linux binaries manifest').run, /--repo "\$\{\{ github.repository \}\}"/);
for (const name of ['Verify published Linux binaries manifest', 'Verify published Relay image descriptor']) {
const verification = steps.find((step) => step.name === name);
assert.match(verification.run, /github.repository/);
assert.ok(steps.indexOf(verification) < steps.findIndex((step) => step.name === 'Publish beta channel manifest'));
}
});

test('Relay image tag selection keeps Beta and old stable backfills away from latest', {
skip: process.platform === 'win32',
}, (t) => {
const { jobs } = yaml.parse(readFileSync(
path.join(repoRoot, '.github/workflows/desktop-package.yml'), 'utf8',
));
const step = jobs['publish-relay-image'].steps.find((entry) => entry.name === 'Resolve image tags');
const root = mkdtempSync(path.join(tmpdir(), 'openbitfun-image-tags-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
for (const scenario of [
{ channel: 'beta', version: '1.0.0-beta.3', imageOnly: 'false', latest: false },
{ channel: 'beta', version: '1.0.0-beta.3', imageOnly: 'true', latest: false },
{ channel: 'stable', version: '1.0.0', imageOnly: 'false', latest: true },
{ channel: 'stable', version: '1.0.0', imageOnly: 'true', latest: true },
{ channel: 'stable', version: '0.2.19', imageOnly: 'true', latest: false },
]) {
const output = path.join(root, 'output');
writeFileSync(output, '');
const result = spawnSync('bash', ['-c', `gh() { printf 'v1.0.0\\n'; }\n${step.run}`], {
env: { ...process.env, GITHUB_OUTPUT: output, GITHUB_REPOSITORY: 'test-owner/OpenBitFun',
IMAGE: 'ghcr.io/test-owner/openbitfun-relay-server', RELEASE_TAG: `v${scenario.version}`,
RELEASE_VERSION: scenario.version, RELEASE_CHANNEL: scenario.channel, IMAGE_ONLY: scenario.imageOnly },
encoding: 'utf8', windowsHide: true,
});
assert.equal(result.status, 0, result.stderr);
const tags = readFileSync(output, 'utf8').trim().split('\n');
assert.deepEqual(tags, [
'value<<EOF', `ghcr.io/test-owner/openbitfun-relay-server:v${scenario.version}`,
`ghcr.io/test-owner/openbitfun-relay-server:${scenario.version}`,
...(scenario.latest ? ['ghcr.io/test-owner/openbitfun-relay-server:latest'] : []), 'EOF',
]);
}
});

test('beta channel readback retries stale content and fails if it never converges', {
skip: process.platform === 'win32' || spawnSync('jq', ['--version'], { windowsHide: true }).status !== 0,
}, (t) => {
Expand Down
Loading
Loading