diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 564d26f195..b5f2480557 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -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 }} @@ -355,8 +354,7 @@ 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') @@ -364,10 +362,11 @@ jobs: 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: @@ -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' @@ -436,10 +435,10 @@ jobs: echo 'value</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 diff --git a/docs/development/releasing.md b/docs/development/releasing.md index e21c6d44b4..4afebec5e0 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -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//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 @@ -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. diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index c8f2630c44..c624225654 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -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'); @@ -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, @@ -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< { diff --git a/scripts/relay/package-contract.test.mjs b/scripts/relay/package-contract.test.mjs index 6ef9d62252..6f74686501 100644 --- a/scripts/relay/package-contract.test.mjs +++ b/scripts/relay/package-contract.test.mjs @@ -35,7 +35,7 @@ test('formal and nightly releases gate publication on Linux binaries', () => { assert.match(workflow, /openbitfun-cli-\*\.tar\.gz/); assert.match(workflow, /linux-release-assets\/\*\.tar\.gz\.sig/); assert.match(workflow, /linux-release-assets\/\*\.tar\.gz\.sha256\.sig/); - assert.match(workflow, /\$\{cli_url\}\.sha256\.sig/); + assert.match(workflow, /\$\{(?:cli|archive)_url\}\.sha256\.sig/); assert.match(workflow, /linux-binaries\.json/); } diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs index 6ffa412377..d14c684434 100644 --- a/scripts/tauri-release-manifest.test.mjs +++ b/scripts/tauri-release-manifest.test.mjs @@ -118,9 +118,56 @@ test('rejects duplicate GitHub release asset names before upload', () => { assert.match(result.stderr, /macos-arm64/); }); +test('Beta Linux CLI and Relay manifests keep signed assets on the versioned repository release', (t) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-beta-linux-')); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const version = '1.0.0-beta.3'; + const tag = `v${version}`; + const assets = []; + for (const target of ['x86_64-unknown-linux-gnu', 'aarch64-unknown-linux-gnu']) { + for (const name of [`openbitfun-cli-${version}-${target}.tar.gz`, `openbitfun-relay-server-${target}.tar.gz`]) { + for (const suffix of ['', '.sha256', '.sig', '.sha256.sig']) { + const filename = path.join(temp, name + suffix); + fs.writeFileSync(filename, `fixture ${name}${suffix}`); + assets.push(filename); + } + } + } + const out = path.join(temp, 'linux-binaries.json'); + const result = run('scripts/generate-linux-binaries-manifest.mjs', [ + '--assets-dir', temp, '--version', version, '--tag', tag, + '--repo', 'test-owner/OpenBitFun', '--out', out, + ]); + assert.equal(result.status, 0, result.stderr); + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.equal(manifest.version, version); + assert.equal(manifest.tag, tag); + assert.deepEqual(Object.keys(manifest.platforms).sort(), ['linux-aarch64', 'linux-x86_64']); + for (const platform of Object.values(manifest.platforms)) { + for (const artifact of [platform.cli, platform.relay]) { + assert.equal(artifact.url, `https://github.com/test-owner/OpenBitFun/releases/download/${tag}/${artifact.filename}`); + assert.equal(artifact.sha256Url, `${artifact.url}.sha256`); + assert.equal(artifact.sigUrl, `${artifact.url}.sig`); + assert.equal(artifact.sha256SigUrl, `${artifact.url}.sha256.sig`); + } + } + const staged = path.join(temp, 'staged'); + const staging = run('scripts/stage-github-release-assets.mjs', ['--out-dir', staged, ...assets, out]); + assert.equal(staging.status, 0, staging.stderr); + assert.deepEqual(fs.readdirSync(staged).sort(), [...assets, out].map((file) => path.basename(file)).sort()); + fs.unlinkSync(assets.find((file) => file.endsWith('.tar.gz'))); + const missing = run('scripts/generate-linux-binaries-manifest.mjs', [ + '--assets-dir', temp, '--version', version, '--tag', tag, + '--repo', 'test-owner/OpenBitFun', '--out', out, + ]); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /Required Linux release asset was not found/); +}); + function run(script, args) { return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8', + windowsHide: true, }); }