diff --git a/.formatter.exs b/.formatter.exs index b551cdc..395b04d 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,7 +1,7 @@ [ inputs: [ "{mix,.formatter,.credo}.exs", - "{config,lib,test}/**/*.{ex,exs}" + "{lib,test}/**/*.{ex,exs}" ], line_length: 120 ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f1eaac..b4944bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ concurrency: env: MIX_ENV: test + # Build the NIF from source: no release has the precompiled NIF of the code under test. + WHISPER_CPP_BUILD: "1" ELIXIR_VERSION: "1.20.5" OTP_VERSION: "29.1.2" RUST_TOOLCHAIN: "1.99.1" diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 9a17fcc..dd727ac 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -18,6 +18,8 @@ concurrency: env: MIX_ENV: test + # Build the NIF from source: no release has the precompiled NIF of the code under test. + WHISPER_CPP_BUILD: "1" ELIXIR_VERSION: "1.20.5" OTP_VERSION: "29.1.2" RUST_TOOLCHAIN: "1.99.1" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 567de04..655c506 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ jobs: cancel-in-progress: false outputs: should_release: ${{ steps.resolve.outputs.should_release }} + should_publish: ${{ steps.resolve.outputs.should_publish }} tag: ${{ steps.resolve.outputs.tag }} version: ${{ steps.resolve.outputs.version }} sha: ${{ steps.resolve.outputs.sha }} @@ -48,6 +49,7 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} INPUT_TAG: ${{ inputs.tag }} + BEFORE: ${{ github.event.before }} run: | VERSION="$(sed -n 's/^ @version "\([^"]*\)"$/\1/p' mix.exs | head -n1)" if [ -z "$VERSION" ]; then @@ -55,6 +57,7 @@ jobs: exit 1 fi TAG="v$VERSION" + SHOULD_PUBLISH=false if [ "$EVENT_NAME" = "workflow_dispatch" ]; then if [ "$TAG" != "$INPUT_TAG" ]; then @@ -64,6 +67,12 @@ jobs: SHOULD_RELEASE=true elif git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then SHOULD_RELEASE=false + + # The merge of the checksum PR publishes: only then does main hold the checksums of the + # release assets, and the Hex package must carry them. + if git diff --name-only "$BEFORE" "$GITHUB_SHA" | grep -qxF checksum-Elixir.WhisperCpp.Native.exs; then + SHOULD_PUBLISH=true + fi else SHOULD_RELEASE=true fi @@ -76,6 +85,7 @@ jobs: fi echo "should_release=$SHOULD_RELEASE" >> "$GITHUB_OUTPUT" + echo "should_publish=$SHOULD_PUBLISH" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" @@ -376,3 +386,50 @@ jobs: artifacts/*.tar.gz artifacts/SHA256SUMS generate_release_notes: true + + publish: + name: Publish to Hex + needs: detect + if: needs.detect.outputs.should_publish == 'true' + runs-on: ubuntu-24.04 + # The Hex workload identity of the package names this environment and this workflow file. + environment: hex + permissions: + contents: read # check out the repository + id-token: write # mix hex.publish exchanges the job's OIDC token for a Hex token + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-publish + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.detect.outputs.sha }} + persist-credentials: false + + # Hex versions are immutable, so a package with checksums of other archives stays broken. + - name: Compare the checksum file with the release + env: + TAG: ${{ needs.detect.outputs.tag }} + run: | + diff \ + <(curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/SHA256SUMS" | awk '{print $2 " sha256:" $1}' | sort) \ + <(sed -n 's/^ "\(.*\)" => "\(sha256:.*\)",$/\1 \2/p' checksum-Elixir.WhisperCpp.Native.exs | sort) + + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + env: + INSTALL_DIR_FOR_OTP: ${{ runner.tool_cache }}/setup-beam/otp-29.1.2 + INSTALL_DIR_FOR_ELIXIR: ${{ runner.tool_cache }}/setup-beam/elixir-1.20.5 + with: + elixir-version: "1.20.5" + otp-version: "29.1.2" + + - name: Install dependencies + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + + # With no Hex credential in the job, Hex publishes through the workload identity. The docs + # compile downloads the precompiled NIF of this runner and checks it against the checksum file. + - name: Publish the package and its docs + run: mix hex.publish --yes diff --git a/AGENTS.md b/AGENTS.md index 8059be2..235b4bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ users never build Rust. ## Checks - `ci.yml` runs `task check` on pushes to `main` and on pull requests. A push to a branch without a pull request runs - nothing. + nothing. `ci.yml` and `integration.yml` set `WHISPER_CPP_BUILD=1`, so they test the NIF of the code under test. - `task test:integration` runs real inference. `integration.yml` runs it weekly and on manual dispatch, never on a pull request. Run it locally when you change the NIF boundary. - `release.yml` builds six NIFs (target and variant). One backend takes minutes to build from source, so leave the @@ -54,11 +54,21 @@ users never build Rust. 3. After the tag exists, a manual dispatch builds the tag's commit. It fails when the tag is missing or its `mix.exs` version differs, and it uploads only the assets the release does not have yet. It never replaces a published tarball, because a new tarball breaks the checksum file in the Hex package. -4. Regenerate `checksum-Elixir.WhisperCpp.Native.exs` from the published assets, then commit and push it. This keeps +4. Regenerate `checksum-Elixir.WhisperCpp.Native.exs` from the published assets, and open a PR with it. This keeps the checksum of each tag reproducible from the repo: ```bash mix rustler_precompiled.download WhisperCpp.Native --all --no-config --ignore-unavailable --print ``` -5. Run `mix hex.publish` from a clean tree. `mix.exs` puts `checksum-*.exs` in the Hex tarball. +5. Merge the checksum PR. A push to `main` that changes the checksum file, for a version that has its tag, starts the + `Publish to Hex` job of `release.yml`. It compares the file with the release's `SHA256SUMS`, stops when they differ, + and runs `mix hex.publish --yes` in the GitHub environment `hex`, which only `main` can use. The job keeps no Hex + key: the package has a workload identity on hex.pm for `rubas/whisper_cpp`, workflow `release.yml`, environment + `hex`, and Hex trades the job's OIDC token for a short token that can only publish `whisper_cpp`. +6. The package has staged publishing on. The job ends with `Package staged for approval at `, and an owner + approves the release on hex.pm with their 2FA within 7 days. A failed docs upload prints `Publishing failed` but + leaves the job green, so read the end of its log. When the job fails, fix the cause and re-run it. + +Merge nothing else to `main` between the version bump and the checksum PR, or the package holds sources that the NIFs +were not built from. Hex versions are immutable, so change the checksum file only in the checksum PR of a release. diff --git a/config/config.exs b/config/config.exs deleted file mode 100644 index d6839fa..0000000 --- a/config/config.exs +++ /dev/null @@ -1,8 +0,0 @@ -import Config - -# Force source build during this library's own CI / local dev. Downstream -# consumers do *not* see this file - `import_config` in their own -# `config/config.exs` is not transitive across deps - so they continue to -# fetch a precompiled artefact unless they set `WHISPER_CPP_BUILD=1` or the -# equivalent `:rustler_precompiled` config themselves. -config :rustler_precompiled, :force_build, whisper_cpp: System.get_env("CI") == "true"