From cec3c20d80ea1dc93660b69a4e7b38aa49f2a56b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:05:44 +0100 Subject: [PATCH 1/4] ci(release): publish standalone Casket binary --- .github/workflows/actions.lock | 2 - .github/workflows/release.yml | 235 ++++++++++++++++++++------------- README.adoc | 25 +++- casket-ssg.cabal | 14 +- 4 files changed, 179 insertions(+), 97 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 4804ea2..268e042 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -40,8 +40,6 @@ workflows: - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.1' - 'actions/upload-artifact@v7.0.1' - - 'haskell-actions/setup@v2.12.0' - - 'softprops/action-gh-release@v3.0.2' '.github/workflows/sbom.yml': - 'actions/checkout@v7.0.1' - 'actions/upload-artifact@v7.0.1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a75222..470d94c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,49 +1,38 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. name: Release + on: + pull_request: + paths: + - '.github/workflows/release.yml' + - 'casket-ssg.cabal' + - 'src/**' + - 'README.adoc' + - 'LICENSE' push: tags: - 'v*' workflow_dispatch: - inputs: - version: - description: 'Version to release (e.g., 1.0.0)' - required: true - type: string -permissions: read-all + +permissions: + contents: read + +concurrency: + group: release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: - name: Build (${{ matrix.os }}-${{ matrix.arch }}) - runs-on: ${{ matrix.runner }} - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - include: - - os: linux - arch: x86_64 - runner: ubuntu-latest - - os: linux - arch: aarch64 - runner: ubuntu-24.04-arm - - os: darwin - arch: x86_64 - runner: macos-15-intel - - os: darwin - arch: aarch64 - runner: macos-latest + name: Build prebuilt Linux artifact + runs-on: ubuntu-24.04 + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@v7.0.1 - - name: Setup Haskell - # SHA fix 2026-05-25: previous pin (dd344bc...74e) did not exist - # upstream (`gh api repos/haskell-actions/setup/commits/` 422s). - # Use v2.11.0, matching the other CI workflows in this repo. - uses: haskell-actions/setup@v2.12.0 with: - ghc-version: '9.8' - cabal-version: '3.12' + persist-credentials: false + - name: Cache Cabal uses: actions/cache@v6.1.0 with: @@ -51,92 +40,158 @@ jobs: ~/.cabal/packages ~/.cabal/store dist-newstyle - key: ${{ runner.os }}-${{ matrix.arch }}-cabal-${{ hashFiles('*.cabal', 'cabal.project') }} + key: linux-x86_64-cabal-${{ hashFiles('casket-ssg.cabal') }} restore-keys: | - ${{ runner.os }}-${{ matrix.arch }}-cabal- - - name: Build + linux-x86_64-cabal- + + - name: Prepare runner Haskell toolchain run: | + ghc --version + cabal --version cabal update - cabal build --enable-executable-static || cabal build - - name: Package - run: | - VERSION="${{ github.event.inputs.version || github.ref_name }}" - VERSION="${VERSION#v}" # Remove leading 'v' if present - mkdir -p dist/casket-ssg-${VERSION} + - name: Validate release version + shell: bash + run: | + set -euo pipefail + package_version="$(awk '$1 == "version:" { print $2; exit }' casket-ssg.cabal)" + test -n "${package_version}" - # Find and copy the binary - BINARY=$(find dist-newstyle -name 'casket-ssg' -type f -executable 2>/dev/null | head -1) - if [ -z "$BINARY" ]; then - # Fallback: look for Main or the module name - BINARY=$(find dist-newstyle -name 'Casket' -type f -executable 2>/dev/null | head -1) + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + if [[ "${release_version}" != "${package_version}" ]]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match cabal version ${package_version}" + exit 1 + fi fi - if [ -n "$BINARY" ]; then - cp "$BINARY" dist/casket-ssg-${VERSION}/casket-ssg - chmod +x dist/casket-ssg-${VERSION}/casket-ssg - else - echo "::error::Could not find built binary" - find dist-newstyle -type f -executable + echo "PACKAGE_VERSION=${package_version}" >> "${GITHUB_ENV}" + + - name: Build optimised core executable + run: | + cabal build exe:casket-ssg \ + --enable-optimization=2 \ + --flags=-lol-i18n + + - name: Exercise executable and site build + shell: bash + run: | + set -euo pipefail + binary="$(cabal list-bin exe:casket-ssg \ + --enable-optimization=2 \ + --flags=-lol-i18n)" + test -x "${binary}" + "${binary}" --version | tee "${RUNNER_TEMP}/casket-version.txt" + grep -F "v${PACKAGE_VERSION}" "${RUNNER_TEMP}/casket-version.txt" + + if ldd "${binary}" | grep -q 'liblol'; then + echo "::error::The core executable unexpectedly requires liblol" exit 1 fi - # Copy documentation - cp README.adoc dist/casket-ssg-${VERSION}/ || cp README.md dist/casket-ssg-${VERSION}/ || true - cp LICENSE dist/casket-ssg-${VERSION}/ || true + smoke_root="${RUNNER_TEMP}/casket-smoke" + mkdir -p "${smoke_root}/content" + printf '%s\n' \ + '---' \ + 'title: Release smoke test' \ + '---' \ + '# Release smoke test' \ + '' \ + 'A real generated page.' \ + > "${smoke_root}/content/index.md" + "${binary}" build "${smoke_root}/content" "${smoke_root}/site" + test -s "${smoke_root}/site/index.html" + grep -F '

Release smoke test

' \ + "${smoke_root}/site/index.html" - # Create tarball - cd dist - ARCHIVE="casket-ssg-${{ matrix.arch }}-${{ matrix.os }}-${VERSION}.tar.gz" - tar -czvf "${ARCHIVE}" casket-ssg-${VERSION} + echo "CASKET_BINARY=${binary}" >> "${GITHUB_ENV}" - # Create checksum - if command -v sha256sum &>/dev/null; then - sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256" - else - shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" - fi + - name: Package deterministic archive + shell: bash + run: | + set -euo pipefail + package_name="casket-ssg-${PACKAGE_VERSION}-x86_64-linux" + package_root="${RUNNER_TEMP}/package" + package_dir="${package_root}/${package_name}" + archive="${GITHUB_WORKSPACE}/dist/${package_name}.tar.gz" + mkdir -p "${package_dir}" "${GITHUB_WORKSPACE}/dist" - echo "ARCHIVE=${ARCHIVE}" >> $GITHUB_ENV - echo "VERSION=${VERSION}" >> $GITHUB_ENV - - name: Upload artifact + install -m 0755 "${CASKET_BINARY}" "${package_dir}/casket-ssg" + strip "${package_dir}/casket-ssg" + cp README.adoc LICENSE "${package_dir}/" + + { + echo "casket-ssg=${PACKAGE_VERSION}" + echo "source-revision=${GITHUB_SHA}" + echo "target=x86_64-linux-gnu" + echo "variant=core" + echo "lol-i18n=disabled" + ghc --version + cabal --version | head -n 1 + ldd "${package_dir}/casket-ssg" + } > "${package_dir}/BUILD-METADATA.txt" + + source_date_epoch="$(git show -s --format=%ct HEAD)" + tar \ + --sort=name \ + --mtime="@${source_date_epoch}" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + -C "${package_root}" \ + -cf - "${package_name}" \ + | gzip -n > "${archive}" + + archive_name="$(basename "${archive}")" + cd "${GITHUB_WORKSPACE}/dist" + sha256sum "${archive_name}" > "${archive_name}.sha256" + sha256sum -c "${archive_name}.sha256" + tar -tzf "${archive_name}" + + - name: Upload workflow artifact uses: actions/upload-artifact@v7.0.1 with: - name: casket-ssg-${{ matrix.arch }}-${{ matrix.os }} + name: casket-ssg-x86_64-linux path: | dist/*.tar.gz dist/*.sha256 + if-no-files-found: error + retention-days: 7 + release: - name: Create Release + name: Publish immutable-tag release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') needs: build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: + attestations: write contents: write id-token: write - attestations: write steps: - - name: Download all artifacts + - name: Download built artifact uses: actions/download-artifact@v8.0.1 with: + name: casket-ssg-x86_64-linux path: artifacts - merge-multiple: true - - name: List artifacts - run: | - find artifacts -type f - - name: Create Release - uses: softprops/action-gh-release@v3.0.2 - with: - files: | - artifacts/*.tar.gz - artifacts/*.sha256 - generate_release_notes: true - fail_on_unmatched_files: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify checksums + working-directory: artifacts + run: sha256sum -c -- ./*.sha256 + - name: Attest build provenance uses: actions/attest-build-provenance@v4.2.2 with: - subject-path: | - artifacts/*.tar.gz - artifacts/*.sha256 + subject-path: artifacts/*.tar.gz + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + artifacts/*.tar.gz \ + artifacts/*.sha256 \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --title "Casket-SSG ${GITHUB_REF_NAME}" \ + --generate-notes diff --git a/README.adoc b/README.adoc index dc9c3dd..d1938eb 100644 --- a/README.adoc +++ b/README.adoc @@ -70,7 +70,13 @@ No Node, no npm, no required config file. == Installation -casket is not yet on Hackage — build from source (GHC 9.4+, Cabal 3 or +Tagged releases provide a checksummed, provenance-attested core binary for +64-bit Linux. The core build has no LoL native-library requirement. Download +the archive and its adjacent `+.sha256+` file from the +https://github.com/hyperpolymath/casket-ssg/releases[GitHub Releases page], +then verify the archive before extracting it. + +casket is not yet on Hackage. To build from source (GHC 9.4+, Cabal 3 or Stack 2): [source,bash] @@ -81,6 +87,20 @@ stack build # or: cabal build stack run casket-ssg -- --version # or put the built binary on PATH ---- +The experimental https://github.com/hyperpolymath/lol[LoL] native bridge is +an explicit compile-time option: + +[source,bash] +---- +cabal build -flol-i18n --extra-lib-dirs=/path/to/liblol/lib +---- + +That option requires a compatible `+liblol+` (currently Zig 0.15.2) and +compiles the Haskell FFI bridge. It does not yet activate translation or +locale-aware rendering in the casket CLI; that product integration remains +planned. Keeping it opt-in prevents an unused native dependency from blocking +the core site generator while preserving a clear integration boundary. + CLI: [source,bash] @@ -146,6 +166,8 @@ code, footnotes, task lists, strikethrough, smart punctuation * `+a2ml+`-native typed content + the Djot core; Pandoc relegated to an importer * `+boj+` cartridge registration; `+llms.txt+` / per-page machine views +* LoL-backed translation and locale-aware rendering through the optional +`+lol-i18n+` native bridge == Documentation @@ -172,6 +194,7 @@ cartridge. * GHC 9.4+ (CI builds with 9.4.8), Cabal 3.0+ or Stack 2.0+ * Pandoc (current library dependency; see ADR-0001 for the planned move off it) +* Optional LoL bridge: a compatible `+liblol+`; not required by core builds == License diff --git a/casket-ssg.cabal b/casket-ssg.cabal index 9126b6a..d416e70 100644 --- a/casket-ssg.cabal +++ b/casket-ssg.cabal @@ -14,6 +14,11 @@ category: Web build-type: Simple extra-source-files: README.adoc +flag lol-i18n + description: Build the experimental LoL native i18n bridge (requires liblol) + default: False + manual: True + executable casket-ssg main-is: CasketGnosis.hs other-modules: Gnosis.Types @@ -22,10 +27,9 @@ executable casket-ssg , Gnosis.Paxos , Gnosis.SixSCM , Gnosis.DAX - , Gnosis.I18n hs-source-dirs: src default-language: Haskell2010 - ghc-options: -Wall -O2 + ghc-options: -Wall build-depends: base >= 4.14 && < 5 , containers >= 0.6 , text >= 1.2 @@ -34,8 +38,10 @@ executable casket-ssg , filepath >= 1.4 , pandoc >= 3.0 , pandoc-types >= 1.23 - , text-icu >= 0.7 - extra-libraries: lol + + if flag(lol-i18n) + other-modules: Gnosis.I18n + extra-libraries: lol source-repository head type: git From 9c709486ad7830c6ac25657cbe236a2922922011 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:27:04 +0100 Subject: [PATCH 2/4] chore(i18n): declare bridge licence --- src/Gnosis/I18n.hs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Gnosis/I18n.hs b/src/Gnosis/I18n.hs index 01cbed4..b5e7b87 100755 --- a/src/Gnosis/I18n.hs +++ b/src/Gnosis/I18n.hs @@ -1,3 +1,4 @@ +-- SPDX-License-Identifier: MPL-2.0 {-# LANGUAGE ForeignFunctionInterface #-} {-# LANGUAGE EmptyDataDecls #-} {-# LANGUAGE OverloadedStrings #-} @@ -13,13 +14,10 @@ module Gnosis.I18n ( ) where import Foreign -import Foreign.C.Types import Foreign.C.String import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Foreign as TF -import System.IO.Unsafe (unsafePerformIO) -import Foreign.Ptr (nullPtr) data LolHandleStruct type LolHandle = Ptr LolHandleStruct From fdf4504442eeb5072f0422ec0c61e331922495f3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:29:07 +0100 Subject: [PATCH 3/4] ci(release): make provenance instructions reproducible --- .github/workflows/release.yml | 3 ++- README.adoc | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 470d94c..512350c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,8 @@ jobs: echo "lol-i18n=disabled" ghc --version cabal --version | head -n 1 - ldd "${package_dir}/casket-ssg" + readelf -d "${package_dir}/casket-ssg" \ + | awk '$2 == "(NEEDED)" { gsub(/[][]/, "", $5); print "needed=" $5 }' } > "${package_dir}/BUILD-METADATA.txt" source_date_epoch="$(git show -s --format=%ct HEAD)" diff --git a/README.adoc b/README.adoc index d1938eb..f91d9dc 100644 --- a/README.adoc +++ b/README.adoc @@ -71,10 +71,17 @@ No Node, no npm, no required config file. == Installation Tagged releases provide a checksummed, provenance-attested core binary for -64-bit Linux. The core build has no LoL native-library requirement. Download +Linux x86-64. The core build has no LoL native-library requirement. Download the archive and its adjacent `+.sha256+` file from the https://github.com/hyperpolymath/casket-ssg/releases[GitHub Releases page], -then verify the archive before extracting it. +then verify both its checksum and GitHub build provenance before extracting it: + +[source,bash] +---- +sha256sum -c casket-ssg-1.0.0-x86_64-linux.tar.gz.sha256 +gh attestation verify casket-ssg-1.0.0-x86_64-linux.tar.gz \ + --repo hyperpolymath/casket-ssg +---- casket is not yet on Hackage. To build from source (GHC 9.4+, Cabal 3 or Stack 2): From 892ffc5e8d25c74598fa0a1aa95f7d085e883557 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:35:10 +0100 Subject: [PATCH 4/4] ci: make core Haskell checks dispatchable --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c00c3b..bf2b694 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: build: runs-on: ubuntu-latest @@ -14,11 +15,8 @@ jobs: steps: - uses: actions/checkout@v7.0.1 - name: Set up GHC - # SHA pinned to v2.7.10 (current at 2026-05-25). The previous pin - # `dd344bc1cec854a9b2f7029b98379ce6c1accc98` did not exist on the - # upstream repo — GitHub Actions failed to resolve it and every - # CI run errored before any GHC step ran. This SHA is the same - # one used elsewhere in this repo (sbom.yml, casket-ci.yml). + # The visible release tag is resolved to an immutable commit by + # actions.lock, and repository policy requires that locked SHA. uses: haskell-actions/setup@v2.12.0 with: ghc-version: '9.4'