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
140 changes: 137 additions & 3 deletions .github/workflows/publish-ready-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@ on:
type: string
source_run_id:
description: Desktop Package run with successful Windows and both Linux jobs
required: true
required: false
type: string
macos_run_id:
description: Publish only one verified macOS producer instead of Windows and Linux
required: false
type: string
macos_platform:
description: Platform produced by macos_run_id
required: true
default: macos-arm64
type: choice
options:
- macos-arm64
- macos-x64

permissions:
actions: read
Expand All @@ -22,6 +34,7 @@ concurrency:

jobs:
publish:
if: ${{ inputs.macos_run_id == '' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -43,8 +56,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$SOURCE_RUN" =~ ^[0-9]+$ ]]
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1
[[ "$SOURCE_RUN" =~ ^[0-9]+$ ]] || exit 1
test -n "$OPENBITFUN_SIGNING_KEY"
test -n "$OPENBITFUN_SIGNING_PUBKEY"
git fetch origin main --tags
Expand Down Expand Up @@ -164,3 +177,124 @@ jobs:
cmp "$asset" "readback/$name"
done
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url,isDraft,assets

publish-macos:
if: ${{ inputs.macos_run_id != '' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag_name }}
SOURCE_RUN: ${{ inputs.source_run_id }}
MACOS_RUN: ${{ inputs.macos_run_id }}
MACOS_PLATFORM: ${{ inputs.macos_platform }}
OPENBITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
OPENBITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENBITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v5
with:
node-version: '22.12.0'
package-manager-cache: false

- name: Validate verified macOS producer
shell: bash
run: |
set -euo pipefail
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1
[[ "$MACOS_RUN" =~ ^[0-9]+$ ]] || exit 1
[[ "$MACOS_PLATFORM" == macos-arm64 || "$MACOS_PLATFORM" == macos-x64 ]] || exit 1
test -z "$SOURCE_RUN"
test -n "$OPENBITFUN_SIGNING_KEY"
test -n "$OPENBITFUN_SIGNING_PUBKEY"
git fetch origin main --tags
git merge-base --is-ancestor "${RELEASE_TAG}^{commit}" origin/main
test "$(git show "${RELEASE_TAG}:package.json" | jq -r .version)" = "${RELEASE_TAG#v}"
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$MACOS_RUN" > macos-run.json
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$MACOS_RUN/jobs?per_page=100" > macos-jobs.json
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$MACOS_RUN/artifacts?per_page=100" > macos-artifacts.json
jq -e --arg repo "$GITHUB_REPOSITORY" '
.repository.full_name == $repo and .head_repository.full_name == $repo and
.path == ".github/workflows/desktop-package.yml" and
(.event == "workflow_dispatch" or .event == "release") and
(.head_sha | test("^[a-f0-9]{40}$"))' macos-run.json
source_sha="$(jq -r .head_sha macos-run.json)"
git merge-base --is-ancestor "$source_sha" origin/main
git merge-base --is-ancestor "${RELEASE_TAG}^{commit}" "$source_sha"
for job in Prepare "Package ($MACOS_PLATFORM)"; do
jq -e --arg job "$job" '.jobs | any(.name == $job and .conclusion == "success")' macos-jobs.json
done
jq -e --arg job "Package ($MACOS_PLATFORM)" '.jobs | any(.name == $job and
(.steps | any(.name == "Verify Apple signature and notarization" and .conclusion == "success")))' macos-jobs.json
artifact="openbitfun-${RELEASE_TAG}-${MACOS_PLATFORM}-bundle"
jq -e --arg name "$artifact" '
[.artifacts[] | select(.name == $name)] | length == 1 and .[0].expired == false' macos-artifacts.json
gh run download "$MACOS_RUN" --repo "$GITHUB_REPOSITORY" \
--name "$artifact" --dir "release-assets/$MACOS_PLATFORM"
echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"

- name: Sign and verify ready macOS assets
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends minisign
case "$MACOS_PLATFORM" in
macos-arm64) updater_platform=darwin-aarch64; dmg_arch=aarch64 ;;
macos-x64) updater_platform=darwin-x86_64; dmg_arch=x64 ;;
esac
mapfile -t dmgs < <(find release-assets -type f -name '*.dmg')
test "${#dmgs[@]}" -eq 1
test "$(basename "${dmgs[0]}")" = "OpenBitFun_${RELEASE_VERSION}_${dmg_arch}.dmg"
node scripts/write-minisign-public-key.mjs --out release-assets/minisign.pub
# Keep the existing release trust root; never replace it in this path.
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern minisign.pub --dir existing-key
cmp release-assets/minisign.pub existing-key/minisign.pub
# A retry must retain the original signature's trusted timestamp.
name="$(basename "${dmgs[0]}")"
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets > existing-assets.json
if jq -e --arg name "$name.sig" '.assets | any(.name == $name)' existing-assets.json >/dev/null; then
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "$name" --pattern "$name.sig" --dir existing-macos
cmp "${dmgs[0]}" "existing-macos/$name"
cp "existing-macos/$name.sig" "${dmgs[0]}.sig"
fi
bash scripts/sign-release-assets.sh "${dmgs[0]}"
node scripts/collect-tauri-updater-assets.mjs --assets-dir release-assets \
--version "$RELEASE_VERSION" --out-dir release-updater-assets \
--required-platforms "$updater_platform"
node scripts/stage-github-release-assets.mjs --out-dir release-upload-assets \
release-updater-assets/* "${dmgs[0]}" "${dmgs[0]}.sig"
test "$(find release-upload-assets -type f | wc -l)" -eq 4
for asset in release-upload-assets/*.dmg release-upload-assets/*.app.tar.gz; do
test -s "$asset.sig"
base64 -d < "$asset.sig" > verify.minisig
minisign -Vm "$asset" -p existing-key/minisign.pub -x verify.minisig
done

- name: Publish ready macOS assets without changing update feeds
shell: bash
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > release-state.json
jq -e '.draft == false and .prerelease == false and
(.assets | all(.name != "latest-v1.json"))' release-state.json
mkdir -p readback
for asset in release-upload-assets/*; do
name="$(basename "$asset")"
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets > existing-assets.json
if jq -e --arg name "$name" '.assets | any(.name == $name)' existing-assets.json >/dev/null; then
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir readback --clobber
cmp "$asset" "readback/$name"
else
gh release upload "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY"
fi
done
for asset in release-upload-assets/*; do
name="$(basename "$asset")"
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir readback --clobber
cmp "$asset" "readback/$name"
done
10 changes: 10 additions & 0 deletions docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ receipt. The archive preserves executable modes and symlinks for re-verification
It is unverified recovery input, never a release installer: it does not match
the normal `*-bundle` publication pattern or satisfy the complete producer gate.

To add a verified Mac package to an already public partial stable release,
dispatch `Publish Ready Desktop Assets` with `tag_name`, `macos_run_id`, and
`macos_platform`; leave `source_run_id` empty. The selected producer must have
successful Prepare and Package jobs on protected-main history, plus an unexpired
normal bundle artifact. The publisher verifies the existing release public key,
signs the DMG, verifies both installer and updater signatures, and reads uploaded
files back. This path only adds the four Mac assets; it does not change release
notes, GitHub Latest, or any update pointer. Use the full finalization above once
every platform is ready.

## Mobile Android releases

Mobile Android packages are released independently from Desktop packages. Use a
Expand Down
89 changes: 84 additions & 5 deletions scripts/check-github-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,22 +71,29 @@ test('partial Desktop publication requires successful trusted producers and leav
const file = path.join(root, 'bin', name);
writeFileSync(file, `#!/usr/bin/env bash\nset -euo pipefail\n${source}\n`, { mode: 0o755 });
}
executable('git', 'if [[ "$1" == show ]]; then echo \'{"version":"1.0.6"}\'; fi');
executable('git', 'if [[ "$1" == merge-base && "${REJECT_ANCESTRY:-}" == true ]]; then exit 1; fi; if [[ "$1" == show ]]; then echo \'{"version":"1.0.6"}\'; fi');
executable('gh', `
if [[ "$1" == api ]]; then
if [[ "$2" == *'/jobs?'* ]]; then cat "$FIXTURE/jobs.json"; else cat "$FIXTURE/run.json"; fi
case "$2" in
*'/jobs?'*) cat "$FIXTURE/jobs.json" ;;
*'/artifacts?'*) cat "$FIXTURE/artifacts.json" ;;
*'/releases/tags/'*) cat "$FIXTURE/state.json" ;;
*) cat "$FIXTURE/run.json" ;;
esac
exit 0
fi
printf '%s\\n' "$*" >> "$FIXTURE/calls"
case "$1 $2" in
'release view') echo '{"assets":[]}' ;;
'release view') cat "$FIXTURE/state.json" ;;
'release download')
shift 2
names=()
while [[ $# -gt 0 ]]; do
case "$1" in --pattern) name="$2"; shift;; --dir) dest="$2"; shift;; esac
case "$1" in --pattern) names+=("$2"); shift;; --dir) dest="$2"; shift;; esac
shift
done
cp "$FIXTURE/release-upload-assets/$name" "$dest/$name"
mkdir -p "$dest"
for name in "\${names[@]}"; do cp "$FIXTURE/release-upload-assets/$name" "$dest/$name"; done
;;
esac
`);
Expand Down Expand Up @@ -149,6 +156,78 @@ test('partial Desktop publication requires successful trusted producers and leav
assert.match(stage, /minisign -Vm/);
assert.match(stage, /sha256sum --check/);
assert.doesNotMatch(stage, /generate-tauri-latest-json/);

const mac = workflow.jobs['publish-macos'];
assert.equal(workflow.jobs.publish.if, "${{ inputs.macos_run_id == '' }}");
assert.equal(mac.if, "${{ inputs.macos_run_id != '' }}");
const macValidate = mac.steps.find((step) => step.name === 'Validate verified macOS producer').run;
const macPublish = mac.steps.find((step) => step.name === 'Publish ready macOS assets without changing update feeds').run;
const macEnv = { SOURCE_RUN: '', MACOS_RUN: '456', MACOS_PLATFORM: 'macos-arm64' };
const macJobs = { jobs: ['Prepare', 'Package (macos-arm64)'].map((name) => ({
name, conclusion: 'success', steps: [{ name: 'Verify Apple signature and notarization', conclusion: 'success' }],
})) };
const artifact = { name: 'openbitfun-v1.0.6-macos-arm64-bundle', expired: false };
function macFixtures(nextRun = run, nextJobs = macJobs, artifacts = [artifact]) {
fixtures(nextRun, nextJobs);
writeFileSync(path.join(root, 'artifacts.json'), JSON.stringify({ artifacts }));
}
macFixtures();
result = execute(macValidate, macEnv);
assert.equal(result.status, 0, result.stderr);
for (const nextJobs of [
{ jobs: macJobs.jobs.filter((job) => job.name !== 'Prepare') },
{ jobs: macJobs.jobs.map((job) => ({ ...job, conclusion: 'failure' })) },
{ jobs: macJobs.jobs.map((job) => ({ ...job, steps: [{ name: 'Verify Apple signature and notarization', conclusion: 'skipped' }] })) },
{ jobs: [{ name: 'Prepare', conclusion: 'success' }, { name: 'Package (macos-x64)', conclusion: 'success' }] },
]) {
macFixtures(run, nextJobs);
assert.notEqual(execute(macValidate, macEnv).status, 0);
}
for (const artifacts of [[], [{ ...artifact, expired: true }], [artifact, artifact],
[{ ...artifact, name: 'openbitfun-v1.0.6-macos-arm64-verification-recovery' }]]) {
macFixtures(run, macJobs, artifacts);
assert.notEqual(execute(macValidate, macEnv).status, 0);
}
for (const nextRun of [
{ ...run, repository: { full_name: 'untrusted/fork' } },
{ ...run, head_repository: { full_name: 'untrusted/fork' } },
{ ...run, path: '.github/workflows/ci.yml' },
{ ...run, event: 'pull_request' }, { ...run, head_sha: 'invalid' },
]) {
macFixtures(nextRun);
assert.notEqual(execute(macValidate, macEnv).status, 0);
}
macFixtures();
for (const overrides of [
{ SOURCE_RUN: '123' }, { MACOS_RUN: '456;echo injected' },
{ MACOS_PLATFORM: 'linux-x64' }, { REJECT_ANCESTRY: 'true' },
]) {
const rejected = execute(macValidate, { ...macEnv, ...overrides });
assert.notEqual(rejected.status, 0, `${JSON.stringify(overrides)}\n${rejected.stderr}`);
}
const macStage = mac.steps.find((step) => step.name === 'Sign and verify ready macOS assets').run;
assert.match(macStage, /cmp release-assets\/minisign.pub existing-key\/minisign.pub/);
assert.match(macStage, /minisign -Vm/);
assert.doesNotMatch(macStage, /generate-tauri-latest-json/);
writeFileSync(path.join(root, 'state.json'), JSON.stringify({ draft: false, prerelease: false, assets: [{ name: 'latest-v1.json' }] }));
assert.notEqual(execute(macPublish, macEnv).status, 0);
writeFileSync(path.join(root, 'state.json'), JSON.stringify({ draft: false, prerelease: false, assets: [] }));
result = execute(macPublish, macEnv);
assert.equal(result.status, 0, result.stderr);
assert.doesNotMatch(macPublish, /release edit|--clobber.*upload|generate-tauri-latest-json/);
// Retrying publication must reuse the existing signature only for identical bytes.
writeFileSync(path.join(root, 'source.dmg'), 'verified dmg bytes');
writeFileSync(path.join(root, 'release-upload-assets/source.dmg'), 'verified dmg bytes');
writeFileSync(path.join(root, 'release-upload-assets/source.dmg.sig'), 'original signature');
writeFileSync(path.join(root, 'state.json'), JSON.stringify({ assets: [{ name: 'source.dmg.sig' }] }));
const reuseSignature = 'set -euo pipefail\ndmgs=("$FIXTURE/source.dmg")\n' + macStage.slice(
macStage.indexOf('name="$(basename'), macStage.indexOf('bash scripts/sign-release-assets.sh'),
);
result = execute(reuseSignature, macEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(readFileSync(path.join(root, 'source.dmg.sig'), 'utf8'), 'original signature');
writeFileSync(path.join(root, 'source.dmg'), 'different dmg bytes');
assert.notEqual(execute(reuseSignature, macEnv).status, 0);
});

test('rejects setup-node node-version-file below the project baseline', (t) => {
Expand Down
Loading