diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..156f9ca
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+*.sh text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.desktop text eol=lf
+*.svg text eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9a21954..94c638d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,13 +29,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
- dotnet-version: 10.0.x
+ dotnet-version: 10.0.204
- name: Build shared core
run: dotnet build src/FB2Blogger.Core/FB2Blogger.Core.csproj -c Release --nologo
- name: Run cross-platform core audit
run: dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
- name: Build Avalonia preview shell
run: dotnet build src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj -c Release --nologo
+ - name: Audit native preview packaging metadata
+ run: dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
windows-full-app:
name: Full Windows application
@@ -44,7 +46,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
- dotnet-version: 10.0.x
+ dotnet-version: 10.0.204
- name: Build
run: dotnet build src/FB2Blogger/FB2Blogger.csproj -c Release --nologo
- name: Regression audit
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 9849a46..6e409bd 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -30,7 +30,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
- dotnet-version: 10.0.x
+ dotnet-version: 10.0.204
- name: Initialize CodeQL
uses: github/codeql-action/init@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml
new file mode 100644
index 0000000..d9d8546
--- /dev/null
+++ b/.github/workflows/preview-packages.yml
@@ -0,0 +1,476 @@
+name: Native Packages and RC Release
+
+on:
+ push:
+ branches: [main]
+ tags:
+ - 'v1.1.0-rc.*'
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: native-packages-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref_type != 'tag' }}
+
+env:
+ SBOM_DOTNET_SDK_VERSION: 8.0.419
+ DOTNET_SDK_VERSION: 10.0.204
+
+jobs:
+ release-gate:
+ name: Validate event and release authority
+ runs-on: ubuntu-24.04
+ outputs:
+ publish: ${{ steps.release_gate.outputs.publish }}
+ tag: ${{ steps.release_gate.outputs.tag }}
+ build_commit: ${{ steps.release_gate.outputs.build_commit }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
+ with:
+ fetch-depth: 0
+ - name: Fetch the protected release authority
+ run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
+ - name: Allow only an exact v1.1.0-rc.N tag contained in main
+ id: release_gate
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ TAG_COMMIT: ${{ github.sha }}
+ MAIN_REF: origin/main
+ run: bash packaging/validate-release-tag.sh
+
+ windows-release:
+ name: Windows x64 full release candidate
+ needs: release-gate
+ runs-on: windows-2025
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
+ with:
+ ref: ${{ needs.release-gate.outputs.build_commit }}
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: |
+ ${{ env.SBOM_DOTNET_SDK_VERSION }}
+ ${{ env.DOTNET_SDK_VERSION }}
+ - name: Resolve traceable package metadata
+ id: metadata
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ RUN_NUMBER: ${{ github.run_number }}
+ run: bash packaging/resolve-metadata.sh
+ - name: Run the complete Windows regression and packaging audits
+ run: |
+ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
+ dotnet run --project tests/AuditHarness/AuditHarness.csproj -c Release
+ dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
+ - name: Publish the self-contained full Windows application
+ shell: pwsh
+ run: |
+ dotnet publish src/FB2Blogger/FB2Blogger.csproj `
+ -c Release `
+ -r win-x64 `
+ --self-contained true `
+ -p:PublishSingleFile=true `
+ -p:ContinuousIntegrationBuild=true `
+ -p:Version='${{ steps.metadata.outputs.app_version }}' `
+ -o '${{ runner.temp }}\fb2blogger-publish-Windows-x64'
+ - name: Launch the packaged UI and create SHA256 plus SPDX SBOM
+ shell: pwsh
+ run: |
+ ./packaging/windows/build-release.ps1 `
+ -PublishDir '${{ runner.temp }}\fb2blogger-publish-Windows-x64' `
+ -OutputDir '${{ github.workspace }}\dist\Windows-x64' `
+ -AppVersion '${{ steps.metadata.outputs.app_version }}' `
+ -RepositoryRoot '${{ github.workspace }}'
+ - name: Upload the Windows EXE, SHA256, and SBOM
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
+ with:
+ name: FB2Blogger-Windows-x64-${{ github.run_id }}
+ path: dist/Windows-x64/*
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 30
+
+ macos-x64-preview:
+ name: macOS Intel x64 Preview DMG
+ needs: release-gate
+ runs-on: macos-15-intel
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
+ with:
+ ref: ${{ needs.release-gate.outputs.build_commit }}
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: |
+ ${{ env.SBOM_DOTNET_SDK_VERSION }}
+ ${{ env.DOTNET_SDK_VERSION }}
+ - name: Resolve traceable package metadata
+ id: metadata
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ RUN_NUMBER: ${{ github.run_number }}
+ run: bash packaging/resolve-metadata.sh
+ - name: Run shared and packaging audits
+ run: |
+ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
+ dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
+ - name: Check packaging script syntax
+ shell: bash
+ run: bash -n packaging/macos/build-dmg.sh packaging/resolve-metadata.sh packaging/generate-sbom.sh
+ - name: Publish the native self-contained preview
+ shell: bash
+ run: |
+ dotnet publish src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj \
+ -c Release \
+ -r osx-x64 \
+ --self-contained true \
+ -p:PublishSingleFile=false \
+ -p:ContinuousIntegrationBuild=true \
+ -p:Version='${{ steps.metadata.outputs.app_version }}' \
+ -o '${{ runner.temp }}/fb2blogger-publish-macOS-x64'
+ - name: Build, mount, and launch-smoke-test the DMG
+ shell: bash
+ env:
+ PUBLISH_DIR: ${{ runner.temp }}/fb2blogger-publish-macOS-x64
+ OUTPUT_DIR: ${{ github.workspace }}/dist/macOS-x64
+ PACKAGE_LABEL: ${{ steps.metadata.outputs.package_label }}
+ APP_VERSION: ${{ steps.metadata.outputs.app_version }}
+ RUNTIME_ID: osx-x64
+ ARCHITECTURE_LABEL: x64
+ run: bash packaging/macos/build-dmg.sh
+ - name: Generate the pinned Microsoft SPDX SBOM
+ shell: bash
+ env:
+ PUBLISH_DIR: ${{ runner.temp }}/fb2blogger-publish-macOS-x64
+ OUTPUT_DIR: ${{ github.workspace }}/dist/macOS-x64
+ APP_VERSION: ${{ steps.metadata.outputs.app_version }}
+ PLATFORM_NAME: macOS-x64
+ run: |
+ package_path="$(find "${OUTPUT_DIR}" -maxdepth 1 -type f -name '*.dmg' -print -quit)"
+ test -n "${package_path}"
+ PACKAGE_FILE_NAME="$(basename "${package_path}")" bash packaging/generate-sbom.sh
+ - name: Upload the DMG, SHA256, and SBOM
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
+ with:
+ name: FB2Blogger-macOS-x64-${{ github.run_id }}
+ path: dist/macOS-x64/*
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 30
+
+ macos-arm64-preview:
+ name: macOS Apple Silicon arm64 Preview DMG
+ needs: release-gate
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
+ with:
+ ref: ${{ needs.release-gate.outputs.build_commit }}
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: |
+ ${{ env.SBOM_DOTNET_SDK_VERSION }}
+ ${{ env.DOTNET_SDK_VERSION }}
+ - name: Resolve traceable package metadata
+ id: metadata
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ RUN_NUMBER: ${{ github.run_number }}
+ run: bash packaging/resolve-metadata.sh
+ - name: Run shared and packaging audits
+ run: |
+ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
+ dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
+ - name: Check packaging script syntax
+ shell: bash
+ run: bash -n packaging/macos/build-dmg.sh packaging/resolve-metadata.sh packaging/generate-sbom.sh
+ - name: Publish the native self-contained preview
+ shell: bash
+ run: |
+ dotnet publish src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj \
+ -c Release \
+ -r osx-arm64 \
+ --self-contained true \
+ -p:PublishSingleFile=false \
+ -p:ContinuousIntegrationBuild=true \
+ -p:Version='${{ steps.metadata.outputs.app_version }}' \
+ -o '${{ runner.temp }}/fb2blogger-publish-macOS-arm64'
+ - name: Build, mount, and launch-smoke-test the DMG
+ shell: bash
+ env:
+ PUBLISH_DIR: ${{ runner.temp }}/fb2blogger-publish-macOS-arm64
+ OUTPUT_DIR: ${{ github.workspace }}/dist/macOS-arm64
+ PACKAGE_LABEL: ${{ steps.metadata.outputs.package_label }}
+ APP_VERSION: ${{ steps.metadata.outputs.app_version }}
+ RUNTIME_ID: osx-arm64
+ ARCHITECTURE_LABEL: arm64
+ run: bash packaging/macos/build-dmg.sh
+ - name: Generate the pinned Microsoft SPDX SBOM
+ shell: bash
+ env:
+ PUBLISH_DIR: ${{ runner.temp }}/fb2blogger-publish-macOS-arm64
+ OUTPUT_DIR: ${{ github.workspace }}/dist/macOS-arm64
+ APP_VERSION: ${{ steps.metadata.outputs.app_version }}
+ PLATFORM_NAME: macOS-arm64
+ run: |
+ package_path="$(find "${OUTPUT_DIR}" -maxdepth 1 -type f -name '*.dmg' -print -quit)"
+ test -n "${package_path}"
+ PACKAGE_FILE_NAME="$(basename "${package_path}")" bash packaging/generate-sbom.sh
+ - name: Upload the DMG, SHA256, and SBOM
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
+ with:
+ name: FB2Blogger-macOS-arm64-${{ github.run_id }}
+ path: dist/macOS-arm64/*
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 30
+
+ linux-preview:
+ name: Linux x64 Preview AppImage
+ needs: release-gate
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
+ with:
+ ref: ${{ needs.release-gate.outputs.build_commit }}
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: |
+ ${{ env.SBOM_DOTNET_SDK_VERSION }}
+ ${{ env.DOTNET_SDK_VERSION }}
+ - name: Resolve traceable package metadata
+ id: metadata
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ RUN_NUMBER: ${{ github.run_number }}
+ run: bash packaging/resolve-metadata.sh
+ - name: Run shared and packaging audits
+ run: |
+ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
+ dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
+ - name: Check packaging script syntax
+ run: bash -n packaging/linux/build-appimage.sh packaging/resolve-metadata.sh packaging/generate-sbom.sh
+ - name: Publish the native self-contained preview
+ run: |
+ dotnet publish src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj \
+ -c Release \
+ -r linux-x64 \
+ --self-contained true \
+ -p:PublishSingleFile=false \
+ -p:ContinuousIntegrationBuild=true \
+ -p:Version='${{ steps.metadata.outputs.app_version }}' \
+ -o '${{ runner.temp }}/fb2blogger-publish-Linux-x64'
+ - name: Build, extract, and launch-smoke-test the AppImage
+ env:
+ PUBLISH_DIR: ${{ runner.temp }}/fb2blogger-publish-Linux-x64
+ OUTPUT_DIR: ${{ github.workspace }}/dist/Linux-x64
+ PACKAGE_LABEL: ${{ steps.metadata.outputs.package_label }}
+ run: bash packaging/linux/build-appimage.sh
+ - name: Generate the pinned Microsoft SPDX SBOM
+ env:
+ PUBLISH_DIR: ${{ runner.temp }}/fb2blogger-publish-Linux-x64
+ OUTPUT_DIR: ${{ github.workspace }}/dist/Linux-x64
+ APP_VERSION: ${{ steps.metadata.outputs.app_version }}
+ PLATFORM_NAME: Linux-x64
+ run: |
+ package_path="$(find "${OUTPUT_DIR}" -maxdepth 1 -type f -name '*.AppImage' -print -quit)"
+ test -n "${package_path}"
+ PACKAGE_FILE_NAME="$(basename "${package_path}")" bash packaging/generate-sbom.sh
+ - name: Upload the AppImage, SHA256, and SBOM
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
+ with:
+ name: FB2Blogger-Linux-x64-${{ github.run_id }}
+ path: dist/Linux-x64/*
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 30
+
+ required-platform-packages:
+ name: Required - All platform packages
+ if: always() && github.event_name == 'pull_request'
+ needs: [windows-release, macos-x64-preview, macos-arm64-preview, linux-preview]
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Require every platform package job to pass
+ shell: bash
+ env:
+ WINDOWS_RESULT: ${{ needs.windows-release.result }}
+ MACOS_X64_RESULT: ${{ needs.macos-x64-preview.result }}
+ MACOS_ARM64_RESULT: ${{ needs.macos-arm64-preview.result }}
+ LINUX_RESULT: ${{ needs.linux-preview.result }}
+ run: |
+ printf 'Windows=%s macOS-x64=%s macOS-arm64=%s Linux=%s\n' \
+ "${WINDOWS_RESULT}" "${MACOS_X64_RESULT}" "${MACOS_ARM64_RESULT}" "${LINUX_RESULT}"
+ test "${WINDOWS_RESULT}" = success
+ test "${MACOS_X64_RESULT}" = success
+ test "${MACOS_ARM64_RESULT}" = success
+ test "${LINUX_RESULT}" = success
+
+ publish-prerelease:
+ name: Verify, attest, and publish the complete release candidate
+ if: github.event_name == 'push' && github.ref_type == 'tag' && needs.release-gate.outputs.publish == 'true'
+ needs: [release-gate, windows-release, macos-x64-preview, macos-arm64-preview, linux-preview]
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ artifact-metadata: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
+ with:
+ ref: ${{ needs.release-gate.outputs.build_commit }}
+ fetch-depth: 0
+ - name: Reconfirm the remote tag still names the reviewed commit
+ shell: bash
+ env:
+ EXPECTED_COMMIT: ${{ needs.release-gate.outputs.build_commit }}
+ RELEASE_TAG: ${{ needs.release-gate.outputs.tag }}
+ run: |
+ set -euo pipefail
+ git fetch --force --no-tags origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
+ actual_commit="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")"
+ test "${actual_commit}" = "${EXPECTED_COMMIT}" || {
+ echo "Release tag moved after validation: expected ${EXPECTED_COMMIT}, found ${actual_commit}." >&2
+ exit 1
+ }
+ - name: Download the Windows package set
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
+ with:
+ name: FB2Blogger-Windows-x64-${{ github.run_id }}
+ path: release-artifacts
+ - name: Download the macOS Intel package set
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
+ with:
+ name: FB2Blogger-macOS-x64-${{ github.run_id }}
+ path: release-artifacts
+ - name: Download the macOS Apple Silicon package set
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
+ with:
+ name: FB2Blogger-macOS-arm64-${{ github.run_id }}
+ path: release-artifacts
+ - name: Download the Linux package set
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
+ with:
+ name: FB2Blogger-Linux-x64-${{ github.run_id }}
+ path: release-artifacts
+ - name: Require the exact package inventory and create SHA256SUMS
+ shell: bash
+ env:
+ ARTIFACT_DIR: release-artifacts
+ RELEASE_TAG: ${{ needs.release-gate.outputs.tag }}
+ run: bash packaging/verify-release-assets.sh
+ - name: Require curated four-language release notes
+ shell: bash
+ env:
+ RELEASE_TAG: ${{ needs.release-gate.outputs.tag }}
+ run: |
+ notes="docs/releases/${RELEASE_TAG}.md"
+ test -s "${notes}"
+ grep -Fq '## 繁體中文' "${notes}"
+ grep -Fq '## 简体中文' "${notes}"
+ grep -Fq '## English' "${notes}"
+ grep -Fq '## 日本語' "${notes}"
+ - name: Attest Windows build provenance
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger.exe
+ - name: Bind the Windows SPDX SBOM
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger.exe
+ sbom-path: release-artifacts/FB2Blogger.exe.spdx.json
+ - name: Attest macOS Intel build provenance
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-macOS-x64-Preview.dmg
+ - name: Bind the macOS Intel SPDX SBOM
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-macOS-x64-Preview.dmg
+ sbom-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-macOS-x64-Preview.dmg.spdx.json
+ - name: Attest macOS Apple Silicon build provenance
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-macOS-arm64-Preview.dmg
+ - name: Bind the macOS Apple Silicon SPDX SBOM
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-macOS-arm64-Preview.dmg
+ sbom-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-macOS-arm64-Preview.dmg.spdx.json
+ - name: Attest Linux build provenance
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-Linux-x64-Preview.AppImage
+ - name: Bind the Linux SPDX SBOM
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0
+ with:
+ subject-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-Linux-x64-Preview.AppImage
+ sbom-path: release-artifacts/FB2Blogger-${{ needs.release-gate.outputs.tag }}-Linux-x64-Preview.AppImage.spdx.json
+ - name: Create one atomic four-language GitHub pre-release
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ needs.release-gate.outputs.tag }}
+ run: |
+ set -euo pipefail
+ if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
+ echo "Release ${RELEASE_TAG} already exists; refusing to overwrite it." >&2
+ exit 2
+ fi
+ mapfile -d '' assets < <(find release-artifacts -maxdepth 1 -type f -print0 | LC_ALL=C sort -z)
+ release_id=''
+ published=false
+ cleanup_failed_draft() {
+ status=$?
+ if [[ ${status} -ne 0 && "${published}" != true && -n "${release_id}" ]]; then
+ gh api --method DELETE "repos/${GITHUB_REPOSITORY}/releases/${release_id}" >/dev/null 2>&1 || true
+ fi
+ trap - EXIT
+ exit "${status}"
+ }
+ trap cleanup_failed_draft EXIT
+ gh release create "${RELEASE_TAG}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --verify-tag \
+ --draft \
+ --prerelease \
+ --title "FB2Blogger ${RELEASE_TAG}" \
+ --notes-file "docs/releases/${RELEASE_TAG}.md"
+ release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id')"
+ test -n "${release_id}"
+ gh release upload "${RELEASE_TAG}" "${assets[@]}" --repo "${GITHUB_REPOSITORY}"
+ mapfile -t actual_assets < <(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" --jq '.[].name' | LC_ALL=C sort)
+ mapfile -t expected_assets < <(printf '%s\n' "${assets[@]##*/}" | LC_ALL=C sort)
+ test "$(printf '%s\n' "${actual_assets[@]}")" = "$(printf '%s\n' "${expected_assets[@]}")" || {
+ echo "Draft Release assets differ from the exact reviewed set." >&2
+ exit 3
+ }
+ gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
+ -F draft=false -F prerelease=true >/dev/null
+ test "$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" --jq '.draft == false and .prerelease == true')" = true
+ published=true
+ trap - EXIT
+ - name: Confirm the existing WordPress release hub will synchronize
+ run: echo "::notice::Flameblade Series Gateway already polls this repository's GitHub Releases hourly; no duplicate page-writing secret is required."
diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml
index f01d580..8ab3065 100644
--- a/.github/workflows/security-audit.yml
+++ b/.github/workflows/security-audit.yml
@@ -35,7 +35,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
- dotnet-version: 10.0.x
+ dotnet-version: 10.0.204
- name: Restore and fail on known vulnerable packages
shell: pwsh
diff --git a/.gitignore b/.gitignore
index 2161d9b..fa34e05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
bin/
obj/
artifacts/
+dist/
+release-artifacts/
+_manifest/
.vs/
.vscode/
*.user
@@ -8,6 +11,8 @@ artifacts/
*.pdb
*.zip
*.exe
+*.dmg
+*.AppImage
settings.dat
completed-*.json
completed-*.json.bak
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16f0b6e..6245d48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,19 +4,19 @@
### 繁體中文
-建立不依賴 WinForms 的共享核心,涵蓋模型、Facebook JSON 解析、安全解壓、文章內容組合、搬移進度與平台資料路徑;新增四語 Avalonia 預覽殼及 Windows/macOS/Linux 核心 CI。完整搬家功能目前仍只在 Windows 版提供,未宣稱 macOS/Linux 已完成實機驗證。
+建立不依賴 WinForms 的共享核心、四語 Avalonia 預覽殼與三平台核心 CI;新增 Windows 完整 EXE 真實主視窗啟動檢查、原生 Intel/Apple Silicon macOS DMG、Linux x64 AppImage、各成品可讀 MIT 授權、SHA256、彙總 `SHA256SUMS`、SPDX SBOM,以及只允許 `main` 歷史中精確 RC 標籤發布的四平台原子化 Pre-release 閘門。完整搬家功能目前仍只在 Windows 版提供,未宣稱 macOS/Linux 已完成實機驗證。
### 简体中文
-建立不依赖 WinForms 的共享核心,包含模型、Facebook JSON 解析、安全解压、文章内容组合、迁移进度及平台数据路径;新增四语 Avalonia 预览界面和 Windows/macOS/Linux 核心 CI。完整迁移功能目前仍仅由 Windows 版提供,不声称 macOS/Linux 已完成实机验证。
+建立不依赖 WinForms 的共享核心、四语 Avalonia 预览界面与三平台核心 CI;新增 Windows 完整 EXE 真实主窗口启动检查、原生 Intel/Apple Silicon macOS DMG、Linux x64 AppImage、各成品可读 MIT 许可证、SHA256、汇总 `SHA256SUMS`、SPDX SBOM,以及只允许 `main` 历史中严格 RC 标签发布的四平台原子化 Pre-release 闸门。完整迁移功能目前仍仅由 Windows 版提供,不声称 macOS/Linux 已完成实机验证。
### English
-Adds a WinForms-independent shared core for models, Facebook JSON parsing, safe extraction, post composition, migration state, and platform data paths, plus a four-language Avalonia preview shell and Windows/macOS/Linux core CI. Full migration remains Windows-only and no real Mac or Linux validation is claimed.
+Adds a WinForms-independent shared core, a four-language Avalonia preview, three-platform core CI, a real-main-window smoke test for the full Windows EXE, native Intel/Apple Silicon macOS DMGs, a Linux x64 AppImage, readable MIT licenses inside every artifact, SHA256 plus aggregate `SHA256SUMS`, SPDX SBOMs, and an atomic four-platform pre-release gate restricted to exact RC tags contained in `main` history. Full migration remains Windows-only and no real Mac or Linux validation is claimed.
### 日本語
-モデル、Facebook JSON 解析、安全な展開、記事生成、移行状態、OS 別データ保存先を WinForms 非依存の共通コアへ分離し、4 言語 Avalonia プレビューと Windows/macOS/Linux のコア CI を追加しました。完全な移行機能は引き続き Windows 版のみで、macOS/Linux 実機検証済みとは表現しません。
+WinForms 非依存の共通コア、4 言語 Avalonia Preview、3 プラットフォームのコア CI に加え、Windows 完全版 EXE の実メインウィンドウ起動確認、Intel/Apple Silicon macOS DMG、Linux x64 AppImage、各成果物内の読みやすい MIT License、SHA256 と集約 `SHA256SUMS`、SPDX SBOM、`main` 履歴内の正確な RC タグだけを許可する 4 プラットフォーム原子的 Pre-release ゲートを追加しました。完全な移行機能は引き続き Windows 版のみで、macOS/Linux 実機検証済みとは表現しません。
## v1.0.0 — 2026-08-05
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7fa1142..044da4c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,17 +28,17 @@
## 繁體中文
-歡迎 Issue 與 Pull Request。請先確認問題能在最新版重現,勿附上私人 Facebook ZIP 或憑證。跨平台核心修改請執行 `dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release` 與 Avalonia 預覽殼建置;Windows 完整版另須執行 `dotnet run --project tests/AuditHarness/AuditHarness.csproj -c Release`。PR 請說明問題、做法、使用者影響、測試作業系統與結果;若改變使用者可見行為,請同步更新繁中、簡中、英文、日文文件。CI 通過不可寫成 macOS/Linux 實機驗證。
+歡迎 Issue 與 Pull Request。請先確認問題能在最新版重現,勿附上私人 Facebook ZIP 或憑證。跨平台核心修改請執行 `dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release` 與 Avalonia 預覽殼建置;Windows 完整版另須執行 `dotnet run --project tests/AuditHarness/AuditHarness.csproj -c Release`。封裝或發布變更還須執行 `dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release`,並等待原生 runner 的 DMG/AppImage 啟動檢查。PR 請說明問題、做法、使用者影響、測試作業系統與結果;若改變使用者可見行為,請同步更新繁中、簡中、英文、日文文件。CI 通過不可寫成 macOS/Linux 實機驗證。
## 简体中文
-欢迎提交 Issue 和 Pull Request。请先在最新版复现问题,勿附上私人 Facebook ZIP 或凭证。跨平台核心修改必须运行 CoreHarness 并构建 Avalonia 预览界面;Windows 完整版还必须运行 AuditHarness。PR 请说明问题、解决方式、用户影响、测试系统与结果;影响用户行为时必须同步更新繁中、简中、英文、日文文档。CI 通过不得写成 macOS/Linux 实机验证。
+欢迎提交 Issue 和 Pull Request。请先在最新版复现问题,勿附上私人 Facebook ZIP 或凭证。跨平台核心修改必须运行 CoreHarness 并构建 Avalonia 预览界面;Windows 完整版还必须运行 AuditHarness。打包或发布变更还必须运行 PackagingAudit,并等待原生 runner 的 DMG/AppImage 启动检查。PR 请说明问题、解决方式、用户影响、测试系统与结果;影响用户行为时必须同步更新繁中、简中、英文、日文文档。CI 通过不得写成 macOS/Linux 实机验证。
## English
-Issues and pull requests are welcome. Reproduce the problem on the latest version and never attach private Facebook archives or credentials. Cross-platform core changes must run CoreHarness and build the Avalonia preview; the full Windows edition must also run AuditHarness. A PR should explain the problem, approach, user impact, test operating systems, and results. User-visible changes must update Traditional Chinese, Simplified Chinese, English, and Japanese documentation together. Never describe CI as real macOS or Linux hardware validation.
+Issues and pull requests are welcome. Reproduce the problem on the latest version and never attach private Facebook archives or credentials. Cross-platform core changes must run CoreHarness and build the Avalonia preview; the full Windows edition must also run AuditHarness. Packaging or release changes must run PackagingAudit and wait for native-runner DMG/AppImage launch checks. A PR should explain the problem, approach, user impact, test operating systems, and results. User-visible changes must update Traditional Chinese, Simplified Chinese, English, and Japanese documentation together. Never describe CI as real macOS or Linux hardware validation.
## 日本語
-Issue と Pull Request を歓迎します。最新版で再現を確認し、非公開の Facebook ZIP や認証情報を添付しないでください。共通コアの変更では CoreHarness と Avalonia プレビューのビルドを行い、Windows 完全版では AuditHarness も実行してください。PR には問題、対応方法、利用者への影響、検証した OS と結果を記載し、利用者向けの変更では繁体字中国語・簡体字中国語・英語・日本語の文書を同時に更新してください。CI 成功を macOS/Linux 実機検証と表現してはいけません。
+Issue と Pull Request を歓迎します。最新版で再現を確認し、非公開の Facebook ZIP や認証情報を添付しないでください。共通コアの変更では CoreHarness と Avalonia プレビューのビルドを行い、Windows 完全版では AuditHarness も実行してください。パッケージまたは公開手順の変更では PackagingAudit も実行し、ネイティブ runner の DMG/AppImage 起動確認を待ってください。PR には問題、対応方法、利用者への影響、検証した OS と結果を記載し、利用者向けの変更では繁体字中国語・簡体字中国語・英語・日本語の文書を同時に更新してください。CI 成功を macOS/Linux 実機検証と表現してはいけません。
diff --git a/FB2Blogger.slnx b/FB2Blogger.slnx
index 27d27cb..daa030c 100644
--- a/FB2Blogger.slnx
+++ b/FB2Blogger.slnx
@@ -7,5 +7,6 @@
+
diff --git a/README.en.md b/README.en.md
index 84c4e93..8d52f41 100644
--- a/README.en.md
+++ b/README.en.md
@@ -37,10 +37,10 @@ The app reads a ZIP that you obtained through Facebook's official “Download yo
## Platform status
- **Windows:** the existing full WinForms edition keeps its current features, LocalAppData location, and DPAPI credential protection.
-- **macOS/Linux:** an Avalonia foundation preview can select, safely extract, and inspect a Facebook ZIP offline. Google authorization, media upload, and Blogger publishing are not connected yet, so this is not a production edition.
+- **macOS/Linux:** an Avalonia foundation preview can select, safely extract, and inspect a Facebook ZIP offline. Native CI produces macOS `.dmg` and Linux `.AppImage` preview packages. Google authorization, media upload, and Blogger publishing are not connected, so these are not production editions.
- **Shared foundation:** models, parsing, safe extraction, post-content composition, migration state, and platform data paths now live in `FB2Blogger.Core`, which GitHub Actions builds and tests separately on Windows, macOS, and Linux.
-Passing CI is not evidence of real macOS or Linux hardware validation. The project owner currently has only Windows hardware, so production support still requires documented testing from contributors on those systems. See the [cross-platform foundation document](docs/CROSS-PLATFORM.md) for the exact matrix and security plan.
+Passing CI is not evidence of real macOS or Linux hardware validation. The project owner currently has only Windows hardware, so production support still requires documented testing from contributors on those systems. See the [cross-platform foundation document](docs/CROSS-PLATFORM.md) for the exact matrix and security plan, and the [native preview package guide](docs/PREVIEW-PACKAGES.md) for downloads, hashes, attestations, and launch instructions.
## Requirements
@@ -74,9 +74,10 @@ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
dotnet build src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj -c Release
dotnet build src/FB2Blogger/FB2Blogger.csproj -c Release
dotnet run --project tests/AuditHarness/AuditHarness.csproj -c Release
+dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
```
-.NET 10 SDK is required. Current production releases remain self-contained Windows executables; the Avalonia preview shell is not yet offered as a production download.
+The .NET 10 SDK pinned by `global.json` is required. Production releases remain self-contained Windows executables. A macOS or Linux artifact may be downloadable from Actions or attached to a release candidate only when it is clearly marked Preview; it does not contain the full Windows migration workflow.
## Open source and responsibility
diff --git a/README.ja.md b/README.ja.md
index 1851dfd..2401545 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -37,10 +37,10 @@ Facebook の公式ダウンロードデータに含まれる投稿・画像・
## 3 OS 対応の進捗
- **Windows:**従来の完全版 WinForms アプリは、既存機能、LocalAppData の保存場所、DPAPI による認証情報保護をそのまま維持します。
-- **macOS/Linux:**Avalonia の基礎プレビューでは、Facebook ZIP の選択、安全な展開、オフライン解析まで行えます。Google 認証、メディアのアップロード、Blogger 公開は未接続で、正式版ではありません。
+- **macOS/Linux:**Avalonia の基礎プレビューでは、Facebook ZIP の選択、安全な展開、オフライン解析まで行えます。ネイティブ CI は macOS `.dmg` と Linux `.AppImage` のプレビュー・パッケージを作成します。Google 認証、メディアのアップロード、Blogger 公開は未接続で、正式版ではありません。
- **共通基盤:**モデル、解析、安全な展開、記事内容の生成、移行状態、OS に応じたデータ保存先を `FB2Blogger.Core` に分離し、GitHub Actions で Windows・macOS・Linux ごとにビルドとテストを行います。
-CI 成功は macOS/Linux 実機検証の代わりにはなりません。プロジェクト所有者の手元には現在 Windows PC しかないため、正式対応には各 OS の協力者による実環境テスト記録が必要です。正確な機能表と安全設計は[クロスプラットフォーム基盤文書](docs/CROSS-PLATFORM.md)をご覧ください。
+CI 成功は macOS/Linux 実機検証の代わりにはなりません。プロジェクト所有者の手元には現在 Windows PC しかないため、正式対応には各 OS の協力者による実環境テスト記録が必要です。正確な機能表と安全設計は[クロスプラットフォーム基盤文書](docs/CROSS-PLATFORM.md)、ダウンロード、ハッシュ、来歴証明、起動方法は[ネイティブ・プレビュー・パッケージの手引き](docs/PREVIEW-PACKAGES.md)をご覧ください。
## 必要なもの
@@ -74,9 +74,10 @@ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
dotnet build src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj -c Release
dotnet build src/FB2Blogger/FB2Blogger.csproj -c Release
dotnet run --project tests/AuditHarness/AuditHarness.csproj -c Release
+dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
```
-.NET 10 SDK が必要です。現在の正式 Release は Windows 向け自己完結型の単一 EXE のままで、Avalonia プレビューはまだ正式ダウンロードとして提供しません。
+`global.json` で固定した .NET 10 SDK が必要です。正式 Release は引き続き Windows 向け自己完結型 EXE です。macOS/Linux 成果物を Actions から取得できる場合やリリース候補へ添付する場合も、必ず Preview と明記し、Windows 完全版の移行機能を含まないことを示します。
## ライセンスと責任
diff --git a/README.md b/README.md
index 8df8ce4..3a36ce3 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,10 @@
## 三平台進度
- **Windows:**既有 WinForms 完整版維持原功能、原 LocalAppData 路徑與 DPAPI 憑證保護。
-- **macOS/Linux:**目前是 Avalonia 基礎預覽殼,可離線選擇、安全解壓並解析 Facebook ZIP;Google 授權、媒體上傳與 Blogger 發布尚未接通,還不是正式成品。
+- **macOS/Linux:**目前是 Avalonia 基礎預覽殼,可離線選擇、安全解壓並解析 Facebook ZIP;原生 CI 會產生 macOS `.dmg` 與 Linux `.AppImage` 預覽封裝。Google 授權、媒體上傳與 Blogger 發布尚未接通,仍不是正式成品。
- **共同基礎:**解析、模型、安全解壓、文章內容組合、搬移進度與平台資料路徑已抽成 `FB2Blogger.Core`,由 GitHub Actions 在 Windows、macOS、Linux 分別建置與測試。
-CI 通過不等於 macOS/Linux 實機驗證。專案擁有者目前只有 Windows 電腦,其他平台正式發布前仍需工程師提供真實環境測試。完整功能矩陣與安全計畫請見 [跨平台基礎文件](docs/CROSS-PLATFORM.md)。
+CI 通過不等於 macOS/Linux 實機驗證。專案擁有者目前只有 Windows 電腦,其他平台正式發布前仍需工程師提供真實環境測試。完整功能矩陣與安全計畫請見[跨平台基礎文件](docs/CROSS-PLATFORM.md),下載、雜湊、來源證明與開啟方式請見[原生預覽封裝指南](docs/PREVIEW-PACKAGES.md)。
## 使用前準備
@@ -75,10 +75,11 @@ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
dotnet build src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj -c Release
dotnet build src/FB2Blogger/FB2Blogger.csproj -c Release
dotnet run --project tests/AuditHarness/AuditHarness.csproj -c Release
+dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
dotnet publish src/FB2Blogger/FB2Blogger.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o artifacts
```
-需要 .NET 10 SDK。現有正式 Release 仍提供 Windows 自含式單一 EXE,使用者不必另裝 .NET Runtime;Avalonia 預覽殼尚未列為正式下載成品。
+需要固定於 `global.json` 的 .NET 10 SDK。現有正式 Release 仍提供 Windows 自含式單一 EXE;macOS/Linux 成品即使可由 Actions 下載或隨候選版附上,也必須清楚標示 Preview,且不具備 Windows 完整搬家功能。
## 開源與責任界線
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 6f7cba9..5eac2a1 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -37,10 +37,10 @@
## 三平台进度
- **Windows:**现有 WinForms 完整版保留原功能、原 LocalAppData 路径及 DPAPI 凭证保护。
-- **macOS/Linux:**目前为 Avalonia 基础预览界面,可离线选择、安全解压并解析 Facebook ZIP;Google 授权、媒体上传与 Blogger 发布尚未接通,并非正式成品。
+- **macOS/Linux:**目前为 Avalonia 基础预览界面,可离线选择、安全解压并解析 Facebook ZIP;原生 CI 会生成 macOS `.dmg` 和 Linux `.AppImage` 预览软件包。Google 授权、媒体上传与 Blogger 发布尚未接通,仍非正式成品。
- **共享基础:**解析、模型、安全解压、文章内容组合、迁移进度及平台数据路径已抽离为 `FB2Blogger.Core`,由 GitHub Actions 在 Windows、macOS、Linux 分别构建和测试。
-CI 通过不代表 macOS/Linux 实机验证。项目所有者目前只有 Windows 电脑,其他平台正式发布前仍需工程师提供真实环境测试。完整功能矩阵与安全计划请参阅[跨平台基础文档](docs/CROSS-PLATFORM.md)。
+CI 通过不代表 macOS/Linux 实机验证。项目所有者目前只有 Windows 电脑,其他平台正式发布前仍需工程师提供真实环境测试。完整功能矩阵与安全计划请参阅[跨平台基础文档](docs/CROSS-PLATFORM.md),下载、哈希、来源证明与打开方法请参阅[原生预览软件包指南](docs/PREVIEW-PACKAGES.md)。
## 使用准备
@@ -74,9 +74,10 @@ dotnet run --project tests/CoreHarness/CoreHarness.csproj -c Release
dotnet build src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj -c Release
dotnet build src/FB2Blogger/FB2Blogger.csproj -c Release
dotnet run --project tests/AuditHarness/AuditHarness.csproj -c Release
+dotnet run --project tests/PackagingAudit/PackagingAudit.csproj -c Release
```
-需要 .NET 10 SDK。现有正式 Release 仍提供 Windows 自包含单文件 EXE,普通用户无需安装 .NET Runtime;Avalonia 预览界面尚未作为正式下载成品。
+需要 `global.json` 固定的 .NET 10 SDK。现有正式 Release 仍提供 Windows 自包含单文件 EXE;macOS/Linux 成品即使可从 Actions 下载或附加到候选版本,也必须明确标记为 Preview,且不具备 Windows 完整迁移功能。
## 开源与责任
diff --git a/SECURITY.md b/SECURITY.md
index 3d32dbb..c6c65a0 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,17 +4,17 @@
請勿在公開 Issue 貼上 OAuth Client Secret、Token、Facebook ZIP、私人文章或個人資料。安全漏洞請寄至 `master@flamebladestudio.com.tw`,主旨註明 `FB2Blogger Security`,並附上版本、重現步驟與影響。收到後會先確認、評估修補並在適當時機公開說明。
-僅 GitHub Releases 中由本儲存庫發布、且 SHA256 相符的檔案視為官方成品。Windows 完整版以 DPAPI 保護本機 OAuth 資料;跨平台預覽殼不接收 OAuth 憑證。未來 macOS/Linux 接上完整功能時,必須使用系統金鑰圈,安全儲存不可用時不得退回明文。使用者仍須保護系統帳號與 Google Cloud 設定。
+僅 GitHub Releases 中由本儲存庫發布、且 SHA256 相符的檔案視為官方成品。四平台候選版各附 SPDX SBOM;只有符合精確 RC 標籤、標籤提交位於 `main` 歷史中且所有平台驗證成功時,才會產生 GitHub 來源與 SBOM 證明並發布。PR、一般 `main` push 與手動驗證產物不簽發證明,也不是官方 Release。Windows 完整版以 DPAPI 保護本機 OAuth 資料;跨平台預覽殼不接收 OAuth 憑證。未來 macOS/Linux 接上完整功能時,必須使用系統金鑰圈,安全儲存不可用時不得退回明文。使用者仍須保護系統帳號與 Google Cloud 設定。
## 简体中文
-请勿在公开 Issue 中粘贴 OAuth Client Secret、Token、Facebook ZIP、私人文章或个人信息。安全漏洞请发送至 `master@flamebladestudio.com.tw`,邮件主题注明 `FB2Blogger Security`,并附上版本、复现步骤和影响。仅 GitHub Releases 中由本仓库发布且 SHA256 一致的文件属于官方构建。跨平台预览界面不接收 OAuth 凭证;未来 macOS/Linux 完整版必须使用系统密钥环,不得降级为明文保存。
+请勿在公开 Issue 中粘贴 OAuth Client Secret、Token、Facebook ZIP、私人文章或个人信息。安全漏洞请发送至 `master@flamebladestudio.com.tw`,邮件主题注明 `FB2Blogger Security`,并附上版本、复现步骤和影响。仅 GitHub Releases 中由本仓库发布且 SHA256 一致的文件属于官方构建。四平台候选版分别附带 SPDX SBOM;只有符合严格 RC 标签、标签提交属于 `main` 历史且全部平台验证成功时,才会生成 GitHub 来源与 SBOM 证明并发布。PR、普通 `main` push 与手动验证产物不会签发证明,也不是官方 Release。跨平台预览界面不接收 OAuth 凭证;未来 macOS/Linux 完整版必须使用系统密钥环,不得降级为明文保存。
## English
-Never post OAuth secrets, tokens, Facebook archives, private posts, or personal data in a public issue. Report vulnerabilities to `master@flamebladestudio.com.tw` with the subject `FB2Blogger Security`, including the affected version, reproduction steps, and impact. Only assets published by this repository under GitHub Releases and matching the listed SHA256 are official builds. The cross-platform preview does not accept OAuth credentials. A future full macOS/Linux edition must use the system keyring and must never fall back to plaintext storage.
+Never post OAuth secrets, tokens, Facebook archives, private posts, or personal data in a public issue. Report vulnerabilities to `master@flamebladestudio.com.tw` with the subject `FB2Blogger Security`, including the affected version, reproduction steps, and impact. Only assets published by this repository under GitHub Releases and matching the listed SHA256 are official builds. Every four-platform RC package has an SPDX SBOM. GitHub provenance and SBOM attestations are issued only when an exact RC tag belongs to `main` history and all platform gates pass. Pull requests, ordinary `main` pushes, and manual validation runs remain unattested and are not official releases. The cross-platform preview does not accept OAuth credentials. A future full macOS/Linux edition must use the system keyring and must never fall back to plaintext storage.
## 日本語
-OAuth の秘密情報、トークン、Facebook ZIP、非公開記事、個人情報を公開 Issue に貼らないでください。脆弱性は件名を `FB2Blogger Security` とし、対象バージョン、再現手順、影響を添えて `master@flamebladestudio.com.tw` へ報告してください。GitHub Releases で本リポジトリが公開し、SHA256 が一致するファイルだけが公式ビルドです。クロスプラットフォーム・プレビューは OAuth 情報を受け取りません。将来の macOS/Linux 完全版はシステムキーチェーンを使用し、平文保存へフォールバックしてはいけません。
+OAuth の秘密情報、トークン、Facebook ZIP、非公開記事、個人情報を公開 Issue に貼らないでください。脆弱性は件名を `FB2Blogger Security` とし、対象バージョン、再現手順、影響を添えて `master@flamebladestudio.com.tw` へ報告してください。GitHub Releases で本リポジトリが公開し、SHA256 が一致するファイルだけが公式ビルドです。4 プラットフォームの RC にはそれぞれ SPDX SBOM を添付します。正確な RC タグが `main` の履歴に含まれ、全プラットフォームの検証が成功した場合だけ、GitHub の来歴証明と SBOM 証明を作成して公開します。Pull Request、通常の `main` push、手動検証の成果物には証明を発行せず、公式 Release とも扱いません。クロスプラットフォーム・プレビューは OAuth 情報を受け取りません。将来の macOS/Linux 完全版はシステムキーチェーンを使用し、平文保存へフォールバックしてはいけません。
diff --git a/docs/CROSS-PLATFORM.md b/docs/CROSS-PLATFORM.md
index ccb50f1..1021337 100644
--- a/docs/CROSS-PLATFORM.md
+++ b/docs/CROSS-PLATFORM.md
@@ -8,10 +8,11 @@
|---|---|---|---|
| 共用解析核心 | CI 建置與自動測試 | CI 建置與自動測試 | CI 建置與自動測試 |
| Avalonia 預覽殼 | 可建置;可離線檢查 ZIP | 可建置;可離線檢查 ZIP | 可建置;可離線檢查 ZIP |
+| 原生預覽封裝 | 不取代既有 WinForms 正式版 | Intel/Apple Silicon `.dmg`,內含 `.app` | x64 `.AppImage` |
| 完整 Google 授權、Drive/YouTube 上傳、Blogger 發布 | 既有 WinForms 版完整保留 | 尚未接通 | 尚未接通 |
| 本機憑證保護 | Windows DPAPI,沿用原資料位置 | 規劃接 macOS Keychain | 規劃接 Secret Service/系統金鑰圈 |
-這一階段是「能編譯、能測試、能安全檢查 Facebook ZIP」的跨平台基礎,不是 macOS/Linux 正式成品。專案擁有者目前只有 Windows 電腦,因此 macOS/Linux 的 CI 通過只代表雲端建置與自動測試成功,不能代替真實硬體、桌面環境、檔案選擇器與 OAuth 瀏覽器流程的驗證。
+這一階段是「能編譯、能測試、能安全檢查 Facebook ZIP,並能由原生 runner 產製與啟動檢查封裝」的跨平台基礎,不是 macOS/Linux 正式成品。專案擁有者目前只有 Windows 電腦,因此 macOS/Linux 的 CI 通過只代表雲端建置、封裝與自動檢查成功,不能代替真實硬體、桌面環境、檔案選擇器與 OAuth 瀏覽器流程的驗證。封裝細節請見 [PREVIEW-PACKAGES.md](PREVIEW-PACKAGES.md)。
### 為什麼保留兩個桌面入口
@@ -23,17 +24,17 @@
1. macOS 使用 Keychain、Linux 使用 Secret Service 或相容系統金鑰圈;若安全儲存不可用就停止設定,不得退回明文。
2. Google OAuth 回呼、預設瀏覽器開啟、Drive/YouTube/Blogger 實際帳號流程需在各平台真人驗證。
-3. Windows 完整回歸測試、三平台核心測試與三平台 Avalonia 建置全部通過後,才可建立候選版安裝檔。
-4. macOS 未簽名套件與 Linux 發行格式必須在 Release 說明中清楚揭露限制。
+3. 離線預覽封裝必須通過三平台核心測試、封裝稽核、原生掛載/解開與封裝內執行檔啟動檢查;接上完整搬家功能的候選版仍須先完成 Windows 完整回歸與各平台真人驗證。
+4. macOS 未使用 Developer ID 簽章與公證、Linux AppImage 相容範圍、SHA256、SBOM、來源證明及未實機驗證狀態,必須在 Release 說明中清楚揭露。
## 简体中文
-当前的共享核心会在 Windows、macOS、Linux 的 CI 中构建并执行自动测试;Avalonia 预览界面也会在三平台构建,并可离线选择、解压和检查 Facebook ZIP。完整的 Google 授权、Drive/YouTube 上传与 Blogger 发布仍只在既有 Windows WinForms 版提供。macOS 将使用 Keychain,Linux 将使用 Secret Service 或系统密钥环;安全存储不可用时不得降级为明文。CI 通过不等于真实 Mac 或 Linux 设备验证,正式发布前仍需要社区工程师提供真实环境测试证据。
+当前的共享核心会在 Windows、macOS、Linux 的 CI 中构建并执行自动测试;原生 runner 还会生成并启动检查 Intel/Apple Silicon macOS `.dmg` 与 Linux x64 `.AppImage`。这些软件包仅包含离线 ZIP 检查功能。完整的 Google 授权、Drive/YouTube 上传与 Blogger 发布仍只在既有 Windows WinForms 版提供。macOS 将使用 Keychain,Linux 将使用 Secret Service 或系统密钥环;安全存储不可用时不得降级为明文。CI 通过不等于真实 Mac 或 Linux 设备验证,正式支持前仍需要社区工程师提供真实环境测试证据。
## English
-The shared core is built and automatically tested on Windows, macOS, and Linux. The Avalonia preview shell also builds on all three platforms and can select, safely extract, and inspect a Facebook ZIP offline. Full Google authorization, Drive/YouTube upload, and Blogger publishing remain available only in the existing Windows WinForms edition. The next stage must use Keychain on macOS and Secret Service or an equivalent system keyring on Linux; it must never fall back to plaintext secrets. Passing CI is not evidence of real Mac or Linux hardware validation, so release readiness still requires documented testing by contributors on those systems.
+The shared core is built and automatically tested on Windows, macOS, and Linux. Native runners also create and launch-smoke-test Intel and Apple Silicon macOS `.dmg` files and a Linux x64 `.AppImage`. These packages contain only offline ZIP inspection. Full Google authorization, Drive/YouTube upload, and Blogger publishing remain available only in the existing Windows WinForms edition. The next stage must use Keychain on macOS and Secret Service or an equivalent system keyring on Linux; it must never fall back to plaintext secrets. Passing CI is not evidence of real Mac or Linux hardware validation, so production support still requires documented testing by contributors on those systems.
## 日本語
-共通コアは Windows・macOS・Linux の CI でビルドと自動テストを行います。Avalonia のプレビュー画面も 3 OS でビルドし、Facebook ZIP の選択、安全な展開、オフライン解析まで確認できます。Google 認証、Drive/YouTube へのアップロード、Blogger 公開の完全な機能は、現時点では従来の Windows WinForms 版のみです。次の段階では macOS は Keychain、Linux は Secret Service または同等のシステムキーチェーンを使用し、平文保存へは絶対にフォールバックしません。CI 成功は実機検証ではないため、正式公開前に各 OS の協力者による実環境テスト記録が必要です。
+共通コアは Windows・macOS・Linux の CI でビルドと自動テストを行います。ネイティブ runner は Intel/Apple Silicon macOS `.dmg` と Linux x64 `.AppImage` も作成し、パッケージ内実行ファイルの起動確認を行います。これらはオフライン ZIP 検査だけを含む Preview です。Google 認証、Drive/YouTube へのアップロード、Blogger 公開の完全な機能は、現時点では従来の Windows WinForms 版のみです。次の段階では macOS は Keychain、Linux は Secret Service または同等のシステムキーチェーンを使用し、平文保存へは絶対にフォールバックしません。CI 成功は実機検証ではないため、正式対応前に各 OS の協力者による実環境テスト記録が必要です。
diff --git a/docs/PREVIEW-PACKAGES.md b/docs/PREVIEW-PACKAGES.md
new file mode 100644
index 0000000..c72dc6c
--- /dev/null
+++ b/docs/PREVIEW-PACKAGES.md
@@ -0,0 +1,99 @@
+# Native preview packages / 原生預覽封裝 / 原生预览软件包 / ネイティブ・プレビュー・パッケージ
+
+## 繁體中文
+
+### 下載內容與功能邊界
+
+- Windows 提供完整功能的自含式 x64 `FB2Blogger.exe`;候選版發布前會在原生 Windows runner 實際開啟主視窗並關閉,確認封裝不是只能建置、不能啟動。
+- macOS 提供 Intel x64 與 Apple Silicon arm64 兩個未使用 Apple Developer ID 簽章、未公證的 Preview `.dmg`,每個映像都內含真正的 `.app`。
+- Linux 提供 x64 Preview `.AppImage`。它是 AppImage 規格的可執行檔,不是改副檔名的 ZIP。
+- 每個成品都附可直接閱讀的 MIT 授權全文:Windows 放在 EXE 旁,macOS 放在 DMG 根目錄與 `.app/Contents/Resources`,Linux 放在 AppImage 的標準文件目錄。
+- 兩種預覽版都只提供離線選擇、安全解壓與解析本人從 Facebook 官方取得的 ZIP。Google OAuth、Drive/YouTube 上傳與 Blogger 發布仍只存在於 Windows 完整版。
+- 預覽版不會要求或保存 OAuth 憑證;請勿把 Client Secret 或 Token 輸入預覽版。
+
+### 自動驗證與可追溯性
+
+GitHub Actions 分別在原生 Windows、`macos-15-intel`、Apple Silicon `macos-15` 與 `ubuntu-24.04` runner 建置封裝。Pull Request、一般 `main` push 與手動執行只做唯讀驗證,不建立 Release。只有格式精確為 `v1.1.0-rc.N`(N 大於零)、且標籤提交確實位於 `origin/main` 歷史中的 tag,才可進入發布閘門;四個平台必須全部成功,並通過精確檔案清單、可讀 MIT 授權、個別 SHA256、彙總 `SHA256SUMS`、SPDX SBOM 與 GitHub 來源/SBOM 證明後,才建立單一四語 Pre-release。
+
+候選版建立後,官網既有的 Flameblade Series Gateway 會依 `products.json` 每小時讀取 GitHub Releases;不另設重複的頁面覆寫程式,也不需要把 WordPress 帳密交給本儲存庫。
+
+```bash
+# macOS
+shasum -a 256 -c FB2Blogger-*.dmg.sha256
+
+# Linux
+sha256sum -c FB2Blogger-*.AppImage.sha256
+
+# 可信任工作流程的 GitHub 證明
+gh attestation verify FB2Blogger-*.dmg --repo hitoshic1982/FB2Blogger
+gh attestation verify FB2Blogger-*.AppImage --repo hitoshic1982/FB2Blogger
+```
+
+### 開啟方式與實機狀態
+
+macOS 使用者請選擇符合處理器的 DMG,將 App 拖入「應用程式」,再按住 Control 點選 App 並選擇「打開」。因為尚未購買 Apple Developer ID,也未經 Apple 公證,系統會顯示安全提醒;請核對 SHA256,不要關閉整部電腦的安全防護。Linux 使用者核對雜湊後執行 `chmod +x 檔名.AppImage`;缺少 FUSE 時可用 `APPIMAGE_EXTRACT_AND_RUN=1 ./檔名.AppImage`。
+
+原生 runner 的啟動檢查證明封裝可建置、可掛載/解開並能啟動,但不等於專案擁有者已在真實 Mac 或 Linux 桌面環境驗證檔案選擇器、字型、視窗管理與長時間操作。請回報作業系統版本、CPU、桌面環境、測試步驟與結果;在累積真實證據前,這些成品一律維持 **Preview**。
+
+## 简体中文
+
+### 下载内容与功能边界
+
+- Windows 提供完整功能的自包含 x64 `FB2Blogger.exe`;候选版发布前会在原生 Windows runner 中实际打开并关闭主窗口,确认软件包能够真正启动。
+- macOS 提供 Intel x64 与 Apple Silicon arm64 两个未使用 Apple Developer ID 签名、未公证的 Preview `.dmg`,每个映像都包含真正的 `.app`。
+- Linux 提供 x64 Preview `.AppImage`,它是符合 AppImage 规范的可执行文件,不是修改扩展名的 ZIP。
+- 每个成品都附带可直接阅读的 MIT 许可证全文:Windows 放在 EXE 旁,macOS 放在 DMG 根目录与 `.app/Contents/Resources`,Linux 放在 AppImage 的标准文档目录。
+- 两种预览版都只支持离线选择、安全解压和解析本人从 Facebook 官方取得的 ZIP。Google OAuth、Drive/YouTube 上传与 Blogger 发布仍仅由 Windows 完整版提供。
+- 预览版不会要求或保存 OAuth 凭据;请勿把 Client Secret 或 Token 输入预览版。
+
+### 自动验证与可追溯性
+
+GitHub Actions 分别在原生 Windows、`macos-15-intel`、Apple Silicon `macos-15` 与 `ubuntu-24.04` runner 构建软件包。Pull Request、普通 `main` push 与手动运行只进行只读验证,不建立 Release。只有格式严格为 `v1.1.0-rc.N`(N 大于零)、且标签提交属于 `origin/main` 历史的 tag 才能进入发布闸门;四个平台必须全部成功,并通过精确文件清单、可读 MIT 许可证、单独 SHA256、汇总 `SHA256SUMS`、SPDX SBOM 与 GitHub 来源/SBOM 证明后,才建立一个四语 Pre-release。
+
+候选版建立后,官网现有的 Flameblade Series Gateway 会按照 `products.json` 每小时读取 GitHub Releases;不另建重复的页面覆写程序,也不需要把 WordPress 凭据交给本仓库。
+
+macOS 请先核对 SHA256,选择与处理器一致的 DMG,将 App 拖入“应用程序”,再按住 Control 点击 App 并选择“打开”。由于没有 Apple Developer ID 签名和公证,系统会显示安全提醒;请勿关闭整台电脑的安全防护。Linux 核对哈希后运行 `chmod +x 文件名.AppImage`;缺少 FUSE 时可使用 `APPIMAGE_EXTRACT_AND_RUN=1 ./文件名.AppImage`。
+
+原生 runner 的启动检查不等于真实 Mac 或 Linux 桌面实机验证。请在反馈中注明系统版本、CPU、桌面环境、测试步骤和结果;在取得足够真实证据之前,这些软件包始终标记为 **Preview**。
+
+## English
+
+### Downloads and functional boundary
+
+- Windows receives the self-contained full-featured x64 `FB2Blogger.exe`. Before an RC can publish, a native Windows runner must open and close the real main window to prove that the packaged application actually starts.
+- macOS receives separate Intel x64 and Apple Silicon arm64 Preview `.dmg` files. Each disk image contains a real `.app`; it is not signed with an Apple Developer ID and is not notarized.
+- Linux receives an x64 Preview `.AppImage` that follows the AppImage format. It is not a ZIP with a renamed extension.
+- Every artifact carries the readable MIT license: beside the Windows EXE, at the DMG root and inside `.app/Contents/Resources` on macOS, and in the standard AppImage documentation directory on Linux.
+- Both previews only select, safely extract, and parse an official Facebook export ZIP offline. Google OAuth, Drive/YouTube upload, and Blogger publishing remain exclusive to the full Windows edition.
+- The previews neither request nor store OAuth credentials. Never enter a Client Secret or token into a preview build.
+
+### Automated evidence and traceability
+
+GitHub Actions builds on native Windows, `macos-15-intel`, Apple Silicon `macos-15`, and `ubuntu-24.04` runners. Pull requests, ordinary `main` pushes, and manual runs are read-only validation and never create a release. Publication is allowed only for an exact `v1.1.0-rc.N` tag where N is greater than zero and the tagged commit is contained in `origin/main`. All four platform packages must succeed and pass exact-inventory, readable-MIT-license, individual SHA256, aggregate `SHA256SUMS`, SPDX SBOM, and GitHub provenance/SBOM attestation checks before one curated four-language pre-release is created.
+
+After publication, the existing Flameblade Series Gateway reads GitHub Releases hourly according to `products.json`. This repository does not duplicate that authority with a page-overwrite script and does not require WordPress credentials.
+
+On macOS, verify SHA256, choose the DMG matching the processor, drag the app to Applications, then Control-click the app and choose Open. Gatekeeper warns because there is no Apple Developer ID signature or notarization; do not disable system-wide security controls. On Linux, verify the hash and run `chmod +x filename.AppImage`. If FUSE is unavailable, use `APPIMAGE_EXTRACT_AND_RUN=1 ./filename.AppImage`.
+
+A native-runner launch check proves that the package can be built, mounted or extracted, and started. It is not evidence that the project owner tested file pickers, fonts, window management, or long-running use on real Mac or Linux hardware. Reports should include the OS version, CPU, desktop environment, steps, and outcome. These packages remain **Preview** until enough real-world evidence exists.
+
+## 日本語
+
+### 配布内容と機能の境界
+
+- Windows では完全機能の自己完結型 x64 `FB2Blogger.exe` を提供します。RC 公開前にネイティブ Windows runner で実際のメインウィンドウを開いて閉じ、パッケージが本当に起動することを確認します。
+- macOS では Intel x64 と Apple Silicon arm64 向けに、Apple Developer ID 署名および公証を行っていない Preview `.dmg` を個別に作成します。各ディスクイメージには実際の `.app` が入ります。
+- Linux では x64 Preview `.AppImage` を作成します。拡張子だけを変更した ZIP ではなく、AppImage 形式の実行ファイルです。
+- すべての成果物に読みやすい MIT License 全文を同梱します。Windows は EXE の隣、macOS は DMG 直下と `.app/Contents/Resources`、Linux は AppImage の標準ドキュメント場所です。
+- どちらのプレビューも、Facebook 公式エクスポート ZIP の選択、安全な展開、オフライン解析だけに対応します。Google OAuth、Drive/YouTube へのアップロード、Blogger 公開は引き続き Windows 完全版のみです。
+- プレビュー版は OAuth 認証情報を要求も保存もしません。Client Secret やトークンを入力しないでください。
+
+### 自動検証と追跡可能性
+
+GitHub Actions は、ネイティブ Windows、`macos-15-intel`、Apple Silicon `macos-15`、`ubuntu-24.04` runner でパッケージを作成します。Pull Request、通常の `main` push、手動実行は読み取り専用の検証だけを行い、Release を作成しません。公開できるのは、N が 0 より大きい正確な `v1.1.0-rc.N` タグで、タグのコミットが `origin/main` の履歴に含まれる場合だけです。4 プラットフォームすべてが成功し、正確なファイル一覧、読みやすい MIT License、個別 SHA256、集約 `SHA256SUMS`、SPDX SBOM、GitHub の来歴/SBOM 証明を通過した後に、4 言語の Pre-release を 1 件だけ作成します。
+
+公開後は、既存の Flameblade Series Gateway が `products.json` に基づいて GitHub Releases を 1 時間ごとに読み取ります。重複するページ上書き処理は作らず、このリポジトリに WordPress 認証情報も要求しません。
+
+macOS では SHA256 を確認し、CPU に合う DMG を選び、App を「アプリケーション」へ移動してください。その後 Control キーを押しながら App をクリックし、「開く」を選びます。Apple Developer ID 署名と公証がないため警告が出ますが、OS 全体の安全機能は無効にしないでください。Linux ではハッシュ確認後に `chmod +x ファイル名.AppImage` を実行します。FUSE がない環境では `APPIMAGE_EXTRACT_AND_RUN=1 ./ファイル名.AppImage` を使用できます。
+
+ネイティブ runner の起動確認は、ビルド、マウント/展開、起動が可能であることを示しますが、プロジェクト所有者が実機 Mac/Linux でファイル選択、フォント、ウィンドウ管理、長時間動作を検証した証拠ではありません。報告には OS、CPU、デスクトップ環境、手順、結果を記載してください。十分な実環境証拠が集まるまで、配布物は **Preview** のままです。
diff --git a/docs/RELEASE-PROCESS.md b/docs/RELEASE-PROCESS.md
index 01abbd3..6801bbf 100644
--- a/docs/RELEASE-PROCESS.md
+++ b/docs/RELEASE-PROCESS.md
@@ -29,31 +29,35 @@
## 繁體中文
1. 確認版本、四語文件、變更紀錄與官網同步內容。
-2. 在乾淨工作樹執行完整建置、CoreHarness、AuditHarness、Windows 封裝、NuGet 弱點稽核與 Gitleaks。
+2. 在乾淨工作樹執行完整建置、CoreHarness、AuditHarness、PackagingAudit、Windows 封裝、原生預覽封裝啟動檢查、NuGet 弱點稽核與 Gitleaks。
3. 由 Pull Request 合併,不直接推送受保護的 `main`。
-4. 等待 Cross-platform CI、CodeQL、Dependency Review、Security Audit 與 Secret Defense 全部通過。
-5. Release 只附上可追溯至標籤提交的自動建置產物與雜湊;沒有實機證據的平台只標示預覽。
+4. 等待固定名稱的 `Required - All platform packages` 聚合檢查,以及 Cross-platform CI、CodeQL、Dependency Review、Security Audit 與 Secret Defense 全部通過;聚合檢查同時要求 Windows、Mac Intel、Mac Apple Silicon、Linux 封裝成功,可直接列入 `main` required checks。
+5. `v1.1.0-rc.N` 候選版只接受 N 大於零、且標籤提交位於 `origin/main` 歷史中的 tag;PR、一般 `main` push 與手動工作流程只驗證,不發布。
+6. Windows 完整 EXE、Mac Intel/Apple Silicon DMG、Linux x64 AppImage 必須在同一工作流程全部成功,再核對精確清單、各成品內可讀的 MIT 授權、個別 SHA256、彙總 `SHA256SUMS`、SPDX SBOM、四語說明與 GitHub 證明後,原子化建立 Pre-release。官網由既有 Flameblade Series Gateway 每小時同步 GitHub Releases,不另寫一次性頁面覆蓋程式。
## 简体中文
1. 确认版本、四语文档、变更记录与官网同步内容。
-2. 在干净工作树运行完整构建、CoreHarness、AuditHarness、Windows 打包、NuGet 漏洞审计与 Gitleaks。
+2. 在干净工作树运行完整构建、CoreHarness、AuditHarness、PackagingAudit、Windows 打包、原生预览软件包启动检查、NuGet 漏洞审计与 Gitleaks。
3. 通过 Pull Request 合并,不直接推送受保护的 `main`。
-4. 等待 Cross-platform CI、CodeQL、Dependency Review、Security Audit 与 Secret Defense 全部通过。
-5. Release 只附上可追溯到标签提交的自动构建产物与哈希;没有实机证据的平台只标示预览。
+4. 等待固定名称的 `Required - All platform packages` 聚合检查,以及 Cross-platform CI、CodeQL、Dependency Review、Security Audit 与 Secret Defense 全部通过;聚合检查同时要求 Windows、Mac Intel、Mac Apple Silicon、Linux 打包成功,可直接加入 `main` required checks。
+5. `v1.1.0-rc.N` 候选版只接受 N 大于零、且标签提交属于 `origin/main` 历史的 tag;PR、普通 `main` push 与手动工作流只验证,不发布。
+6. Windows 完整 EXE、Mac Intel/Apple Silicon DMG、Linux x64 AppImage 必须在同一工作流中全部成功,再核对精确清单、各成品内可读的 MIT 许可证、单独 SHA256、汇总 `SHA256SUMS`、SPDX SBOM、四语说明与 GitHub 证明后,原子化建立 Pre-release。官网由现有 Flameblade Series Gateway 每小时同步 GitHub Releases,不另写一次性页面覆盖程序。
## English
1. Confirm the version, four-language documentation, changelog, and official-site synchronization.
-2. From a clean worktree, run the full build, CoreHarness, AuditHarness, Windows publish, NuGet vulnerability audit, and Gitleaks.
+2. From a clean worktree, run the full build, CoreHarness, AuditHarness, PackagingAudit, Windows publish, native preview package launch checks, NuGet vulnerability audit, and Gitleaks.
3. Merge through a pull request; never push directly to protected `main`.
-4. Wait for Cross-platform CI, CodeQL, Dependency Review, Security Audit, and Secret Defense to pass.
-5. Attach only automated artifacts and hashes traceable to the tagged commit. Label a platform as preview when real-hardware evidence is unavailable.
+4. Wait for the stable `Required - All platform packages` aggregate check, Cross-platform CI, CodeQL, Dependency Review, Security Audit, and Secret Defense. The aggregate requires Windows, Intel Mac, Apple Silicon Mac, and Linux packages and is suitable for the `main` required-check rule.
+5. A `v1.1.0-rc.N` candidate requires N greater than zero and a tagged commit contained in `origin/main`; pull requests, ordinary `main` pushes, and manual runs validate only and never publish.
+6. The full Windows EXE, Intel and Apple Silicon Mac DMGs, and Linux x64 AppImage must all succeed in one workflow. Only after exact-inventory, readable MIT license payloads, individual SHA256, aggregate `SHA256SUMS`, SPDX SBOM, four-language notes, and GitHub attestation checks may the workflow atomically create a pre-release. The existing Flameblade Series Gateway synchronizes GitHub Releases hourly; do not add a one-off page-overwrite path.
## 日本語
1. バージョン、4 言語の文書、変更履歴、公式サイトの同期内容を確認します。
-2. クリーンなワークツリーで、全体ビルド、CoreHarness、AuditHarness、Windows 配布ビルド、NuGet 脆弱性監査、Gitleaks を実行します。
+2. クリーンなワークツリーで、全体ビルド、CoreHarness、AuditHarness、PackagingAudit、Windows 配布ビルド、ネイティブ Preview パッケージの起動確認、NuGet 脆弱性監査、Gitleaks を実行します。
3. 保護された `main` へ直接 push せず、Pull Request を通じてマージします。
-4. Cross-platform CI、CodeQL、Dependency Review、Security Audit、Secret Defense がすべて成功するまで待ちます。
-5. タグ付きコミットへ追跡できる自動生成物とハッシュだけを Release に添付し、実機証拠がないプラットフォームはプレビューと明記します。
+4. 固定名の `Required - All platform packages` 集約チェック、Cross-platform CI、CodeQL、Dependency Review、Security Audit、Secret Defense がすべて成功するまで待ちます。この集約は Windows、Intel Mac、Apple Silicon Mac、Linux の全パッケージを要求し、`main` の required checks に登録できます。
+5. `v1.1.0-rc.N` 候補では N が 0 より大きく、タグのコミットが `origin/main` の履歴に含まれていることを必須とします。Pull Request、通常の `main` push、手動実行は検証だけを行い、公開しません。
+6. Windows 完全版 EXE、Intel/Apple Silicon Mac DMG、Linux x64 AppImage が同一ワークフローですべて成功し、正確な一覧、各成果物の読みやすい MIT License、個別 SHA256、集約 `SHA256SUMS`、SPDX SBOM、4 言語の説明、GitHub 証明を確認した後にだけ、Pre-release を原子的に作成します。公式サイトは既存の Flameblade Series Gateway が GitHub Releases を 1 時間ごとに同期し、単発のページ上書き処理は追加しません。
diff --git a/docs/releases/v1.1.0-rc.1.md b/docs/releases/v1.1.0-rc.1.md
new file mode 100644
index 0000000..cd70144
--- /dev/null
+++ b/docs/releases/v1.1.0-rc.1.md
@@ -0,0 +1,45 @@
+# FB2Blogger v1.1.0-rc.1
+
+## 繁體中文
+
+這是 FB2Blogger 首個三平台候選版,也是炎劍文化工作室「讓創作者把數位資產帶回自己手上」的重要一步。
+
+- **Windows x64:**提供完整搬家功能,可解析 Facebook 官方匯出 ZIP、處理媒體並發布至 Blogger。正式 EXE 會在 Windows 原生 runner 實際開啟主視窗後才允許發布。
+- **macOS Intel/Apple Silicon:**提供原生 DMG 預覽版,可離線選擇、安全解壓與檢查 Facebook ZIP;尚未連接 Google 授權、上傳與 Blogger 發布。
+- **Linux x64:**提供原生 AppImage 預覽版,功能界線與 macOS 預覽版相同。
+- 四個平台產物必須在同一次工作流程全部成功,並通過可讀 MIT 授權、SHA256、SPDX SBOM、來源證明及精確檔案清單檢查,才會建立這個 Pre-release。
+
+macOS 套件尚未使用 Apple Developer ID 簽章與公證;Linux AppImage 也仍需更多真實桌面環境驗證。CI 成功不代表已完成所有實機相容性驗證,歡迎工程師回報測試結果或提出 PR。
+
+## 简体中文
+
+这是 FB2Blogger 首个三平台候选版,也是炎剑文化工作室帮助创作者取回数字资产自主权的重要一步。
+
+- **Windows x64:**提供完整迁移功能,可解析 Facebook 官方导出 ZIP、处理媒体并发布到 Blogger。正式 EXE 必须在 Windows 原生 runner 中实际打开主窗口后才允许发布。
+- **macOS Intel/Apple Silicon:**提供原生 DMG 预览版,可离线选择、安全解压和检查 Facebook ZIP;尚未接入 Google 授权、上传与 Blogger 发布。
+- **Linux x64:**提供原生 AppImage 预览版,功能边界与 macOS 预览版相同。
+- 四个平台产物必须在同一次工作流中全部成功,并通过可读 MIT 许可证、SHA256、SPDX SBOM、来源证明与精确文件清单检查,才会建立这个 Pre-release。
+
+macOS 软件包尚未使用 Apple Developer ID 签名与公证;Linux AppImage 也仍需更多真实桌面环境验证。CI 通过不等于已完成全部实机兼容性验证,欢迎工程师提供测试结果或 Pull Request。
+
+## English
+
+This is FB2Blogger's first three-platform release candidate and another step in Flameblade Studio's effort to help creators regain control of their digital work.
+
+- **Windows x64:** the full migration workflow parses an official Facebook export ZIP, processes media, and publishes to Blogger. The release EXE must open its real main window on a native Windows runner before publication is allowed.
+- **macOS Intel and Apple Silicon:** native DMG previews can select, safely extract, and inspect a Facebook ZIP offline. Google authorization, uploads, and Blogger publishing are not connected yet.
+- **Linux x64:** a native AppImage preview with the same functional boundary as the macOS preview.
+- All four platform packages must succeed in the same workflow and pass readable-MIT-license, exact-inventory, SHA256, SPDX SBOM, and provenance checks before this pre-release can be created.
+
+The macOS packages are not signed or notarized with an Apple Developer ID, and the Linux AppImage still needs broader real-desktop validation. Passing CI is not a claim of complete hardware compatibility. Contributor test reports and pull requests are welcome.
+
+## 日本語
+
+これは FB2Blogger 初の 3 プラットフォーム向けリリース候補であり、クリエイターが自分のデジタル資産を取り戻せるようにする Flameblade Studio の取り組みにおける重要な一歩です。
+
+- **Windows x64:**Facebook 公式エクスポート ZIP の解析、メディア処理、Blogger への公開を含む完全な移行機能です。公開前に、ネイティブ Windows runner 上で正式 EXE が実際のメインウィンドウを開くことを確認します。
+- **macOS Intel/Apple Silicon:**ネイティブ DMG Preview で、Facebook ZIP の選択、安全な展開、オフライン確認ができます。Google 認証、アップロード、Blogger 公開はまだ接続していません。
+- **Linux x64:**macOS Preview と同じ機能範囲を持つネイティブ AppImage Preview です。
+- 同一ワークフローで 4 プラットフォームすべてが成功し、読みやすい MIT License、正確なファイル一覧、SHA256、SPDX SBOM、来歴証明を通過した場合に限り、この Pre-release を作成します。
+
+macOS パッケージは Apple Developer ID による署名・公証を行っていません。Linux AppImage も、さらに多くの実デスクトップ環境での検証が必要です。CI 成功を完全な実機互換性の証明とは表現しません。テスト報告や Pull Request を歓迎します。
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..59df384
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.204",
+ "rollForward": "disable",
+ "allowPrerelease": false
+ }
+}
diff --git a/packaging/PREVIEW-NOTICE.txt b/packaging/PREVIEW-NOTICE.txt
new file mode 100644
index 0000000..2998705
--- /dev/null
+++ b/packaging/PREVIEW-NOTICE.txt
@@ -0,0 +1,13 @@
+FB2Blogger CROSS-PLATFORM PREVIEW / 跨平台預覽 / 跨平台预览 / クロスプラットフォーム・プレビュー
+
+繁體中文
+這是離線檢查 Facebook 官方匯出 ZIP 的預覽版,不是完整搬家工具。它可以選擇 ZIP、安全解壓並解析貼文;Google OAuth、Drive/YouTube 上傳與 Blogger 發布尚未接通。請勿輸入 OAuth 憑證。此套件由原生 GitHub runner 自動建置與啟動檢查,但尚未由專案擁有者在真實 Mac/Linux 裝置驗證。
+
+简体中文
+这是用于离线检查 Facebook 官方导出 ZIP 的预览版,不是完整迁移工具。它可以选择 ZIP、安全解压并解析帖子;Google OAuth、Drive/YouTube 上传与 Blogger 发布尚未接通。请勿输入 OAuth 凭据。此软件包由原生 GitHub runner 自动构建并执行启动检查,但项目所有者尚未在真实 Mac/Linux 设备上验证。
+
+English
+This is a preview for inspecting an official Facebook export ZIP offline, not the full migration application. It can select a ZIP, extract it safely, and parse posts. Google OAuth, Drive/YouTube upload, and Blogger publishing are not connected. Do not enter OAuth credentials. A native GitHub runner builds and launch-smoke-tests this package, but the project owner has not validated it on real Mac or Linux hardware.
+
+日本語
+これは Facebook 公式エクスポート ZIP をオフラインで確認するためのプレビュー版であり、完全な移行アプリではありません。ZIP の選択、安全な展開、投稿解析には対応しますが、Google OAuth、Drive/YouTube へのアップロード、Blogger 公開は未接続です。OAuth 認証情報を入力しないでください。このパッケージは各 OS の GitHub runner で自動ビルドと起動確認を行いますが、プロジェクト所有者による実機 Mac/Linux 検証はまだ完了していません。
diff --git a/packaging/generate-sbom.sh b/packaging/generate-sbom.sh
new file mode 100755
index 0000000..623ba48
--- /dev/null
+++ b/packaging/generate-sbom.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+publish_dir="${PUBLISH_DIR:?PUBLISH_DIR is required}"
+output_dir="${OUTPUT_DIR:?OUTPUT_DIR is required}"
+package_file_name="${PACKAGE_FILE_NAME:?PACKAGE_FILE_NAME is required}"
+app_version="${APP_VERSION:?APP_VERSION is required}"
+platform_name="${PLATFORM_NAME:?PLATFORM_NAME is required}"
+runner_temp="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
+tool_version="4.1.5"
+
+if [[ ! -d "${publish_dir}" ]]; then
+ echo "Published application directory does not exist: ${publish_dir}" >&2
+ exit 2
+fi
+
+tool_dir="${runner_temp}/fb2blogger-sbom-tool-${platform_name}"
+manifest_dir="$(mktemp -d "${runner_temp}/fb2blogger-sbom.XXXXXX")"
+trap 'rm -rf "${manifest_dir}"' EXIT
+rm -rf "${tool_dir}"
+dotnet tool install Microsoft.Sbom.DotNetTool \
+ --tool-path "${tool_dir}" \
+ --version "${tool_version}"
+
+"${tool_dir}/sbom-tool" generate \
+ -b "${publish_dir}" \
+ -bc "${GITHUB_WORKSPACE:-$(pwd)}" \
+ -pn "FB2Blogger ${platform_name} Preview" \
+ -pv "${app_version}" \
+ -ps "Flameblade Studio" \
+ -nsb "https://github.com/hitoshic1982/FB2Blogger" \
+ -m "${manifest_dir}"
+
+manifest_list="${manifest_dir}/manifest-list.txt"
+find "${manifest_dir}" -type f -name 'manifest.spdx.json' -print > "${manifest_list}"
+manifest_count="$(wc -l < "${manifest_list}" | tr -d ' ')"
+if [[ "${manifest_count}" != "1" ]]; then
+ echo "Expected one SPDX manifest, found ${manifest_count}." >&2
+ exit 3
+fi
+manifest_path="$(sed -n '1p' "${manifest_list}")"
+
+mkdir -p "${output_dir}"
+sbom_path="${output_dir}/${package_file_name}.spdx.json"
+cp "${manifest_path}" "${sbom_path}"
+python3 -c 'import json,sys; json.load(open(sys.argv[1], encoding="utf-8"))' "${sbom_path}"
+if [[ "$(wc -c < "${sbom_path}")" -ge 16777216 ]]; then
+ echo "SBOM exceeds the GitHub attestation size limit." >&2
+ exit 4
+fi
+
+echo "Created ${sbom_path} with Microsoft.Sbom.DotNetTool ${tool_version}"
diff --git a/packaging/linux/build-appimage.sh b/packaging/linux/build-appimage.sh
new file mode 100755
index 0000000..30f1752
--- /dev/null
+++ b/packaging/linux/build-appimage.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "$(uname -s)" != "Linux" ]]; then
+ echo "build-appimage.sh must run on Linux." >&2
+ exit 2
+fi
+if [[ "$(uname -m)" != "x86_64" ]]; then
+ echo "The Linux Preview currently requires a native x86_64 runner." >&2
+ exit 3
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "${script_dir}/../.." && pwd)"
+license_file="${repo_root}/LICENSE"
+publish_dir="${PUBLISH_DIR:?PUBLISH_DIR is required}"
+output_dir="${OUTPUT_DIR:?OUTPUT_DIR is required}"
+package_label="${PACKAGE_LABEL:?PACKAGE_LABEL is required}"
+
+executable="${publish_dir}/FB2Blogger.Desktop"
+if [[ ! -x "${executable}" ]]; then
+ echo "Published executable is missing or is not executable: ${executable}" >&2
+ exit 4
+fi
+if [[ ! -f "${license_file}" ]] || ! grep -Fq 'Permission is hereby granted' "${license_file}"; then
+ echo "A readable MIT license is required: ${license_file}" >&2
+ exit 5
+fi
+
+safe_label="$(printf '%s' "${package_label}" | tr -c 'A-Za-z0-9._-' '-')"
+artifact_name="FB2Blogger-${safe_label}-Linux-x64-Preview.AppImage"
+mkdir -p "${output_dir}"
+output_dir="$(cd "${output_dir}" && pwd)"
+
+work_dir="$(mktemp -d "${TMPDIR:-/tmp}/fb2blogger-appimage.XXXXXX")"
+trap 'rm -rf "${work_dir}"' EXIT
+app_dir="${work_dir}/FB2Blogger.AppDir"
+mkdir -p \
+ "${app_dir}/usr/bin" \
+ "${app_dir}/usr/share/applications" \
+ "${app_dir}/usr/share/doc/fb2blogger-preview" \
+ "${app_dir}/usr/share/icons/hicolor/scalable/apps"
+cp -a "${publish_dir}/." "${app_dir}/usr/bin/"
+cp "${repo_root}/packaging/PREVIEW-NOTICE.txt" "${app_dir}/usr/share/doc/fb2blogger-preview/PREVIEW-NOTICE.txt"
+cp "${license_file}" "${app_dir}/usr/share/doc/fb2blogger-preview/LICENSE.txt"
+cp "${repo_root}/packaging/linux/fb2blogger-preview.desktop" "${app_dir}/fb2blogger-preview.desktop"
+cp "${repo_root}/packaging/linux/fb2blogger-preview.desktop" "${app_dir}/usr/share/applications/fb2blogger-preview.desktop"
+cp "${repo_root}/packaging/linux/fb2blogger-preview.svg" "${app_dir}/fb2blogger-preview.svg"
+cp "${repo_root}/packaging/linux/fb2blogger-preview.svg" "${app_dir}/usr/share/icons/hicolor/scalable/apps/fb2blogger-preview.svg"
+ln -s "fb2blogger-preview.svg" "${app_dir}/.DirIcon"
+
+cat > "${app_dir}/AppRun" <<'APPRUN'
+#!/bin/sh
+set -eu
+here="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+exec "${here}/usr/bin/FB2Blogger.Desktop" "$@"
+APPRUN
+chmod +x "${app_dir}/AppRun" "${app_dir}/usr/bin/FB2Blogger.Desktop"
+
+# AppImage upstream intentionally uses continuous releases. The content digest
+# pins this download to commit 8c8c91f762b412a19f4e8d2c4b35afb98f2d7c81
+# (published 2025-12-04); an upstream replacement fails closed until reviewed.
+appimagetool_url="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
+appimagetool_sha256="a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0"
+appimagetool="${work_dir}/appimagetool-x86_64.AppImage"
+curl --fail --location --proto '=https' --tlsv1.2 "${appimagetool_url}" -o "${appimagetool}"
+printf '%s %s\n' "${appimagetool_sha256}" "${appimagetool}" | sha256sum --check --strict
+chmod +x "${appimagetool}"
+
+artifact_path="${output_dir}/${artifact_name}"
+ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "${appimagetool}" "${app_dir}" "${artifact_path}"
+chmod +x "${artifact_path}"
+file "${artifact_path}" | grep -Eq 'ELF 64-bit.*executable'
+
+verify_dir="${work_dir}/verify"
+mkdir -p "${verify_dir}"
+(
+ cd "${verify_dir}"
+ "${artifact_path}" --appimage-extract >/dev/null
+ test -x squashfs-root/AppRun
+ test -x squashfs-root/usr/bin/FB2Blogger.Desktop
+ test -f squashfs-root/usr/share/doc/fb2blogger-preview/LICENSE.txt
+ grep -Fq 'Permission is hereby granted' squashfs-root/usr/share/doc/fb2blogger-preview/LICENSE.txt
+)
+
+smoke_output="$(APPIMAGE_EXTRACT_AND_RUN=1 "${artifact_path}" --package-smoke-test)"
+printf '%s\n' "${smoke_output}"
+grep -Fq "FB2BLOGGER_PREVIEW_SMOKE_OK" <<<"${smoke_output}"
+
+(
+ cd "${output_dir}"
+ sha256sum "${artifact_name}" > "${artifact_name}.sha256"
+)
+
+echo "Created ${artifact_path}"
diff --git a/packaging/linux/fb2blogger-preview.desktop b/packaging/linux/fb2blogger-preview.desktop
new file mode 100644
index 0000000..6632280
--- /dev/null
+++ b/packaging/linux/fb2blogger-preview.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=FB2Blogger Preview
+Comment=Inspect an official Facebook export ZIP offline
+Exec=FB2Blogger.Desktop
+Icon=fb2blogger-preview
+Terminal=false
+Categories=Utility;Office;
+Keywords=Facebook;Blogger;export;archive;preview;
diff --git a/packaging/linux/fb2blogger-preview.svg b/packaging/linux/fb2blogger-preview.svg
new file mode 100644
index 0000000..1a2e70e
--- /dev/null
+++ b/packaging/linux/fb2blogger-preview.svg
@@ -0,0 +1,17 @@
+
diff --git a/packaging/macos/build-dmg.sh b/packaging/macos/build-dmg.sh
new file mode 100755
index 0000000..7fef615
--- /dev/null
+++ b/packaging/macos/build-dmg.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+ echo "build-dmg.sh must run on macOS." >&2
+ exit 2
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "${script_dir}/../.." && pwd)"
+license_file="${repo_root}/LICENSE"
+publish_dir="${PUBLISH_DIR:?PUBLISH_DIR is required}"
+output_dir="${OUTPUT_DIR:?OUTPUT_DIR is required}"
+package_label="${PACKAGE_LABEL:?PACKAGE_LABEL is required}"
+app_version="${APP_VERSION:?APP_VERSION is required}"
+runtime_id="${RUNTIME_ID:?RUNTIME_ID is required}"
+architecture_label="${ARCHITECTURE_LABEL:?ARCHITECTURE_LABEL is required}"
+bundle_short_version="${app_version%%-*}"
+
+if [[ ! "${bundle_short_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "The macOS bundle version must resolve to three numeric components: ${app_version}" >&2
+ exit 3
+fi
+
+case "${runtime_id}:$(uname -m)" in
+ osx-x64:x86_64|osx-arm64:arm64) ;;
+ *)
+ echo "The native runner architecture does not match ${runtime_id}: $(uname -m)." >&2
+ exit 4
+ ;;
+esac
+
+executable="${publish_dir}/FB2Blogger.Desktop"
+if [[ ! -x "${executable}" ]]; then
+ echo "Published executable is missing or is not executable: ${executable}" >&2
+ exit 5
+fi
+if [[ ! -f "${license_file}" ]] || ! grep -Fq 'Permission is hereby granted' "${license_file}"; then
+ echo "A readable MIT license is required: ${license_file}" >&2
+ exit 6
+fi
+
+safe_label="$(printf '%s' "${package_label}" | tr -c 'A-Za-z0-9._-' '-')"
+artifact_name="FB2Blogger-${safe_label}-macOS-${architecture_label}-Preview.dmg"
+mkdir -p "${output_dir}"
+output_dir="$(cd "${output_dir}" && pwd)"
+
+work_dir="$(mktemp -d "${TMPDIR:-/tmp}/fb2blogger-dmg.XXXXXX")"
+mount_dir="${work_dir}/mounted"
+mounted_device=""
+cleanup() {
+ if [[ -n "${mounted_device}" ]]; then
+ hdiutil detach "${mounted_device}" -quiet || true
+ fi
+ rm -rf "${work_dir}"
+}
+trap cleanup EXIT
+
+app_bundle="${work_dir}/FB2Blogger Preview.app"
+contents="${app_bundle}/Contents"
+mkdir -p "${contents}/MacOS" "${contents}/Resources"
+cp -R "${publish_dir}/." "${contents}/MacOS/"
+cp "${repo_root}/packaging/PREVIEW-NOTICE.txt" "${contents}/Resources/PREVIEW-NOTICE.txt"
+cp "${license_file}" "${contents}/Resources/LICENSE.txt"
+chmod +x "${contents}/MacOS/FB2Blogger.Desktop"
+
+cat > "${contents}/Info.plist" <
+
+
+
+ CFBundleDevelopmentRegionen
+ CFBundleDisplayNameFB2Blogger Preview
+ CFBundleExecutableFB2Blogger.Desktop
+ CFBundleIdentifiertw.com.flamebladestudio.fb2blogger.preview
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleNameFB2Blogger Preview
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString${bundle_short_version}
+ CFBundleVersion${GITHUB_RUN_NUMBER:-1}
+ LSMinimumSystemVersion12.0
+ NSHighResolutionCapable
+
+
+PLIST
+printf 'APPL????' > "${contents}/PkgInfo"
+plutil -lint "${contents}/Info.plist"
+
+dmg_root="${work_dir}/dmg-root"
+mkdir -p "${dmg_root}"
+cp -R "${app_bundle}" "${dmg_root}/"
+cp "${repo_root}/packaging/PREVIEW-NOTICE.txt" "${dmg_root}/README-PREVIEW.txt"
+cp "${license_file}" "${dmg_root}/LICENSE.txt"
+ln -s /Applications "${dmg_root}/Applications"
+
+artifact_path="${output_dir}/${artifact_name}"
+hdiutil create \
+ -volname "FB2Blogger Preview" \
+ -srcfolder "${dmg_root}" \
+ -format UDZO \
+ -ov \
+ "${artifact_path}"
+hdiutil imageinfo "${artifact_path}" >/dev/null
+
+mkdir -p "${mount_dir}"
+attach_output="$(hdiutil attach -nobrowse -readonly -mountpoint "${mount_dir}" "${artifact_path}")"
+mounted_device="$(printf '%s\n' "${attach_output}" | awk '/^\/dev\// { print $1; exit }')"
+test -n "${mounted_device}"
+test -d "${mount_dir}/FB2Blogger Preview.app"
+test -f "${mount_dir}/README-PREVIEW.txt"
+test -f "${mount_dir}/LICENSE.txt"
+test -f "${mount_dir}/FB2Blogger Preview.app/Contents/Resources/LICENSE.txt"
+grep -Fq 'Permission is hereby granted' "${mount_dir}/LICENSE.txt"
+grep -Fq 'Permission is hereby granted' "${mount_dir}/FB2Blogger Preview.app/Contents/Resources/LICENSE.txt"
+
+smoke_output="$("${mount_dir}/FB2Blogger Preview.app/Contents/MacOS/FB2Blogger.Desktop" --package-smoke-test)"
+printf '%s\n' "${smoke_output}"
+grep -Fq "FB2BLOGGER_PREVIEW_SMOKE_OK" <<<"${smoke_output}"
+
+hdiutil detach "${mounted_device}" -quiet
+mounted_device=""
+
+(
+ cd "${output_dir}"
+ shasum -a 256 "${artifact_name}" > "${artifact_name}.sha256"
+)
+
+echo "Created ${artifact_path}"
diff --git a/packaging/resolve-metadata.sh b/packaging/resolve-metadata.sh
new file mode 100755
index 0000000..97c11c2
--- /dev/null
+++ b/packaging/resolve-metadata.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+project="${PROJECT_FILE:-${repo_root}/src/FB2Blogger.Desktop/FB2Blogger.Desktop.csproj}"
+project_version="$(dotnet msbuild "${project}" -nologo -getProperty:Version | tr -d '\r' | tail -n 1)"
+
+if [[ ! "${project_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
+ echo "Could not resolve a valid project version: ${project_version}" >&2
+ exit 2
+fi
+
+case "${REF_TYPE:-}:${EVENT_NAME:-}" in
+ tag:*)
+ package_label="${REF_NAME:?REF_NAME is required for tag builds}"
+ app_version="${package_label#v}"
+ ;;
+ *:pull_request)
+ package_label="PR-${PR_NUMBER:?PR_NUMBER is required for pull-request builds}-validation"
+ app_version="${project_version}"
+ ;;
+ *)
+ package_label="v${project_version}-preview.build${RUN_NUMBER:?RUN_NUMBER is required}"
+ app_version="${project_version}"
+ ;;
+esac
+
+if [[ ! "${app_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
+ echo "Resolved tag version is not valid semantic version text: ${app_version}" >&2
+ exit 3
+fi
+
+if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+ printf 'package_label=%s\n' "${package_label}" >> "${GITHUB_OUTPUT}"
+ printf 'app_version=%s\n' "${app_version}" >> "${GITHUB_OUTPUT}"
+ printf 'project_version=%s\n' "${project_version}" >> "${GITHUB_OUTPUT}"
+else
+ printf 'package_label=%s\napp_version=%s\nproject_version=%s\n' \
+ "${package_label}" "${app_version}" "${project_version}"
+fi
diff --git a/packaging/validate-release-tag.sh b/packaging/validate-release-tag.sh
new file mode 100644
index 0000000..de53817
--- /dev/null
+++ b/packaging/validate-release-tag.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+event_name="${EVENT_NAME:?EVENT_NAME is required}"
+ref_type="${REF_TYPE:?REF_TYPE is required}"
+ref_name="${REF_NAME:-}"
+tag_commit="${TAG_COMMIT:?TAG_COMMIT is required}"
+main_ref="${MAIN_REF:-origin/main}"
+publish="false"
+resolved_commit="$(git rev-parse "${tag_commit}^{commit}")"
+
+if [[ "${event_name}" == "push" && "${ref_type}" == "tag" ]]; then
+ if [[ ! "${ref_name}" =~ ^v1\.1\.0-rc\.([1-9][0-9]*)$ ]]; then
+ echo "Only v1.1.0-rc.N tags with N greater than zero may publish this release line: ${ref_name}" >&2
+ exit 2
+ fi
+
+ if ! git merge-base --is-ancestor "${resolved_commit}" "${main_ref}"; then
+ echo "Release tag ${ref_name} does not point to a commit contained in ${main_ref}." >&2
+ exit 3
+ fi
+ publish="true"
+fi
+
+if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+ printf 'publish=%s\n' "${publish}" >> "${GITHUB_OUTPUT}"
+ printf 'tag=%s\n' "${ref_name}" >> "${GITHUB_OUTPUT}"
+ printf 'build_commit=%s\n' "${resolved_commit}" >> "${GITHUB_OUTPUT}"
+else
+ printf 'publish=%s\ntag=%s\nbuild_commit=%s\n' "${publish}" "${ref_name}" "${resolved_commit}"
+fi
diff --git a/packaging/verify-release-assets.sh b/packaging/verify-release-assets.sh
new file mode 100644
index 0000000..ff38170
--- /dev/null
+++ b/packaging/verify-release-assets.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+artifact_dir="${ARTIFACT_DIR:?ARTIFACT_DIR is required}"
+tag="${RELEASE_TAG:?RELEASE_TAG is required}"
+
+if [[ ! "${tag}" =~ ^v1\.1\.0-rc\.([1-9][0-9]*)$ ]]; then
+ echo "Refusing to prepare assets for an invalid release tag: ${tag}" >&2
+ exit 2
+fi
+if [[ ! -d "${artifact_dir}" ]]; then
+ echo "Release artifact directory does not exist: ${artifact_dir}" >&2
+ exit 3
+fi
+
+packages=(
+ "FB2Blogger.exe"
+ "FB2Blogger-${tag}-macOS-x64-Preview.dmg"
+ "FB2Blogger-${tag}-macOS-arm64-Preview.dmg"
+ "FB2Blogger-${tag}-Linux-x64-Preview.AppImage"
+)
+expected=()
+for package in "${packages[@]}"; do
+ expected+=("${package}" "${package}.sha256" "${package}.spdx.json")
+done
+expected+=("FB2Blogger-Windows-x64-LICENSE.txt")
+
+mapfile -t actual < <(find "${artifact_dir}" -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort)
+mapfile -t wanted < <(printf '%s\n' "${expected[@]}" | LC_ALL=C sort)
+if [[ "${actual[*]}" != "${wanted[*]}" ]]; then
+ printf 'Expected release files:\n%s\n' "$(printf ' %s\n' "${wanted[@]}")" >&2
+ printf 'Actual release files:\n%s\n' "$(printf ' %s\n' "${actual[@]}")" >&2
+ exit 4
+fi
+
+(
+ cd "${artifact_dir}"
+ for package in "${packages[@]}"; do
+ sha256sum --check --strict "${package}.sha256"
+ python3 -c 'import json,sys; json.load(open(sys.argv[1], encoding="utf-8"))' "${package}.spdx.json"
+ done
+ grep -Fq 'Permission is hereby granted' FB2Blogger-Windows-x64-LICENSE.txt
+ sha256sum "${expected[@]}" > SHA256SUMS
+)
+
+test "$(wc -l < "${artifact_dir}/SHA256SUMS" | tr -d ' ')" = "${#expected[@]}"
+echo "Verified four platform packages, their readable MIT license payloads, and ${artifact_dir}/SHA256SUMS."
diff --git a/packaging/windows/build-release.ps1 b/packaging/windows/build-release.ps1
new file mode 100644
index 0000000..5892e85
--- /dev/null
+++ b/packaging/windows/build-release.ps1
@@ -0,0 +1,107 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$PublishDir,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputDir,
+
+ [Parameter(Mandatory = $true)]
+ [string]$AppVersion,
+
+ [Parameter(Mandatory = $true)]
+ [string]$RepositoryRoot
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$publishPath = (Resolve-Path -LiteralPath $PublishDir).Path
+$repositoryPath = (Resolve-Path -LiteralPath $RepositoryRoot).Path
+$sourceExecutable = Join-Path $publishPath 'FB2Blogger.exe'
+$sourceLicense = Join-Path $repositoryPath 'LICENSE'
+if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) {
+ throw "Published Windows executable is missing: $sourceExecutable"
+}
+if (-not (Test-Path -LiteralPath $sourceLicense -PathType Leaf)) {
+ throw "MIT license is missing: $sourceLicense"
+}
+if ($AppVersion -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') {
+ throw "Invalid application version: $AppVersion"
+}
+
+$outputPath = [System.IO.Path]::GetFullPath($OutputDir)
+[System.IO.Directory]::CreateDirectory($outputPath) | Out-Null
+$artifactPath = Join-Path $outputPath 'FB2Blogger.exe'
+$licensePath = Join-Path $outputPath 'FB2Blogger-Windows-x64-LICENSE.txt'
+Copy-Item -LiteralPath $sourceExecutable -Destination $artifactPath -Force
+Copy-Item -LiteralPath $sourceLicense -Destination $licensePath -Force
+if (-not (Select-String -LiteralPath $licensePath -SimpleMatch 'Permission is hereby granted' -Quiet)) {
+ throw "The packaged Windows license is not a readable MIT license: $licensePath"
+}
+
+$runnerTemp = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() }
+$workPath = Join-Path $runnerTemp ("fb2blogger-windows-package-" + [Guid]::NewGuid().ToString('N'))
+[System.IO.Directory]::CreateDirectory($workPath) | Out-Null
+try {
+ $markerPath = Join-Path $workPath 'launch-smoke.txt'
+ $process = Start-Process `
+ -FilePath $artifactPath `
+ -ArgumentList @('--package-smoke-test', $markerPath) `
+ -PassThru `
+ -Wait `
+ -WindowStyle Hidden
+ if ($process.ExitCode -ne 0) {
+ $diagnostic = if (Test-Path -LiteralPath $markerPath) { Get-Content -LiteralPath $markerPath -Raw } else { 'No diagnostic marker was written.' }
+ throw "Packaged Windows executable failed its native-window smoke test with exit code $($process.ExitCode): $diagnostic"
+ }
+ $marker = Get-Content -LiteralPath $markerPath -Raw
+ if (-not $marker.StartsWith('FB2BLOGGER_WINDOWS_PACKAGE_SMOKE_OK', [StringComparison]::Ordinal)) {
+ throw "Packaged Windows executable did not produce the expected smoke marker: $marker"
+ }
+ Write-Output $marker
+
+ $hash = (Get-FileHash -LiteralPath $artifactPath -Algorithm SHA256).Hash.ToLowerInvariant()
+ [System.IO.File]::WriteAllText(
+ "$artifactPath.sha256",
+ "$hash FB2Blogger.exe`n",
+ [System.Text.UTF8Encoding]::new($false)
+ )
+
+ $toolPath = Join-Path $workPath 'sbom-tool'
+ & dotnet tool install Microsoft.Sbom.DotNetTool --tool-path $toolPath --version 4.1.5
+ if ($LASTEXITCODE -ne 0) {
+ throw "Microsoft SBOM tool installation failed with exit code $LASTEXITCODE."
+ }
+ $manifestPath = Join-Path $workPath 'manifest'
+ [System.IO.Directory]::CreateDirectory($manifestPath) | Out-Null
+ $sbomTool = Join-Path $toolPath 'sbom-tool.exe'
+ & $sbomTool generate `
+ -b $publishPath `
+ -bc $repositoryPath `
+ -pn 'FB2Blogger Windows Release Candidate' `
+ -pv $AppVersion `
+ -ps 'Flameblade Studio' `
+ -nsb 'https://github.com/hitoshic1982/FB2Blogger' `
+ -m $manifestPath
+ if ($LASTEXITCODE -ne 0) {
+ throw "Microsoft SBOM generation failed with exit code $LASTEXITCODE."
+ }
+ $manifests = @(Get-ChildItem -LiteralPath $manifestPath -Recurse -File -Filter 'manifest.spdx.json')
+ if ($manifests.Count -ne 1) {
+ throw "Expected one SPDX manifest, found $($manifests.Count)."
+ }
+ $sbomPath = "$artifactPath.spdx.json"
+ Copy-Item -LiteralPath $manifests[0].FullName -Destination $sbomPath -Force
+ Get-Content -LiteralPath $sbomPath -Raw | ConvertFrom-Json | Out-Null
+ if ((Get-Item -LiteralPath $sbomPath).Length -ge 16MB) {
+ throw 'SBOM exceeds the GitHub attestation size limit.'
+ }
+}
+finally {
+ if (Test-Path -LiteralPath $workPath) {
+ Remove-Item -LiteralPath $workPath -Recurse -Force
+ }
+}
+
+Write-Output "Created $artifactPath with its readable MIT license, SHA256, and SPDX SBOM."
diff --git a/src/FB2Blogger.Desktop/Program.cs b/src/FB2Blogger.Desktop/Program.cs
index 639f31c..a5dd32e 100644
--- a/src/FB2Blogger.Desktop/Program.cs
+++ b/src/FB2Blogger.Desktop/Program.cs
@@ -1,14 +1,38 @@
using Avalonia;
+using System.Runtime.InteropServices;
namespace FB2Blogger.Desktop;
internal static class Program
{
[STAThread]
- public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ public static int Main(string[] args)
+ {
+ if (args is ["--package-smoke-test"])
+ return RunPackageSmokeTest();
+
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ return 0;
+ }
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure()
.UsePlatformDetect()
.LogToTrace();
+
+ private static int RunPackageSmokeTest()
+ {
+ try
+ {
+ _ = BuildAvaloniaApp();
+ Console.WriteLine(
+ $"FB2BLOGGER_PREVIEW_SMOKE_OK os={RuntimeInformation.OSDescription} arch={RuntimeInformation.ProcessArchitecture}");
+ return 0;
+ }
+ catch (Exception error)
+ {
+ Console.Error.WriteLine($"FB2BLOGGER_PREVIEW_SMOKE_FAILED {error.GetType().Name}: {error.Message}");
+ return 1;
+ }
+ }
}
diff --git a/src/FB2Blogger/MainForm.cs b/src/FB2Blogger/MainForm.cs
index 3df50b8..6e5a56d 100644
--- a/src/FB2Blogger/MainForm.cs
+++ b/src/FB2Blogger/MainForm.cs
@@ -28,7 +28,7 @@ internal sealed class MainForm : Form
readonly AppSettings settings;
CancellationTokenSource? cts;
- public MainForm(AppSettings settings)
+ public MainForm(AppSettings settings, bool enableInteractiveStartup = true)
{
this.settings = settings;
Text = "FB2Blogger"; Width = 900; Height = 650; MinimumSize = new(760, 560); StartPosition = FormStartPosition.CenterScreen; Font = new(L.FontName, 10);
@@ -47,7 +47,8 @@ public MainForm(AppSettings settings)
publishArticle.Click += PublishArticle;
composeTitle.TextChanged += (_, _) => { if (cts is null) composePostKey = ""; };
composeBody.TextChanged += (_, _) => { if (cts is null) composePostKey = ""; };
- Shown += async (_, _) => { if (string.IsNullOrWhiteSpace(settings.ClientId)) await ConfigureFirstRunAsync(); else Say(L.T("configured_account", string.IsNullOrEmpty(settings.BlogName) ? L.T("google_account") : settings.BlogName)); };
+ if (enableInteractiveStartup)
+ Shown += async (_, _) => { if (string.IsNullOrWhiteSpace(settings.ClientId)) await ConfigureFirstRunAsync(); else Say(L.T("configured_account", string.IsNullOrEmpty(settings.BlogName) ? L.T("google_account") : settings.BlogName)); };
}
Control BuildComposer()
diff --git a/src/FB2Blogger/Program.cs b/src/FB2Blogger/Program.cs
index 0f70907..391c7f7 100644
--- a/src/FB2Blogger/Program.cs
+++ b/src/FB2Blogger/Program.cs
@@ -3,13 +3,16 @@ namespace FB2Blogger;
internal static class Program
{
[STAThread]
- static void Main()
+ static int Main(string[] args)
{
+ if (args is ["--package-smoke-test", var markerPath])
+ return RunPackageSmokeTest(markerPath);
+
using var singleInstance = new Mutex(true, "Local\\FB2Blogger.SingleInstance", out var isFirstInstance);
if (!isFirstInstance)
{
MessageBox.Show(L.T("single_instance"), "FB2Blogger", MessageBoxButtons.OK, MessageBoxIcon.Information);
- return;
+ return 0;
}
ApplicationConfiguration.Initialize();
var settings = SettingsStore.Load();
@@ -21,6 +24,47 @@ static void Main()
using var mainForm = new MainForm(settings);
Application.Run(mainForm);
GC.KeepAlive(singleInstance);
+ return 0;
+ }
+
+ static int RunPackageSmokeTest(string markerPath)
+ {
+ try
+ {
+ if (!Path.IsPathFullyQualified(markerPath))
+ throw new ArgumentException("The smoke-test marker path must be fully qualified.", nameof(markerPath));
+
+ ApplicationConfiguration.Initialize();
+ L.Configure("en");
+ using var mainForm = new MainForm(new AppSettings(), enableInteractiveStartup: false);
+ mainForm.Show();
+ Application.DoEvents();
+ if (!mainForm.Visible || !mainForm.IsHandleCreated)
+ throw new InvalidOperationException("The main window did not create a visible native handle.");
+ mainForm.Close();
+ Application.DoEvents();
+
+ var markerDirectory = Path.GetDirectoryName(markerPath)
+ ?? throw new InvalidOperationException("The smoke-test marker directory could not be resolved.");
+ Directory.CreateDirectory(markerDirectory);
+ File.WriteAllText(
+ markerPath,
+ $"FB2BLOGGER_WINDOWS_PACKAGE_SMOKE_OK os={Environment.OSVersion} arch={System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture}");
+ return 0;
+ }
+ catch (Exception error)
+ {
+ try
+ {
+ if (Path.IsPathFullyQualified(markerPath))
+ File.WriteAllText(markerPath, $"FB2BLOGGER_WINDOWS_PACKAGE_SMOKE_FAILED {error.GetType().Name}: {error.Message}");
+ }
+ catch
+ {
+ // The exit code remains the authority when the diagnostic marker cannot be written.
+ }
+ return 1;
+ }
}
}
diff --git a/tests/PackagingAudit/PackagingAudit.csproj b/tests/PackagingAudit/PackagingAudit.csproj
new file mode 100644
index 0000000..238f157
--- /dev/null
+++ b/tests/PackagingAudit/PackagingAudit.csproj
@@ -0,0 +1,8 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
diff --git a/tests/PackagingAudit/Program.cs b/tests/PackagingAudit/Program.cs
new file mode 100644
index 0000000..9a97d2d
--- /dev/null
+++ b/tests/PackagingAudit/Program.cs
@@ -0,0 +1,173 @@
+using System.Text.RegularExpressions;
+
+var failures = new List();
+void Check(bool condition, string name)
+{
+ Console.WriteLine($"{(condition ? "PASS" : "FAIL")} {name}");
+ if (!condition) failures.Add(name);
+}
+
+string Read(string relativePath) => File.ReadAllText(Path.Combine(Directory.GetCurrentDirectory(), relativePath));
+
+var workflow = Read(Path.Combine(".github", "workflows", "preview-packages.yml"));
+var macScript = Read(Path.Combine("packaging", "macos", "build-dmg.sh"));
+var linuxScript = Read(Path.Combine("packaging", "linux", "build-appimage.sh"));
+var windowsScript = Read(Path.Combine("packaging", "windows", "build-release.ps1"));
+var releaseGateScript = Read(Path.Combine("packaging", "validate-release-tag.sh"));
+var releaseAssetsScript = Read(Path.Combine("packaging", "verify-release-assets.sh"));
+var metadataScript = Read(Path.Combine("packaging", "resolve-metadata.sh"));
+var sbomScript = Read(Path.Combine("packaging", "generate-sbom.sh"));
+var program = Read(Path.Combine("src", "FB2Blogger.Desktop", "Program.cs"));
+var windowsProgram = Read(Path.Combine("src", "FB2Blogger", "Program.cs"));
+var mainForm = Read(Path.Combine("src", "FB2Blogger", "MainForm.cs"));
+var notice = Read(Path.Combine("packaging", "PREVIEW-NOTICE.txt"));
+var previewDocument = Read(Path.Combine("docs", "PREVIEW-PACKAGES.md"));
+var releaseNotes = Read(Path.Combine("docs", "releases", "v1.1.0-rc.1.md"));
+
+Check(program.Contains("--package-smoke-test", StringComparison.Ordinal) &&
+ program.Contains("FB2BLOGGER_PREVIEW_SMOKE_OK", StringComparison.Ordinal),
+ "The packaged executable exposes a deterministic launch smoke test");
+
+Check(windowsProgram.Contains("--package-smoke-test", StringComparison.Ordinal) &&
+ windowsProgram.Contains("mainForm.Show()", StringComparison.Ordinal) &&
+ windowsProgram.Contains("mainForm.IsHandleCreated", StringComparison.Ordinal) &&
+ windowsProgram.Contains("FB2BLOGGER_WINDOWS_PACKAGE_SMOKE_OK", StringComparison.Ordinal) &&
+ mainForm.Contains("enableInteractiveStartup", StringComparison.Ordinal),
+ "The full Windows executable opens its real main window without triggering first-run interaction");
+Check(windowsScript.Contains("Start-Process", StringComparison.Ordinal) &&
+ windowsScript.Contains("-Wait", StringComparison.Ordinal) &&
+ windowsScript.Contains("Get-FileHash", StringComparison.Ordinal) &&
+ windowsScript.Contains("Microsoft.Sbom.DotNetTool", StringComparison.Ordinal) &&
+ windowsScript.Contains("--version 4.1.5", StringComparison.Ordinal) &&
+ windowsScript.Contains("CreateDirectory($manifestPath)", StringComparison.Ordinal) &&
+ windowsScript.Contains("FB2Blogger-Windows-x64-LICENSE.txt", StringComparison.Ordinal) &&
+ windowsScript.Contains("Permission is hereby granted", StringComparison.Ordinal),
+ "Windows packaging executes the published EXE and creates a readable MIT license, SHA256, and pinned SPDX SBOM");
+
+Check(macScript.Contains("hdiutil create", StringComparison.Ordinal) &&
+ macScript.Contains("FB2Blogger Preview.app", StringComparison.Ordinal) &&
+ macScript.Contains("Contents/MacOS/FB2Blogger.Desktop", StringComparison.Ordinal) &&
+ macScript.Contains("bundle_short_version=\"${app_version%%-*}\"", StringComparison.Ordinal) &&
+ macScript.Contains("hdiutil attach", StringComparison.Ordinal) &&
+ macScript.Contains("--package-smoke-test", StringComparison.Ordinal) &&
+ macScript.Contains("contents}/Resources/LICENSE.txt", StringComparison.Ordinal) &&
+ macScript.Contains("dmg_root}/LICENSE.txt", StringComparison.Ordinal) &&
+ macScript.Contains("Permission is hereby granted", StringComparison.Ordinal) &&
+ macScript.Contains("shasum -a 256", StringComparison.Ordinal),
+ "macOS packaging creates, mounts, launches, and hashes a real DMG whose root and app resources contain the MIT license");
+
+Check(linuxScript.Contains("FB2Blogger.AppDir", StringComparison.Ordinal) &&
+ linuxScript.Contains("appimagetool-x86_64.AppImage", StringComparison.Ordinal) &&
+ linuxScript.Contains("--appimage-extract", StringComparison.Ordinal) &&
+ linuxScript.Contains("--package-smoke-test", StringComparison.Ordinal) &&
+ linuxScript.Contains("usr/share/doc/fb2blogger-preview/LICENSE.txt", StringComparison.Ordinal) &&
+ linuxScript.Contains("Permission is hereby granted", StringComparison.Ordinal) &&
+ linuxScript.Contains("sha256sum", StringComparison.Ordinal),
+ "Linux packaging creates, extracts, launches, and hashes a real AppImage containing the MIT license");
+Check(linuxScript.Contains("8c8c91f762b412a19f4e8d2c4b35afb98f2d7c81", StringComparison.Ordinal) &&
+ linuxScript.Contains("a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0", StringComparison.Ordinal),
+ "AppImage tooling is fail-closed to an audited upstream commit and SHA256");
+
+Check(metadataScript.Contains("REF_TYPE", StringComparison.Ordinal) &&
+ metadataScript.Contains("PR-${PR_NUMBER", StringComparison.Ordinal) &&
+ metadataScript.Contains("preview.build", StringComparison.Ordinal),
+ "Artifact labels distinguish tags, PR validation, and main-branch preview builds");
+Check(sbomScript.Contains("Microsoft.Sbom.DotNetTool", StringComparison.Ordinal) &&
+ sbomScript.Contains("tool_version=\"4.1.5\"", StringComparison.Ordinal) &&
+ sbomScript.Contains("manifest.spdx.json", StringComparison.Ordinal),
+ "The Microsoft SBOM generator and output format are pinned");
+
+Check(releaseGateScript.Contains("^v1\\.1\\.0-rc\\.([1-9][0-9]*)$", StringComparison.Ordinal) &&
+ releaseGateScript.Contains("git merge-base --is-ancestor", StringComparison.Ordinal) &&
+ releaseGateScript.Contains("build_commit=%s", StringComparison.Ordinal) &&
+ workflow.Contains("+refs/heads/main:refs/remotes/origin/main", StringComparison.Ordinal),
+ "Release authority requires an exact positive RC tag whose commit is contained in origin/main");
+Check(workflow.Contains("permissions:\n contents: read", StringComparison.Ordinal) &&
+ workflow.Contains("if: github.event_name == 'push' && github.ref_type == 'tag' && needs.release-gate.outputs.publish == 'true'", StringComparison.Ordinal) &&
+ workflow.Contains("needs: [release-gate, windows-release, macos-x64-preview, macos-arm64-preview, linux-preview]", StringComparison.Ordinal),
+ "Pull requests and ordinary main pushes remain read-only while release publication waits for every platform");
+Check(workflow.Contains("name: Required - All platform packages", StringComparison.Ordinal) &&
+ workflow.Contains("if: always() && github.event_name == 'pull_request'", StringComparison.Ordinal) &&
+ workflow.Contains("needs: [windows-release, macos-x64-preview, macos-arm64-preview, linux-preview]", StringComparison.Ordinal) &&
+ workflow.Contains("needs.macos-x64-preview.result", StringComparison.Ordinal) &&
+ workflow.Contains("needs.macos-arm64-preview.result", StringComparison.Ordinal),
+ "Every pull request exposes one stable required-check context aggregating all four package jobs");
+
+foreach (var runner in new[] { "macos-15-intel", "macos-15", "ubuntu-24.04" })
+ Check(workflow.Contains(runner, StringComparison.Ordinal), $"Workflow uses the explicit native runner {runner}");
+foreach (var runtime in new[] { "osx-x64", "osx-arm64", "linux-x64" })
+ Check(workflow.Contains(runtime, StringComparison.Ordinal), $"Workflow publishes the native runtime {runtime}");
+Check(workflow.Contains("windows-2025", StringComparison.Ordinal) &&
+ workflow.Contains("src/FB2Blogger/FB2Blogger.csproj", StringComparison.Ordinal) &&
+ workflow.Contains("packaging/windows/build-release.ps1", StringComparison.Ordinal),
+ "The same workflow builds the full Windows x64 release candidate");
+Check(workflow.Contains("SBOM_DOTNET_SDK_VERSION: 8.0.419", StringComparison.Ordinal) &&
+ Regex.Matches(workflow, @"\$\{\{ env\.SBOM_DOTNET_SDK_VERSION \}\}").Count == 4 &&
+ workflow.Contains("DOTNET_SDK_VERSION: 10.0.204", StringComparison.Ordinal),
+ "Every package runner installs the exact .NET 8 SBOM runtime alongside the pinned .NET 10 build SDK");
+
+var actionUses = Regex.Matches(workflow, @"(?m)^\s*(?:-\s*)?uses:\s*([^\s]+)@([^\s]+)")
+ .Cast()
+ .Select(match => (Name: match.Groups[1].Value, Revision: match.Groups[2].Value))
+ .ToArray();
+Check(actionUses.Length >= 8, "Preview workflow invokes the expected official Actions");
+Check(actionUses.All(action => Regex.IsMatch(action.Revision, "^[0-9a-f]{40}$", RegexOptions.CultureInvariant)),
+ "Every GitHub Action is pinned to an immutable commit");
+Check(actionUses.All(action => action.Name.StartsWith("actions/", StringComparison.Ordinal)),
+ "Preview workflow uses only GitHub-maintained Actions");
+
+Check(workflow.Contains("actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6", StringComparison.Ordinal) &&
+ workflow.Contains("if: github.event_name == 'push' && github.ref_type == 'tag'", StringComparison.Ordinal) &&
+ workflow.Contains("id-token: write", StringComparison.Ordinal) &&
+ workflow.Contains("attestations: write", StringComparison.Ordinal) &&
+ workflow.Contains("artifact-metadata: write", StringComparison.Ordinal),
+ "Attestations run only for trusted events with explicit minimum write permissions");
+Check(workflow.Contains("sbom-path:", StringComparison.Ordinal) &&
+ workflow.Contains("subject-path:", StringComparison.Ordinal),
+ "Trusted builds bind both provenance and the SPDX SBOM to each package");
+Check(releaseAssetsScript.Contains("FB2Blogger.exe", StringComparison.Ordinal) &&
+ releaseAssetsScript.Contains("macOS-x64-Preview.dmg", StringComparison.Ordinal) &&
+ releaseAssetsScript.Contains("macOS-arm64-Preview.dmg", StringComparison.Ordinal) &&
+ releaseAssetsScript.Contains("Linux-x64-Preview.AppImage", StringComparison.Ordinal) &&
+ releaseAssetsScript.Contains("FB2Blogger-Windows-x64-LICENSE.txt", StringComparison.Ordinal) &&
+ releaseAssetsScript.Contains("Permission is hereby granted", StringComparison.Ordinal) &&
+ releaseAssetsScript.Contains("sha256sum --check --strict", StringComparison.Ordinal) &&
+ releaseAssetsScript.Contains("SHA256SUMS", StringComparison.Ordinal) &&
+ workflow.Contains("bash packaging/verify-release-assets.sh", StringComparison.Ordinal),
+ "The release job requires the exact four-platform inventory and creates aggregate SHA256SUMS");
+Check(workflow.Contains("gh release create", StringComparison.Ordinal) &&
+ workflow.Contains("--verify-tag", StringComparison.Ordinal) &&
+ workflow.Contains("--draft", StringComparison.Ordinal) &&
+ workflow.Contains("--prerelease", StringComparison.Ordinal) &&
+ workflow.Contains("gh release upload", StringComparison.Ordinal) &&
+ workflow.Contains("draft=false", StringComparison.Ordinal) &&
+ workflow.Contains("Release tag moved after validation", StringComparison.Ordinal) &&
+ workflow.Contains("ref: ${{ needs.release-gate.outputs.build_commit }}", StringComparison.Ordinal) &&
+ workflow.Contains("--notes-file", StringComparison.Ordinal),
+ "The guarded workflow locks one reviewed commit and atomically publishes one exact curated GitHub pre-release");
+Check(workflow.Contains("*.dmg", StringComparison.Ordinal) &&
+ workflow.Contains("*.AppImage", StringComparison.Ordinal) &&
+ !Regex.IsMatch(workflow + macScript + linuxScript, @"(?i)zip[^\n]*(?:\.dmg|\.AppImage)"),
+ "The workflow produces native package formats instead of renamed ZIP files");
+
+foreach (var heading in new[] { "繁體中文", "简体中文", "English", "日本語" })
+{
+ Check(notice.Contains(heading, StringComparison.Ordinal), $"In-package notice includes {heading}");
+ Check(Regex.IsMatch(previewDocument, $@"(?m)^## {Regex.Escape(heading)}\r?$"), $"Preview guide includes {heading}");
+ Check(Regex.IsMatch(releaseNotes, $@"(?m)^## {Regex.Escape(heading)}\r?$"), $"v1.1.0-rc.1 release notes include {heading}");
+}
+
+foreach (var readme in new[] { "README.md", "README.zh-CN.md", "README.en.md", "README.ja.md" })
+{
+ var text = Read(readme);
+ Check(text.Contains("docs/PREVIEW-PACKAGES.md", StringComparison.Ordinal), $"{readme} links the shared preview package guide");
+}
+
+if (failures.Count > 0)
+{
+ Console.Error.WriteLine("FAILED: " + string.Join(", ", failures));
+ return 1;
+}
+
+Console.WriteLine("ALL PACKAGING AUDITS PASSED");
+return 0;