clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

build: make Atlas releases transactional

Dami committed 51b93ef
Package native file associations on macOS, Linux, and Windows; prepare signing and notarization; validate exact checksummed assets; and publish only after a verified draft.
commit
51b93efeb1b072808bbd62d35379b4851b986320
parent
78d7f3f5dd08896f999906cdbba263b84051dee1
author
Dami <dami.etoile@gmail.com>
20 files changed, +659 -109
M.github/workflows/release.yml168++++++++++++++++--------------
MMakefile36++-----
Mdocs/README.md1+
Mdocs/authoring.md17+++-
Adocs/releasing.md52+++++++++
Mdocs/testing.md8+
Apackaging/linux/control10++
Apackaging/linux/dev.felinestatemachine.Atlas.desktop9++
Apackaging/linux/dev.felinestatemachine.atlas.xml7+
Apackaging/linux/postinst8+
Apackaging/linux/postrm8+
Apackaging/macos/Info.plist40+++++++
Apackaging/windows/Atlas.iss59+++++++++++
Ascripts/release/package-desktop.sh59+++++++++++
Ascripts/release/package-smoke.sh37+++++++
Ascripts/release/package-windows.ps124++++
Ascripts/release/sign-macos.sh57++++++++++
Ascripts/release/sign-windows.ps127+++++
Ascripts/release/verify-assets.sh38+++++++
Atests/release/test_packaging.py103++++++++++++++++++
M .github/workflows/release.yml +89 -79
@@ -6,9 +6,8 @@
66 # workbench, serve, dev. Pure Go, CGO_ENABLED=0, cross-compiled for77 # five targets from one runner.88 # Atlas the desktop application: the hypermedia app in a webview (§3.4).9-# It links a native webview, so it is built on the platform it runs10-# on -- except Windows, which cross-compiles because WebView2 is11-# loaded from the machine at runtime rather than linked.9+# It links a native webview, so it is built and packaged on the10+# platform it runs on. Each package registers .atlas with the OS.1211 #1312 # It replaces ci.yml, which built and released the pre-rewrite desktop shell on1413 # `v*` tags until the old tree was archived at close-out. `v*` answers here
@@ -20,12 +19,9 @@
2019 # - version from the tag, `dev-<short sha>` off a tag;2120 # - `-trimpath` and `-ldflags "-s -w"`, so a build is a function of its2221 # inputs and carries no local paths;23-# - one archive per platform: .tar.gz for the unix targets, .zip for Windows;24-# - the macOS .app assembled by hand -- Contents/MacOS plus an Info.plist25-# naming dev.felinestatemachine.atlas, LSMinimumSystemVersion 12.0,26-# NSHighResolutionCapable -- and packed with `ditto -c -k --keepParent`,27-# because there is no wails.json and no Wails CLI in this tree (docs/app.md28-# §1.4);22+# - CLI archives remain .tar.gz/.zip; desktop delivery is an application23+# bundle on macOS, a MIME-registering .deb on Linux, and an association-24+# registering Inno Setup installer on Windows;2925 # - a release job gated on the tag, `--generate-notes --verify-tag`, and a3026 # prerelease when the version carries a hyphen.3127 #
@@ -148,8 +144,7 @@ jobs:
148144 tags: "desktop,production,webkit2_41"149145 cgo: "1"150146 - target: windows-amd64151- os: ubuntu-24.04152- goos: windows147+ os: windows-2025153148 tags: "desktop,production"154149 cgo: "0"155150 # -H windowsgui is what stops a console window opening behind the
@@ -176,9 +171,13 @@ jobs:
176171 # accident -- so the step asserts its own output, to be noticed if it177172 # ever silently stops producing one.178173 - name: Build the seam into the shell's static tree174+ shell: bash179175 run: |180176 npm ci --ignore-scripts181- make static177+ npm run --silent seam-build178+ mkdir -p dist/static static179+ cp render/dist/app.js dist/static/app.js180+ cp render/dist/app.js static/app.js182181 test -s static/app.js183182 184183 - name: Install GTK and WebKitGTK headers
@@ -187,70 +186,83 @@ jobs:
187186 sudo apt-get update188187 sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev189188 190- - name: Build191- env:192- GOOS: ${{ matrix.goos }}193- CGO_ENABLED: ${{ matrix.cgo }}189+ - name: Resolve version190+ id: metadata191+ shell: bash194192 run: |195193 VERSION="${GITHUB_REF_NAME#atlas-}"196194 if [[ "${GITHUB_REF}" != refs/tags/* ]]; then197195 VERSION="${{ inputs.version }}"198196 [[ -n "${VERSION}" ]] || VERSION="dev-$(git rev-parse --short HEAD)"199197 fi200- NAME="Atlas-${VERSION}-${{ matrix.target }}"198+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"201199 200+ - name: Build native desktop binary201+ shell: bash202+ env:203+ CGO_ENABLED: ${{ matrix.cgo }}204+ run: |202205 BINARY="Atlas"203206 if [[ "${{ matrix.target }}" == windows-* ]]; then BINARY="Atlas.exe"; fi204207 go build -tags "${{ matrix.tags }}" \205- -ldflags "-s -w -X main.version=${VERSION} ${{ matrix.ldflags }}" -o "${BINARY}" .208+ -ldflags "-s -w -X main.version=${{ steps.metadata.outputs.version }} ${{ matrix.ldflags }}" -o "${BINARY}" .209+210+ - name: Sign Windows application when credentials are configured211+ if: matrix.target == 'windows-amd64'212+ shell: pwsh213+ env:214+ WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}215+ WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}216+ run: ./scripts/release/sign-windows.ps1 -Path Atlas.exe217+218+ - name: Package macOS application219+ if: matrix.target == 'darwin-arm64'220+ shell: bash221+ env:222+ MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}223+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}224+ MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}225+ MACOS_NOTARY_KEY_BASE64: ${{ secrets.MACOS_NOTARY_KEY_BASE64 }}226+ MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}227+ MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}228+ run: ./scripts/release/package-desktop.sh darwin-arm64 "${{ steps.metadata.outputs.version }}" Atlas dist229+230+ - name: Package Linux application231+ if: matrix.target == 'linux-amd64'232+ shell: bash233+ run: ./scripts/release/package-desktop.sh linux-amd64 "${{ steps.metadata.outputs.version }}" Atlas dist206234 207- mkdir -p dist208- case "${{ matrix.target }}" in209- windows-*)210- zip -q "dist/${NAME}.zip" Atlas.exe211- ;;212- linux-*)213- tar -czf "dist/${NAME}.tar.gz" Atlas214- ;;215- darwin-*)216- mkdir -p Atlas.app/Contents/MacOS217- cp Atlas Atlas.app/Contents/MacOS/Atlas218- cat > Atlas.app/Contents/Info.plist <<EOF219- <?xml version="1.0" encoding="UTF-8"?>220- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">221- <plist version="1.0">222- <dict>223- <key>CFBundleExecutable</key><string>Atlas</string>224- <key>CFBundleIdentifier</key><string>dev.felinestatemachine.atlas</string>225- <key>CFBundleName</key><string>Atlas</string>226- <key>CFBundlePackageType</key><string>APPL</string>227- <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>228- <key>CFBundleShortVersionString</key><string>${VERSION#v}</string>229- <key>CFBundleDocumentTypes</key><array><dict>230- <key>CFBundleTypeName</key><string>Atlas Volume</string>231- <key>CFBundleTypeRole</key><string>Viewer</string>232- <key>LSItemContentTypes</key><array><string>dev.felinestatemachine.atlas.volume</string></array>233- </dict></array>234- <key>UTExportedTypeDeclarations</key><array><dict>235- <key>UTTypeIdentifier</key><string>dev.felinestatemachine.atlas.volume</string>236- <key>UTTypeConformsTo</key><array><string>public.data</string><string>public.archive</string></array>237- <key>UTTypeDescription</key><string>Atlas Volume</string>238- <key>UTTypeTagSpecification</key><dict><key>public.filename-extension</key><array><string>atlas</string></array></dict>239- </dict></array>240- <key>LSSupportsOpeningDocumentsInPlace</key><true/>241- <key>LSMinimumSystemVersion</key><string>12.0</string>242- <key>NSHighResolutionCapable</key><true/>243- </dict>244- </plist>245- EOF246- ditto -c -k --keepParent Atlas.app "dist/${NAME}.zip"247- ;;248- esac235+ - name: Package Windows installer236+ if: matrix.target == 'windows-amd64'237+ shell: pwsh238+ run: ./scripts/release/package-windows.ps1 -Version "${{ steps.metadata.outputs.version }}" -Binary "${{ github.workspace }}\Atlas.exe" -Dist "${{ github.workspace }}\dist"239+240+ - name: Sign Windows installer when credentials are configured241+ if: matrix.target == 'windows-amd64'242+ shell: pwsh243+ env:244+ WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}245+ WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}246+ run: ./scripts/release/sign-windows.ps1 -Path "dist/Atlas-${{ steps.metadata.outputs.version }}-windows-amd64-setup.exe"247+248+ - name: Smoke Windows install and .atlas association249+ if: matrix.target == 'windows-amd64'250+ shell: pwsh251+ run: |252+ $installer = "dist/Atlas-${{ steps.metadata.outputs.version }}-windows-amd64-setup.exe"253+ & $installer /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER254+ if ($LASTEXITCODE -ne 0) { throw "installer exited $LASTEXITCODE" }255+ $association = (Get-Item "HKCU:\Software\Classes\.atlas").GetValue("")256+ if ($association -ne "Atlas.Volume") { throw "unexpected .atlas association: $association" }257+ $command = (Get-Item "HKCU:\Software\Classes\Atlas.Volume\shell\open\command").GetValue("")258+ if ($command -notmatch 'Atlas\.exe.*%1') { throw "unexpected open command: $command" }249259 250260 - uses: actions/upload-artifact@v4251261 with:252262 name: Atlas-${{ matrix.target }}253263 path: |264+ dist/*.deb265+ dist/*.exe254266 dist/*.tar.gz255267 dist/*.zip256268 if-no-files-found: error
@@ -269,25 +281,9 @@ jobs:
269281 merge-multiple: true270282 - name: Verify the exact asset set and smoke the packaged CLI271283 run: |272- mapfile -t ASSETS < <(find dist -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) -printf '%f\n' | sort)273- [[ "${#ASSETS[@]}" -eq 8 ]] || { printf 'wanted 8 release archives, found %s\n' "${#ASSETS[@]}"; printf '%s\n' "${ASSETS[@]}"; exit 1; }274- [[ "$(printf '%s\n' "${ASSETS[@]}" | sort -u | wc -l)" -eq 8 ]]275-276- CLI_ARCHIVE="$(find dist -maxdepth 1 -type f -name 'atlas-cli-*-linux-amd64.tar.gz' -print -quit)"277- [[ -n "${CLI_ARCHIVE}" ]]278- mkdir -p smoke279- tar -xzf "${CLI_ARCHIVE}" -C smoke280- CLI="$(find smoke -type f -name atlas -perm -u+x -print -quit)"281- [[ -n "${CLI}" ]]282- "${CLI}" --help >/dev/null283- "${CLI}" --version | grep -F "${GITHUB_REF_NAME#atlas-}"284- CACHE="$(mktemp -d)"285- LIBRARY="$(mktemp -d)"286- "${CLI}" build -cache "${CACHE}" -bundles "${LIBRARY}" examples/sample-region.atlas-project287- [[ "$(find "${LIBRARY}" -maxdepth 1 -type f -name '*.atlas' | wc -l)" -eq 1 ]]288-289- (cd dist && sha256sum "${ASSETS[@]}" > SHA256SUMS)290- [[ "$(wc -l < dist/SHA256SUMS)" -eq 8 ]]284+ VERSION="${GITHUB_REF_NAME#atlas-}"285+ scripts/release/verify-assets.sh dist "$VERSION"286+ scripts/release/package-smoke.sh dist "$VERSION" "$GITHUB_WORKSPACE"291287 292288 - name: Publish a complete release transaction293289 env:
@@ -306,4 +302,18 @@ jobs:
306302 mapfile -t REMOTE < <(gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name' | sort)307303 [[ "${#LOCAL[@]}" -eq 9 && "${#REMOTE[@]}" -eq 9 ]]308304 diff -u <(printf '%s\n' "${LOCAL[@]}") <(printf '%s\n' "${REMOTE[@]}")305+306+ while read -r CHECKSUM NAME; do307+ REMOTE_DIGEST="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" \308+ --jq ".assets[] | select(.name == \"${NAME}\") | .digest")"309+ [[ "${REMOTE_DIGEST}" == "sha256:${CHECKSUM}" ]] || {310+ printf 'remote digest mismatch for %s: wanted sha256:%s, got %s\n' \311+ "${NAME}" "${CHECKSUM}" "${REMOTE_DIGEST}" >&2312+ exit 1313+ }314+ done < dist/SHA256SUMS315+ CHECKSUM="$(sha256sum dist/SHA256SUMS | cut -d ' ' -f 1)"316+ REMOTE_DIGEST="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" \317+ --jq '.assets[] | select(.name == "SHA256SUMS") | .digest')"318+ [[ "${REMOTE_DIGEST}" == "sha256:${CHECKSUM}" ]]309319 gh release edit "${GITHUB_REF_NAME}" --draft=false
M Makefile +9 -27
@@ -3,7 +3,7 @@
33 # These targets are the enforcement surface; the existing build recipes are44 # untouched and still live where they always did.55 6-.PHONY: test test-e2e corpus-smoke spec depcheck lint-lanes analysis-lane render-lane seam seam-watch static serve-static desktop6+.PHONY: test test-e2e release-packaging corpus-smoke spec depcheck lint-lanes analysis-lane render-lane seam seam-watch static serve-static desktop77 88 # The one entrypoint: every required gate, and nothing that can silently99 # decline to judge. Go tests run through tools/testgate, which fails the run
@@ -17,6 +17,7 @@ test:
1717 npm run --silent lane1818 npm run --silent seam-lane1919 go run ./tools/depcheck20+ $(MAKE) release-packaging2021 2122 # The application in a real browser, over a registry packed from the committed2223 # corpus. Needs a Playwright Chromium (npx playwright install chromium); every
@@ -25,6 +26,12 @@ test-e2e: static
2526 go run ./tests/e2e/prep2627 npx playwright test --config tests/e2e/playwright.config.ts2728 29+# Native packaging is executable release policy: every OS declares the same30+# .atlas identity, and publication stays draft-first until exact assets match.31+# This is static and hermetic; platform runners smoke the installers themselves.32+release-packaging:33+ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests/release/test_packaging.py34+2835 # The maintainer's deep check, and deliberately not a CI gate: walk a real2936 # installed library and hold every current-format bundle to the reader's3037 # invariants. Compares no stamps, no hashes, no content.
@@ -102,29 +109,4 @@ desktop: static
102109 go build -tags "desktop,production" -ldflags "-s -w" -o Atlas .103110 mkdir -p Atlas.app/Contents/MacOS104111 cp Atlas Atlas.app/Contents/MacOS/Atlas105- printf '%s\n' \106- '<?xml version="1.0" encoding="UTF-8"?>' \107- '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \108- '<plist version="1.0">' \109- '<dict>' \110- ' <key>CFBundleExecutable</key><string>Atlas</string>' \111- ' <key>CFBundleIdentifier</key><string>dev.felinestatemachine.atlas</string>' \112- ' <key>CFBundleName</key><string>Atlas</string>' \113- ' <key>CFBundlePackageType</key><string>APPL</string>' \114- ' <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>' \115- ' <key>CFBundleDocumentTypes</key><array><dict>' \116- ' <key>CFBundleTypeName</key><string>Atlas Volume</string>' \117- ' <key>CFBundleTypeRole</key><string>Viewer</string>' \118- ' <key>LSItemContentTypes</key><array><string>dev.felinestatemachine.atlas.volume</string></array>' \119- ' </dict></array>' \120- ' <key>UTExportedTypeDeclarations</key><array><dict>' \121- ' <key>UTTypeIdentifier</key><string>dev.felinestatemachine.atlas.volume</string>' \122- ' <key>UTTypeConformsTo</key><array><string>public.data</string><string>public.archive</string></array>' \123- ' <key>UTTypeDescription</key><string>Atlas Volume</string>' \124- ' <key>UTTypeTagSpecification</key><dict><key>public.filename-extension</key><array><string>atlas</string></array></dict>' \125- ' </dict></array>' \126- ' <key>LSSupportsOpeningDocumentsInPlace</key><true/>' \127- ' <key>LSMinimumSystemVersion</key><string>12.0</string>' \128- ' <key>NSHighResolutionCapable</key><true/>' \129- '</dict>' \130- '</plist>' > Atlas.app/Contents/Info.plist112+ sed 's/@VERSION@/0.0.0/g' packaging/macos/Info.plist > Atlas.app/Contents/Info.plist
M docs/README.md +1 -0
M docs/authoring.md +14 -3
@@ -124,9 +124,20 @@ current single feature pack and per-tile ZIP layout scale without limit. The
124124 next storage-generation break for genuinely massive worlds is partitioned125125 feature blocks plus deterministic raster shard packs and range/page APIs.126126 127-Release publication runs correctness and browser acceptance first, smokes a128-packaged CLI by building Sample Region, generates checksums for the exact asset129-set, uploads to a draft, verifies every remote asset name, and only then makes127+Release publication runs correctness and browser acceptance first, then builds128+native desktop delivery on each target OS. macOS exports the Atlas UTI from a129+signed/notarizable application bundle, Linux installs a desktop entry and130+shared MIME declaration from a `.deb`, and Windows installs and immediately131+smokes its per-user `.atlas` association. Signing steps are inert when their132+CI credentials are absent; adding the documented release secrets activates133+the same package path rather than a second release recipe.134+135+The release job checks the exact eight-asset allowlist, validates every package136+payload, and drives the packaged CLI through online and cache-only Sample137+Region builds. Those two `.atlas` files must be byte-identical and readable by138+the packaged measurement command. Only then does it generate and verify139+`SHA256SUMS`, upload all nine files to a draft, compare the complete local and140+remote asset sets, verify each remote asset's GitHub SHA-256 digest, and make130141 the release public. Both CLI and desktop binaries answer `--version` from the131142 release tag.132143
A docs/releasing.md +52 -0
@@ -0,0 +1,52 @@
1+# Native releases2+3+Atlas publishes five portable CLI archives and one desktop package per OS.4+The desktop packages own `.atlas` consistently:5+6+| OS | Package | Native registration |7+|---|---|---|8+| macOS | `Atlas-<version>-darwin-arm64.zip` | exported UTI `dev.felinestatemachine.atlas.volume` |9+| Linux | `Atlas-<version>-linux-amd64.deb` | shared MIME `application/vnd.felinestatemachine.atlas` |10+| Windows | `Atlas-<version>-windows-amd64-setup.exe` | `Atlas.Volume` per-user file association |11+12+The metadata lives in `packaging/`; release scripts consume those files rather13+than reproducing them in workflow YAML. `make release-packaging` parses and14+checks that contract locally.15+16+## Signing inputs17+18+Unsigned workflow-dispatch builds exercise the same package recipes. A tagged19+release becomes signed when the corresponding GitHub Actions secrets exist:20+21+| Secret | Purpose |22+|---|---|23+| `MACOS_CERTIFICATE_P12_BASE64` | base64 Developer ID Application certificate |24+| `MACOS_CERTIFICATE_PASSWORD` | certificate password |25+| `MACOS_SIGNING_IDENTITY` | exact `codesign` identity |26+| `MACOS_NOTARY_KEY_BASE64` | base64 App Store Connect `.p8` key |27+| `MACOS_NOTARY_KEY_ID` | App Store Connect key ID |28+| `MACOS_NOTARY_ISSUER_ID` | App Store Connect issuer UUID |29+| `WINDOWS_CERTIFICATE_BASE64` | base64 Authenticode `.pfx` |30+| `WINDOWS_CERTIFICATE_PASSWORD` | certificate password |31+32+macOS signing uses hardened runtime and timestamping, submits with33+`notarytool`, staples the accepted ticket, and recreates the final ZIP. Windows34+signs and verifies both `Atlas.exe` and the installer with SHA-256 and a public35+timestamp service. No key material is written to the repository or uploaded as36+an artifact.37+38+## Publication transaction39+40+Tag publication is deliberately one-way:41+42+1. required unit, compatibility-corpus, browser, and packaging gates pass;43+2. all platform jobs produce the exact eight allowed assets;44+3. packaged smoke builds Sample Region online and cache-only, requires45+ byte-identical `.atlas` output, and reads the result through `measure`;46+4. `SHA256SUMS` is generated and verified over the allowlist;47+5. all nine files upload to a draft release;48+6. remote names must exactly equal local names and every GitHub asset digest49+ must equal its local SHA-256 before the draft becomes public.50+51+An upload or comparison failure leaves a non-public draft for diagnosis; a52+partial public release is never produced.
M docs/testing.md +8 -0
A packaging/linux/control +10 -0
A packaging/linux/dev.felinestatemachine.Atlas.desktop +9 -0
A packaging/linux/dev.felinestatemachine.atlas.xml +7 -0
A packaging/linux/postinst +8 -0
A packaging/linux/postrm +8 -0
A packaging/macos/Info.plist +40 -0
@@ -0,0 +1,40 @@
1+<?xml version="1.0" encoding="UTF-8"?>2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">3+<plist version="1.0">4+<dict>5+ <key>CFBundleExecutable</key><string>Atlas</string>6+ <key>CFBundleIdentifier</key><string>dev.felinestatemachine.atlas</string>7+ <key>CFBundleName</key><string>Atlas</string>8+ <key>CFBundlePackageType</key><string>APPL</string>9+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>10+ <key>CFBundleShortVersionString</key><string>@VERSION@</string>11+ <key>CFBundleVersion</key><string>@VERSION@</string>12+ <key>CFBundleDocumentTypes</key>13+ <array>14+ <dict>15+ <key>CFBundleTypeName</key><string>Atlas Volume</string>16+ <key>CFBundleTypeRole</key><string>Viewer</string>17+ <key>LSHandlerRank</key><string>Owner</string>18+ <key>LSItemContentTypes</key>19+ <array><string>dev.felinestatemachine.atlas.volume</string></array>20+ </dict>21+ </array>22+ <key>UTExportedTypeDeclarations</key>23+ <array>24+ <dict>25+ <key>UTTypeIdentifier</key><string>dev.felinestatemachine.atlas.volume</string>26+ <key>UTTypeConformsTo</key>27+ <array><string>public.data</string><string>public.archive</string></array>28+ <key>UTTypeDescription</key><string>Atlas Volume</string>29+ <key>UTTypeTagSpecification</key>30+ <dict>31+ <key>public.filename-extension</key><array><string>atlas</string></array>32+ <key>public.mime-type</key><string>application/vnd.felinestatemachine.atlas</string>33+ </dict>34+ </dict>35+ </array>36+ <key>LSSupportsOpeningDocumentsInPlace</key><true/>37+ <key>LSMinimumSystemVersion</key><string>12.0</string>38+ <key>NSHighResolutionCapable</key><true/>39+</dict>40+</plist>
A packaging/windows/Atlas.iss +59 -0
@@ -0,0 +1,59 @@
1+#ifndef MyAppVersion2+ #define MyAppVersion "dev"3+#endif4+#ifndef MyAppExe5+ #define MyAppExe "Atlas.exe"6+#endif7+#ifndef OutputDir8+ #define OutputDir "."9+#endif10+#ifndef OutputBase11+ #define OutputBase "Atlas-setup"12+#endif13+14+[Setup]15+AppId={{CDE7D85C-293E-4E31-A815-87A08AC04E48}16+AppName=Atlas17+AppVersion={#MyAppVersion}18+AppPublisher=Feline State Machine19+DefaultDirName={autopf}\Atlas20+DefaultGroupName=Atlas21+UninstallDisplayName=Atlas22+ArchitecturesAllowed=x64compatible23+ArchitecturesInstallIn64BitMode=x64compatible24+PrivilegesRequired=lowest25+PrivilegesRequiredOverridesAllowed=dialog commandline26+ChangesAssociations=yes27+Compression=lzma228+SolidCompression=yes29+OutputDir={#OutputDir}30+OutputBaseFilename={#OutputBase}31+WizardStyle=modern32+33+[Files]34+Source: "{#MyAppExe}"; DestDir: "{app}"; Flags: ignoreversion35+36+[Icons]37+Name: "{autoprograms}\Atlas"; Filename: "{app}\Atlas.exe"38+39+[Registry]40+Root: HKA; Subkey: "Software\Classes\.atlas"; ValueType: string; ValueName: ""; ValueData: "Atlas.Volume"; Flags: uninsdeletevalue41+Root: HKA; Subkey: "Software\Classes\.atlas"; ValueType: string; ValueName: "Content Type"; ValueData: "application/vnd.felinestatemachine.atlas"; Flags: uninsdeletevalue42+Root: HKA; Subkey: "Software\Classes\.atlas"; ValueType: string; ValueName: "PerceivedType"; ValueData: "document"; Flags: uninsdeletevalue43+Root: HKA; Subkey: "Software\Classes\Atlas.Volume"; ValueType: string; ValueName: ""; ValueData: "Atlas Volume"; Flags: uninsdeletekey44+Root: HKA; Subkey: "Software\Classes\Atlas.Volume\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\Atlas.exe,0"45+Root: HKA; Subkey: "Software\Classes\Atlas.Volume\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\Atlas.exe"" ""%1"""46+47+[Code]48+const49+ SHCNE_ASSOCCHANGED = $08000000;50+ SHCNF_IDLIST = $0000;51+52+procedure SHChangeNotify(wEventId: LongWord; uFlags: Cardinal; dwItem1, dwItem2: Integer);53+ external 'SHChangeNotify@shell32.dll stdcall';54+55+procedure CurStepChanged(CurStep: TSetupStep);56+begin57+ if CurStep = ssPostInstall then58+ SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, 0, 0);59+end;
A scripts/release/package-desktop.sh +59 -0
@@ -0,0 +1,59 @@
1+#!/usr/bin/env bash2+set -euo pipefail3+4+if [[ $# -ne 4 ]]; then5+ echo "usage: package-desktop.sh <linux-amd64|darwin-arm64> <version> <binary> <dist>" >&26+ exit 27+fi8+9+target=$110+version=$211+binary=$312+dist=$413+root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)14+version_number=${version#v}15+mkdir -p "$dist"16+17+case "$target" in18+ darwin-arm64)19+ mac_version=0.0.020+ if [[ "$version_number" =~ ^[0-9]+(\.[0-9]+){0,2} ]]; then21+ mac_version=${BASH_REMATCH[0]}22+ fi23+ stage=$(mktemp -d)24+ trap 'rm -rf "$stage"' EXIT25+ app="$stage/Atlas.app"26+ mkdir -p "$app/Contents/MacOS"27+ cp "$binary" "$app/Contents/MacOS/Atlas"28+ sed "s/@VERSION@/$mac_version/g" "$root/packaging/macos/Info.plist" > "$app/Contents/Info.plist"29+ "$root/scripts/release/sign-macos.sh" "$app" "$dist/Atlas-$version-darwin-arm64.zip"30+ ;;31+ linux-amd64)32+ deb_version=$version_number33+ if [[ ! "$deb_version" =~ ^[0-9] ]]; then34+ deb_version="0~$deb_version"35+ fi36+ stage=$(mktemp -d)37+ trap 'rm -rf "$stage"' EXIT38+ mkdir -p \39+ "$stage/DEBIAN" \40+ "$stage/usr/bin" \41+ "$stage/usr/share/applications" \42+ "$stage/usr/share/mime/packages" \43+ "$stage/usr/share/doc/atlas-desktop"44+ cp "$binary" "$stage/usr/bin/Atlas"45+ cp "$root/packaging/linux/dev.felinestatemachine.Atlas.desktop" "$stage/usr/share/applications/"46+ cp "$root/packaging/linux/dev.felinestatemachine.atlas.xml" "$stage/usr/share/mime/packages/"47+ sed -e "s/@VERSION@/$deb_version/g" -e "s/@ARCH@/amd64/g" \48+ "$root/packaging/linux/control" > "$stage/DEBIAN/control"49+ install -m 0755 "$root/packaging/linux/postinst" "$stage/DEBIAN/postinst"50+ install -m 0755 "$root/packaging/linux/postrm" "$stage/DEBIAN/postrm"51+ cp "$root/LICENSE" "$stage/usr/share/doc/atlas-desktop/copyright"52+ chmod 0755 "$stage/usr/bin/Atlas"53+ dpkg-deb --root-owner-group --build "$stage" "$dist/Atlas-$version-linux-amd64.deb"54+ ;;55+ *)56+ echo "unsupported desktop target: $target" >&257+ exit 258+ ;;59+esac
A scripts/release/package-smoke.sh +37 -0
@@ -0,0 +1,37 @@
1+#!/usr/bin/env bash2+set -euo pipefail3+4+if [[ $# -ne 3 ]]; then5+ echo "usage: package-smoke.sh <dist> <version> <repo-root>" >&26+ exit 27+fi8+9+dist=$110+version=$211+root=$312+smoke=$(mktemp -d)13+trap 'rm -rf "$smoke"' EXIT14+15+unzip -Z1 "$dist/Atlas-$version-darwin-arm64.zip" | grep -Fx 'Atlas.app/Contents/Info.plist'16+unzip -Z1 "$dist/Atlas-$version-darwin-arm64.zip" | grep -Fx 'Atlas.app/Contents/MacOS/Atlas'17+dpkg-deb --info "$dist/Atlas-$version-linux-amd64.deb" >/dev/null18+dpkg-deb --contents "$dist/Atlas-$version-linux-amd64.deb" | grep -F 'usr/share/applications/dev.felinestatemachine.Atlas.desktop'19+dpkg-deb --contents "$dist/Atlas-$version-linux-amd64.deb" | grep -F 'usr/share/mime/packages/dev.felinestatemachine.atlas.xml'20+21+tar -xzf "$dist/atlas-cli-$version-linux-amd64.tar.gz" -C "$smoke"22+cli=$(find "$smoke" -type f -name atlas -perm -u+x -print -quit)23+[[ -n "$cli" ]]24+"$cli" --help >/dev/null25+"$cli" --version | grep -F "$version"26+27+cache="$smoke/cache"28+online="$smoke/online"29+offline="$smoke/offline"30+mkdir -p "$cache" "$online" "$offline"31+"$cli" build -cache "$cache" -bundles "$online" "$root/examples/sample-region.atlas-project"32+"$cli" build -offline -cache "$cache" -bundles "$offline" "$root/examples/sample-region.atlas-project"33+online_atlas=$(find "$online" -maxdepth 1 -type f -name '*.atlas' -print -quit)34+offline_atlas=$(find "$offline" -maxdepth 1 -type f -name '*.atlas' -print -quit)35+[[ -n "$online_atlas" && -n "$offline_atlas" ]]36+cmp "$online_atlas" "$offline_atlas"37+"$cli" measure -bundles "$online" -json sample-region | grep -F 'sample-region'
A scripts/release/package-windows.ps1 +24 -0
A scripts/release/sign-macos.sh +57 -0
@@ -0,0 +1,57 @@
1+#!/usr/bin/env bash2+set -euo pipefail3+4+if [[ $# -ne 2 ]]; then5+ echo "usage: sign-macos.sh <Atlas.app> <output.zip>" >&26+ exit 27+fi8+9+app=$110+output=$211+keychain=""12+certificate=""13+notary_key=""14+cleanup() {15+ if [[ -n "$keychain" ]]; then16+ security delete-keychain "$keychain" >/dev/null 2>&1 || true17+ fi18+ [[ -z "$certificate" ]] || rm -f "$certificate"19+ [[ -z "$notary_key" ]] || rm -f "$notary_key"20+}21+trap cleanup EXIT22+23+if [[ -n "${MACOS_CERTIFICATE_P12_BASE64:-}" ]]; then24+ : "${MACOS_CERTIFICATE_PASSWORD:?required with MACOS_CERTIFICATE_P12_BASE64}"25+ : "${MACOS_SIGNING_IDENTITY:?required with MACOS_CERTIFICATE_P12_BASE64}"26+ certificate="$RUNNER_TEMP/atlas-release.p12"27+ keychain="$RUNNER_TEMP/atlas-release.keychain-db"28+ printf '%s' "$MACOS_CERTIFICATE_P12_BASE64" | base64 -D > "$certificate"29+ security create-keychain -p atlas-release "$keychain"30+ security unlock-keychain -p atlas-release "$keychain"31+ security import "$certificate" -k "$keychain" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign32+ security set-key-partition-list -S apple-tool:,apple: -s -k atlas-release "$keychain"33+ codesign --force --deep --options runtime --timestamp \34+ --keychain "$keychain" --sign "$MACOS_SIGNING_IDENTITY" "$app"35+ codesign --verify --deep --strict --verbose=2 "$app"36+else37+ echo "MACOS_CERTIFICATE_P12_BASE64 is empty; packaging an unsigned app"38+fi39+40+ditto -c -k --keepParent "$app" "$output"41+42+if [[ -n "${MACOS_NOTARY_KEY_BASE64:-}" ]]; then43+ [[ -n "${MACOS_CERTIFICATE_P12_BASE64:-}" ]] || {44+ echo "MACOS_CERTIFICATE_P12_BASE64 is required when notarization is configured" >&245+ exit 246+ }47+ : "${MACOS_NOTARY_KEY_ID:?required with MACOS_NOTARY_KEY_BASE64}"48+ : "${MACOS_NOTARY_ISSUER_ID:?required with MACOS_NOTARY_KEY_BASE64}"49+ notary_key="$RUNNER_TEMP/AuthKey_$MACOS_NOTARY_KEY_ID.p8"50+ printf '%s' "$MACOS_NOTARY_KEY_BASE64" | base64 -D > "$notary_key"51+ xcrun notarytool submit "$output" --wait \52+ --key "$notary_key" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID"53+ xcrun stapler staple "$app"54+ xcrun stapler validate "$app"55+ rm -f "$output"56+ ditto -c -k --keepParent "$app" "$output"57+fi
A scripts/release/sign-windows.ps1 +27 -0
A scripts/release/verify-assets.sh +38 -0
A tests/release/test_packaging.py +103 -0
@@ -0,0 +1,103 @@
1+"""Static acceptance for the native packages produced by release.yml."""2+3+from pathlib import Path4+import plistlib5+import subprocess6+import tempfile7+import unittest8+import xml.etree.ElementTree as ET9+10+11+ROOT = Path(__file__).resolve().parents[2]12+MIME = "application/vnd.felinestatemachine.atlas"13+EXTENSION = "atlas"14+15+16+class NativePackagingTest(unittest.TestCase):17+ def test_macos_exports_atlas_document_type(self) -> None:18+ with (ROOT / "packaging/macos/Info.plist").open("rb") as source:19+ info = plistlib.load(source)20+21+ self.assertEqual(info["CFBundleIdentifier"], "dev.felinestatemachine.atlas")22+ self.assertTrue(info["LSSupportsOpeningDocumentsInPlace"])23+ declaration = info["UTExportedTypeDeclarations"][0]24+ self.assertIn(EXTENSION, declaration["UTTypeTagSpecification"]["public.filename-extension"])25+ self.assertEqual(declaration["UTTypeTagSpecification"]["public.mime-type"], MIME)26+ self.assertIn(declaration["UTTypeIdentifier"], info["CFBundleDocumentTypes"][0]["LSItemContentTypes"])27+28+ def test_linux_package_registers_atlas_mime(self) -> None:29+ desktop = (ROOT / "packaging/linux/dev.felinestatemachine.Atlas.desktop").read_text()30+ self.assertIn(f"MimeType={MIME};", desktop)31+ self.assertIn("Exec=Atlas %f", desktop)32+ self.assertIn("Terminal=false", desktop)33+34+ mime = ET.parse(ROOT / "packaging/linux/dev.felinestatemachine.atlas.xml").getroot()35+ namespace = {"mime": "http://www.freedesktop.org/standards/shared-mime-info"}36+ mime_type = mime.find("mime:mime-type", namespace)37+ self.assertIsNotNone(mime_type)38+ self.assertEqual(mime_type.attrib["type"], MIME)39+ glob = mime_type.find("mime:glob", namespace)40+ self.assertEqual(glob.attrib["pattern"], f"*.{EXTENSION}")41+42+ control = (ROOT / "packaging/linux/control").read_text()43+ self.assertIn("Package: atlas-desktop", control)44+ self.assertIn("Depends: libgtk-3-0, libwebkit2gtk-4.1-0", control)45+46+ def test_windows_installer_registers_atlas_file(self) -> None:47+ installer = (ROOT / "packaging/windows/Atlas.iss").read_text()48+ self.assertIn("ChangesAssociations=yes", installer)49+ self.assertIn("PrivilegesRequired=lowest", installer)50+ self.assertIn("Software\\Classes\\.atlas", installer)51+ self.assertIn('ValueData: "Atlas.Volume"', installer)52+ self.assertIn(f'ValueData: "{MIME}"', installer)53+ self.assertIn('ValueData: """{app}\\Atlas.exe"" ""%1"""', installer)54+ self.assertIn("SHChangeNotify", installer)55+56+ def test_release_is_draft_first_and_verifies_content(self) -> None:57+ workflow = (ROOT / ".github/workflows/release.yml").read_text()58+ self.assertIn("scripts/release/verify-assets.sh", workflow)59+ self.assertIn("--draft", workflow)60+ self.assertIn("--draft=false", workflow)61+ self.assertIn(".digest", workflow)62+ self.assertIn("package-desktop.sh", workflow)63+ self.assertIn("sign-windows.ps1", workflow)64+ self.assertIn("package-smoke.sh", workflow)65+ mac_package = (ROOT / "scripts/release/package-desktop.sh").read_text()66+ self.assertIn("sign-macos.sh", mac_package)67+ self.assertIn("stage=$(mktemp -d)", mac_package)68+ self.assertNotIn('app="$root/Atlas.app"', mac_package)69+70+ mac_signer = (ROOT / "scripts/release/sign-macos.sh").read_text()71+ self.assertIn('--keychain "$keychain"', mac_signer)72+ self.assertNotIn("security list-keychains", mac_signer)73+ self.assertIn('xcrun stapler validate "$app"', mac_signer)74+75+ def test_release_asset_allowlist_is_exact(self) -> None:76+ version = "v1.2.3"77+ assets = [78+ f"Atlas-{version}-darwin-arm64.zip",79+ f"Atlas-{version}-linux-amd64.deb",80+ f"Atlas-{version}-windows-amd64-setup.exe",81+ f"atlas-cli-{version}-darwin-amd64.tar.gz",82+ f"atlas-cli-{version}-darwin-arm64.tar.gz",83+ f"atlas-cli-{version}-linux-amd64.tar.gz",84+ f"atlas-cli-{version}-linux-arm64.tar.gz",85+ f"atlas-cli-{version}-windows-amd64.zip",86+ ]87+ with tempfile.TemporaryDirectory() as directory:88+ dist = Path(directory)89+ for asset in assets:90+ (dist / asset).touch()91+92+ verifier = ROOT / "scripts/release/verify-assets.sh"93+ subprocess.run([verifier, dist, version], check=True, capture_output=True)94+ self.assertEqual(len((dist / "SHA256SUMS").read_text().splitlines()), len(assets))95+96+ (dist / "unexpected.zip").touch()97+ rejected = subprocess.run([verifier, dist, version], capture_output=True)98+ self.assertNotEqual(rejected.returncode, 0)99+ self.assertIn(b"unexpected.zip", rejected.stdout + rejected.stderr)100+101+102+if __name__ == "__main__":103+ unittest.main()