Hex
Detection signals
Section titled “Detection signals”Hex is present when a mix.exs or a mix.lock turns up anywhere in the checkout. Mix documents that “a Mix project is defined by calling use Mix.Project in a module, usually placed in mix.exs”, exporting a project/0 function that returns the project configuration, so the manifest is always that one file.
Hex is shipped when project/0 returns a :package key and publish evidence exists: a release workflow running mix hex.publish, or an existing page on hex.pm for the package name, which defaults to the snake_case application name. Hex requires :licenses under :package and states plainly that “this attribute is required”, so a :package block with no licenses is a half-configured publish rather than a publishing project.
Sources: Publishing a package, Mix.Project.
Three cases decide most arguments:
- A
mix.exswith no:packagekey is present and not shipped. Every Elixir application and every Phoenix service is this case, and they are far more common in the wild than published libraries. - An umbrella project’s root
mix.exsusually carries no:package, and each application underapps/answers the shipped question for itself. Read the children before concluding the repository publishes nothing. - A package published to a Hex organization is shipped, privately. Report it as shipped and name the organization, rather than reading a private scope as an absence. A
mix.lockon its own is present and says nothing about publishing.
Release track
Section titled “Release track”Hex takes the registry-push track. mix hex.publish uploads a built tarball to hex.pm under an API key, so there is an upload to secure and a credential to scope, which is what assigns the track. The roster records "track": "registry-push" for Hex and the release area’s preamble names Hex in its registry-push list.
Verified 2026-07-31 against https://hex.pm/docs/publish and https://mix.hexdocs.pm/Mix.Project.html.
Version badge
Section titled “Version badge”Shields.io serves this one at img.shields.io/hexpm/v/PACKAGE, taking the package name as its only path parameter. It reports the package’s latest stable version and falls back to the latest version when there is no stable one, so a package that has only ever published a release candidate still gets a badge rather than an error.
The default label is hex. Keep it.
Link the badge to the package page:
[](https://hex.pm/packages/PACKAGE)Install command
Section titled “Install command”There is no install command. A Hex package is declared in the deps list of mix.exs and fetched by a command that takes no package name, so a README shows the entry and then the fetch:
defp deps do [ {:package, "~> 1.0"} ]endmix deps.getHex documents the dependency form as {:package, requirement} and mix deps.get as fetching whatever is not already fetched. Write the requirement against the version actually published, because the ~> operator is what decides how much of the next release a reader gets. Elixir’s Version documentation reads ~> 2.0 as >= 2.0.0 and < 3.0.0 and ~> 2.0.0 as >= 2.0.0 and < 2.1.0, so one extra digit in a README snippet is the difference between a reader tracking a major line and a reader pinned to a patch line.
Keep the atom and the package name identical. Hex documents the :hex option as the package name, defaulting to the dependency’s application name, so a package whose Hex name differs from the atom needs that option in the snippet and a README that shows the atom alone documents a dependency that does not resolve.
Verified 2026-07-31 against Hex usage, Elixir Version, and services/hexpm/hexpm.service.js in badges/shields.
License declaration (R-COM-01)
Section titled “License declaration (R-COM-01)”mix.exs declares it. The package metadata returned by the project’s package/0 function carries a :licenses key holding a list, and Hex’s publishing documentation states plainly that the attribute is required. Valid identifiers come from the SPDX license list, and a license SPDX does not carry uses a LicenseRef-<idstring> identifier with the full license text shipped in the package.
That requirement is worth using. Because Hex refuses to publish without :licenses, every package on the registry declared something, so a published Hex package always has a manifest side to compare rather than the absence other ecosystems permit. A mix.exs with no package/0 at all is an application rather than a library, which declares nothing and publishes nothing.
The default :files list already includes LICENSE* and license*, so the root license file normally travels with the package without being named anywhere. Where a project overrides :files, check the license file is still in the list; a package declaring MIT and shipping no license text is the mismatch this rule exists to catch, in a form the manifest alone does not reveal.
So the manifest side of R-COM-01 is the :licenses list, and the file side is the root license file. A list with more than one entry needs every entry traceable to what that file says.
Source: Publishing a package.
Funding platform name
Section titled “Funding platform name”Tidelift lists no platform for this ecosystem, so there is no tidelift value a Hex package can write.
GitHub’s own documentation for the funding file enumerates the accepted tidelift platform names. It names six, every one of them another ecosystem’s registry; Elixir, Erlang, and Hex appear nowhere in it. That is why the roster at skills/oss-audit/ecosystems.json carries no tidelift key for this ecosystem.
Source: Displaying a sponsor button in your repository.
The strongest documented fallback is the rest of the same file. The github, open_collective, liberapay, and custom keys are forge-level rather than ecosystem-level, so they reach an Elixir project exactly as they reach any other. The package metadata’s own :links map is a second surface, since it renders on the package page and takes an arbitrary label and URL, which is where a funding page reaches a reader who arrived at the registry rather than the repository.
No rule in STANDARD.md requires a funding file at all, so this gap costs a repository nothing when it is scored. Tidelift adding a Hex platform, and GitHub’s funding documentation listing it, is what would retire it.
Verified 2026-07-31 against Publishing a package and Displaying a sponsor button in your repository.
Toolchain and matrix (R-CI-03)
Section titled “Toolchain and matrix (R-CI-03)”On GitHub Actions, erlef/setup-beam installs Erlang/OTP and optionally Elixir, Gleam, and rebar3. The matrix here is a pair rather than a single version, because an Elixir release supports a range of OTP releases and the combination is what breaks:
strategy: fail-fast: false matrix: include: - elixir: '1.17' otp: '26' - elixir: '1.18' otp: '27'steps: - uses: erlef/setup-beam@v1 # oss-harden pins this to a commit SHA with: otp-version: ${{ matrix.otp }} elixir-version: ${{ matrix.elixir }}The support claim lives in the :elixir key of the project list in mix.exs, a version requirement such as "~> 1.14". Quote every version as a YAML string, which the README asks for explicitly: 23.0 unquoted parses as the number 23, which is a different OTP release. Set version-type: strict with exact versions where the OTP version scheme matters, since the default is loose semver resolution.
Two constraints bound the matrix from outside the manifest. The README carries a table of which OTP releases work on which runner images, and a self-hosted runner has to set the ImageOS environment variable itself, because the action uses it to pick the assets to download.
On GitLab, run the job in the Elixir official image, whose tags carry both versions, and vary the tag with parallel:matrix.
Sources: erlef/setup-beam, Mix.Project, erlef/docker-elixir.
Dependency caching (R-CI-04)
Section titled “Dependency caching (R-CI-04)”Hex documents no cache directory to reuse, and erlef/setup-beam has no cache input. The action’s README describes installing OTP, Elixir, Gleam, rebar3, and local.hex, with version resolution, problem matchers, and platform support, and no caching of anything. Hex’s own FAQ and the mix hex task documentation cover configuration, publishing, and ownership, and name no local cache path and no HEX_HOME.
Source: erlef/setup-beam, Hex FAQ, mix hex.
The strongest documented fallback is an explicit cache over the two directories Mix does document, keyed on the lockfile Mix names: :deps_path defaults to deps, :build_path defaults to _build, and :lockfile defaults to mix.lock. Both are inside the project directory, so this shape works unchanged on GitLab.
- uses: actions/cache@v4 # oss-harden pins this to a commit SHA with: path: | deps _build key: ${{ runner.os }}-otp${{ matrix.otp }}-elixir${{ matrix.elixir }}-${{ hashFiles('mix.lock') }}Both directories hold fetched sources and compiled BEAM files rather than package-manager download data, so this sits below the bar R-CI-04 sets and needs the OTP and Elixir versions in the key, since compiled artifacts are not portable across them. Hex documenting a download cache path, or setup-beam gaining a cache input keyed on mix.lock, would retire the gap.
Sources: Mix.Project, GitLab CI/CD YAML reference, cache.
Test command (R-CI-06)
Section titled “Test command (R-CI-06)”mix test starts the application, loads test/test_helper.exs, and requires every file matching test/**/*_test.exs in parallel. test_paths defaults to ["test"] when that directory exists and to an empty list when it does not, so a project with no test/ directory runs nothing and reports success.
The command is declared by the layout rather than by a line in mix.exs, so read for the directory and the _test.exs files. A project that has moved its tests declares test_paths in the project list, and a project with an alias, such as an aliases entry mapping test to a setup step plus test, has already written the command CI should call.
Sources: mix test.
Verified 2026-07-31 against https://github.com/erlef/setup-beam, https://mix.hexdocs.pm/Mix.Project.html, https://mix.hexdocs.pm/Mix.Tasks.Test.html, https://hex.pm/docs/faq, https://hex.hexdocs.pm/Mix.Tasks.Hex.html, https://github.com/erlef/docker-elixir, and https://docs.gitlab.com/ci/yaml/.
Automated dependency updates (R-SEC-03)
Section titled “Automated dependency updates (R-SEC-03)”Dependabot’s mix value carries version updates and not security updates. Private registries are supported and private repositories are not. So an Elixir project on GitHub gets scheduled dependency bumps and never an advisory-driven one, which makes the updater half of this ecosystem’s posture the easy half and the watching half the whole problem. The Vulnerability watch section below carries the rest of that consequence.
On GitLab the Renovate manager is mix.
Lockfile and frozen install (R-SEC-08)
Section titled “Lockfile and frozen install (R-SEC-08)”Mix writes mix.lock without being asked, and the frozen install is mix deps.get --check-locked, documented as raising if there are pending changes to the lockfile. Hex’s own guidance is to always commit mix.lock, and it describes mix deps.get as locking the version of a dependency so every developer gets the same one.
What breaks the first time you commit the lockfile
Section titled “What breaks the first time you commit the lockfile”This is the quietest of the eleven, and two of the three usual failures do not reach it.
Runtime version is not a documented dimension. Neither Hex nor Mix documents the resolved set as varying with the Elixir or OTP version, so there is no lowest-runtime rule to follow here and no reason to regenerate per matrix leg. What does vary by runtime is the compiled _build tree, so a CI cache keyed on mix.lock alone serves artifacts compiled by another OTP release. Key that cache on the OTP and Elixir versions as well as the lock.
Platform is not a dimension either. mix.lock records a resolved version and a checksum per dependency, with no per-platform entry, so a lock generated on macOS installs on a Linux runner unchanged.
An umbrella has one lock, and a child that misconfigures its paths gets a second. Mix defaults :lockfile to mix.lock, :deps_path to deps, and the build path alongside them, and an umbrella child overrides all three to point at the parent, conventionally lockfile: "../../mix.lock". A child whose mix.exs omits that override resolves its own dependencies into its own lock, which no updater bumps and no root --check-locked reads. Read each child’s mix.exs for the four shared paths before reporting the umbrella locked.
Verified 2026-07-31 against mix deps.get, Mix.Project, and Hex mix usage.
Static analysis (R-SEC-09)
Section titled “Static analysis (R-SEC-09)”CodeQL does not support Elixir; it appears nowhere in the supported languages list.
Sobelow is the analyzer this ecosystem publishes, maintained by NCC Group and described as a security-focused static analysis tool for Elixir and the Phoenix framework. It covers insecure configuration, known-vulnerable dependencies, cross-site scripting, SQL injection, command injection, code execution, denial of service, directory traversal, and unsafe serialization. Add it as {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} and run mix sobelow from the project root. The flag that makes it a gate rather than a report is --exit, which returns a non-zero exit status at or above a confidence threshold of low, medium, or high and defaults to false.
GitLab ships the same tool: its SAST table lists Elixir (Phoenix) under the Sobelow analyzer, in the tier that carries the open source analyzers.
Vulnerability watch (R-SEC-11)
Section titled “Vulnerability watch (R-SEC-11)”Nothing on the forge watches this ecosystem, and the gap is total rather than partial.
The dependency graph has no Hex row at all: neither mix.exs nor mix.lock appears anywhere in its supported ecosystems table. The SBOM shows that rather than implying it. Run the SBOM command from github.md against elixir-ecto/ecto and it reports one pkg:github and five pkg:githubactions entries and not a single pkg:hex package; run it against phoenixframework/phoenix and it reports 635 pkg:npm packages beside the same zero, from that project’s JavaScript assets. The absence is Elixir’s, not two repositories that never turned the graph on.
Dependabot’s ecosystem table is the second half of it, and it points the same way: mix is listed for version updates and not for security updates. So an Elixir project has no watcher until a scanner reads mix.lock, and the residual is the ecosystem’s whole advisory feed rather than the transitive tail of a parsed manifest.
The advisories themselves do exist. The GitHub Advisory Database names this ecosystem Erlang against the hex.pm registry, so what is missing is not the data but anything on the forge matching it against the project.
osv-scanner reads mix.lock. On this ecosystem that job is the whole watch rather than a supplement to one, so treat a repository that has it as covered and one that does not as unwatched, whatever the security overview shows.
Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, mix deps.get, Sobelow, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles, with the two SBOM readings taken the same day.
Concrete flow for the decisions SKILL.md makes, for a package published to hex.pm. Hex documents no OIDC flow and no trusted publishing: CI authenticates with a HEX_API_KEY in an environment variable and nothing else. What Hex does offer, and what makes it different from Maven Central’s account-scoped token, is a key that can be scoped to a single package. R-PUB-02’s own clause admits that as below the bar but permitted, and this file’s Step 2 is how to get there.
Two Hex behaviours shape the workflow. mix hex.publish builds the tarball it uploads, so build and publish cannot be split across jobs the way they are for npm or RubyGems. And a published version can be replaced for one hour, after which it is fixed.
Read Step 2 before running any command hex.pm’s publishing guide gives for CI. That guide’s key-generation command was removed from the Hex client in 2026 and the guide still carries it, so it is the one place in this file where the registry’s own documentation is not the source to follow.
Source: Hex, Publishing a package, Hex docs, mix hex.publish, and hexpm/hex, CHANGELOG.
Contents
Section titled “Contents”- Gather facts (Step 1)
- Configure trusted publishing (Step 2): a scoped key, not a check
- Write the hardened release workflow (Step 3)
- Gate on manual approval (Step 4)
- Verify provenance (Step 5): a gap, not a check
- Describe and sign what the release attaches (Step 6)
- The one-hour window
Gather facts (Step 1)
Section titled “Gather facts (Step 1)”Read mix.exs. The project/0 function carries :app, which is the package name unless :package overrides it with :name, and :version. The :package keyword also carries :links, which is where the repository URL usually lives; fall back to git remote get-url origin.
Check whether the package is already published:
curl -s -o /dev/null -w '%{http_code}' https://hex.pm/api/packages/<name>A 404 means the name is free, or taken and private. An unpublished package cannot take the flow below unchanged: hex.pm refuses to issue a key scoped to a package the account does not yet own, so the first publish needs a broader key or a hand-run publish. Step 2 covers what that forces.
Note whether mix.lock is committed. It has to be, for the exposure in Step 3 to be bounded at all.
Configure trusted publishing (Step 2): a scoped key, not a check
Section titled “Configure trusted publishing (Step 2): a scoped key, not a check”Hex has no OIDC flow, no trusted publishing, and no equivalent under another name. What CI authenticates with is a stored key in the HEX_API_KEY environment variable. This is the checked negative R-PUB-02’s clause was written for, so report it as a registry limitation and not as an unconfigured feature.
Do not take the CI section of hex.pm’s publishing guide at face value. It documents mix hex.user key generate --key-name ... --permission ..., and that command no longer exists. Hex 2.4.0, released 2026-03-14, replaced password based authentication with an OAuth device flow, and the key subcommand went with it. On Hex 2.5.1 the task’s source declares @switches [] and accepts three subcommands only, whoami, auth, and deauth, so every invocation the guide gives fails with --key-name : Unknown option. Upstream’s own hex.organization documentation still refers to “the hex.user key commands” in passing, which is the same staleness in a second place. HEX_API_KEY itself is unaffected: the client still reads it, as api_key in lib/hex/state.ex, so a stored key remains how CI authenticates and only the way one is created has moved.
Reported upstream as hexpm/hexpm#1787, with the documentation fix in hexpm/hexpm#1788. Where those have merged, the guide and this section agree again; where they have not, this section is the current one.
Generate the key in the browser, at https://hex.pm/dashboard/keys. The form asks for a name, an expiry, and a set of permissions, and it re-prompts for authentication before it will create one. Set the expiry from the presets, 7, 30, 60, 90, or 365 days, or a custom date, to the shortest value that fits the release cadence, and plan to rotate on that schedule. For permissions, do not tick the whole-API box: that reaches every package the account owns. Tick the single package under Packages instead, which the form lists from the packages the account owns and which records a package permission scoped to that name.
A package-scoped key is what the fallback asks for, because hex.pm’s release endpoint authorizes publishing on the api:write and package domains together with a package-owner check, so such a key publishes that one package and nothing else.
Two consequences worth telling the maintainer. The form lists only packages the account already owns, so the very first publish of a brand new package needs a broader key or an interactive mix hex.publish from the maintainer’s own machine. And a repository publishing several packages needs one key per package, or it is back to an account-wide key.
Do not reach for the OAuth token that mix hex.user auth writes into ~/.hex/hex.config as a substitute. No upstream source documents extracting it for CI, it belongs to an interactive session that Hex refreshes and can invalidate, and treating an undocumented on-disk credential as a publishing token is exactly the improvisation this reference exists to prevent.
An organization publishing to its own repository takes a different command, and that one is current: mix hex.organization key ORGANIZATION generate [--key-name KEY_NAME] [--permission PERMISSION] still exists and still parses those flags. It defaults to repository:ORGANIZATION, which is read-only access to the organization’s repository, so name the permission the publish actually needs rather than accepting the default.
Store the key as a GitHub Actions secret bound to the approval environment from Step 4, not as a repository secret, so it is unreadable by any workflow run that has not been approved. On GitLab CI/CD, store it as a variable that is masked, protected, and scoped to the protected environment.
This sits below R-PUB-02’s bar, because a scoped, long-lived key is still a credential that can leak, where an OIDC exchange stores nothing at all. It stays below it while Hex documents no OIDC flow, and it retires the day one ships. hexpm/hexpm#1785, “feat: trusted publisher”, is open and is the change to watch: it would move Hex onto the same track as npm and PyPI and make this whole section a fallback of last resort. Re-read the Hex client changelog and that pull request before each release process is written rather than treating the absence as settled.
Write the hardened release workflow (Step 3)
Section titled “Write the hardened release workflow (Step 3)”mix hex.publish builds the tarball as part of publishing. There is no equivalent of npm pack handing a finished artifact to a credentialed job that does nothing else, so build, package, and publish collapse into the one job that holds the key. That collapse is the security problem this section has to answer, and it cannot be removed, only bounded.
That is also why this is the one file in this directory with no build job. SKILL.md’s workflow shape gives every other ecosystem one, and here it would have nothing to hand on: mix hex.publish accepts no prebuilt tarball path. mix hex.build in the test job is the closest thing, and it exists to fail the release before the credentialed job runs rather than to produce an artifact for it.
The exposure is specific to Elixir rather than generic. mix runs dependency code at compile time through macros, so a job that holds a long-lived Hex key while compiling the dependency tree hands that key’s reach to every transitive dependency in it. Hex has no OIDC to shorten the key’s life, so a dependency that reads the environment during compilation reads a credential that is valid until somebody revokes it. Treat that as a known residual and say so to the maintainer, rather than presenting the workflow below as a solved problem.
Four controls bound it, and all four are already in this skill’s vocabulary:
- A job that does nothing but publish. No tests, no linting, no documentation build, no release-notes step. Those belong in the uncredentialed job above it.
mix deps.get --check-lockedagainst a committedmix.lock, so the tree compiled in the credentialed job is the exact tree CI already compiled and tested without a key. R-SEC-08 is the rule, and itsCheck:asks for the frozen mode rather than the plain install, so a baremix deps.getin a workflow this skill writes fails a rule the kit owns and an audit run afterwards will score it.oss-hardenowns the lockfile itself.- The key scoped as narrowly as Hex permits, per Step 2, so what a compromised dependency gets is publish rights on one package rather than on the account.
- The key held in an environment gated by R-PUB-04’s approval, so it is not available to every workflow run.
name: Releaseon: push: tags: - 'v*'jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: erlef/setup-beam@v1 # oss-harden pins this to a commit SHA with: version-file: .tool-versions version-type: strict - run: mix deps.get --check-locked - run: mix test # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02) - run: mix hex.build - run: test -f "<name>-${GITHUB_REF_NAME#v}.tar" - run: mix hex.publish --dry-run
publish: runs-on: ubuntu-latest needs: [test] environment: release permissions: contents: read steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: erlef/setup-beam@v1 with: version-file: .tool-versions version-type: strict - run: mix deps.get --check-locked - run: mix hex.publish --yes env: HEX_API_KEY: ${{ secrets.HEX_API_KEY }}The version check is a file test rather than a property query, because mix hex.build names its output <app>-<version>.tar from the project configuration: if the tag and mix.exs disagree, the expected file is simply not there. Replace <name> with the package name from Step 1 and adapt the v prefix strip to the repository’s actual tag format. --dry-run builds the package and runs the local checks without publishing, which is what moves every failure that is not the upload itself into the job with no key. --yes publishes without the confirmation prompts, which a non-interactive job cannot answer.
mix hex.publish also builds and publishes documentation by running mix docs, so the credentialed job runs the documentation toolchain too unless the project splits the two. mix hex.publish package publishes the package alone and mix hex.publish docs the documentation alone; splitting them lets the docs build run in an uncredentialed job and keeps the credentialed one to the package upload. Offer that split rather than assuming it, because it costs a second key or a second approval.
oss-harden pins every uses: line above to a commit SHA and sets the test job’s minimal permissions. Cache nothing in either job: a restored build cache in a job that compiles dependencies and holds a publish key is the worst version of the exposure this section is about. On GitLab CI/CD the same two jobs apply, with HEX_API_KEY as a masked and protected variable and the publish job behind a protected environment with approval rules.
If an existing workflow reads an account-wide HEX_API_KEY from repository secrets, replace it with the scoped key from Step 2 and tell the user to revoke the old one from the account’s key list on hex.pm once the new flow is verified.
Gate on manual approval (Step 4)
Section titled “Gate on manual approval (Step 4)”Pin the publish job to environment: release as above, and create that environment at https://github.com/<owner>/<repo>/settings/environments/new with required reviewers naming at least one person other than an automation account, and with HEX_API_KEY as an environment secret rather than a repository secret. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. On GitLab Premium or Ultimate, use a protected environment with approval rules.
Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.
ENV=releaseGHUID=$(gh api user --jq .id)gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \ -F wait_timer=0 \ -F prevent_self_review=false \ -f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \ -F 'deployment_branch_policy[protected_branches]=false' \ -F 'deployment_branch_policy[custom_branch_policies]=true'gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \ -f 'name=v*' -f type=tagThree details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.
reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.
Hex has no registry-side approval gate. The one-hour replacement window below is not one: it runs after the version is already public and installable, so it shortens the damage rather than preventing it. Report R-PUB-04 as unmet when the forge plan provides no native gate, rather than presenting the window as a substitute.
The gate carries more weight here than in most of this directory, because it is also what keeps the long-lived key out of unapproved runs. An environment secret behind required reviewers is the difference between a key any workflow run can read and one only an approved release job can.
Verify provenance (Step 5): a gap, not a check
Section titled “Verify provenance (Step 5): a gap, not a check”hex.pm serves no build provenance. There is no attestation object, no signature on a published tarball, and nothing comparable to npm’s npm audit signatures or PyPI’s Integrity API. The Hex client changelog through v2.5.2-dev mentions no OIDC, trusted publishing, attestation, provenance, or Sigstore work at all, so read both before reporting, and treat the absence as current rather than as something nobody looked for.
Source: Hex, Publishing a package and hexpm/hex, CHANGELOG.
What hex.pm does serve is a digest. The release endpoint returns a checksum field for a published version:
curl -s https://hex.pm/api/packages/<name>/releases/<version> | jq -r .checksumThat is the checksum Hex computes over the release tarball, and it is what mix.lock records for a dependency, so a consumer’s lockfile and the registry can be compared directly. It is integrity, not provenance: it says the bits have not changed since publication, and nothing about which commit or workflow produced them. Report it that way, and use it as the release check after the first tag-triggered publish, comparing the registry’s value against the entry the project’s own mix.lock records for that version.
The strongest substitute for provenance is a forge attestation over the tarball the publish job built. mix hex.build writes <name>-<version>.tar without publishing it, so the credentialed job can attest what it uploaded:
gh attestation verify <name>-<version>.tar --repo <owner>/<repo>hex.pm neither surfaces nor links to that attestation, and a consumer installing through mix deps.get never sees it, so it is worth less here than in an ecosystem whose registry serves the record. On GitLab CI/CD there is no equivalent forge attestation in this skill’s scope at all.
This sits below R-PUB-03, which asks for provenance tied to the exact published artifact and the workflow that built it. Report it as unmet with the registry limitation named. It retires the day hex.pm serves an attestation for a published release.
Describe and sign what the release attaches (Step 6)
Section titled “Describe and sign what the release attaches (Step 6)”Only for a release that attaches a built asset to the forge release. Publishing to hex.pm attaches nothing to the forge, and the source archives the forge generates for a tag are not built assets, so a library that only publishes goes to Step 7 instead. What this section covers is a project that also attaches a compiled release or an escript.
This reference names no SBOM generator for Elixir. The ones in common use are third-party Mix tasks rather than part of the toolchain, and a task that reads the dependency tree inside the release workflow is one the maintainer vets before it goes there. mix.lock is the closest thing the project already has, and it is neither SPDX nor CycloneDX, so publishing it does not satisfy R-PUB-05’s format requirement; say that rather than presenting the lockfile as a bill of materials.
Two rules apply here and they ask for different things. R-PUB-05 wants that inventory. R-PUB-06 wants the assets signed, or listed by hash in a signed manifest. A manifest of hashes answers the second and nothing about the first, so do not report R-PUB-05 as met by publishing one.
What answers R-PUB-05 without a generator, on GitHub, is the forge’s own export of the repository’s dependency graph, which is already SPDX and needs nothing installed and, in particular, nothing added to the credentialed job. The gh api step below writes it into dist/, so it ships as a release asset for R-PUB-05 and is listed in SHA256SUMS and attested alongside the assets for R-PUB-06, in a job after the publish so everything stays behind the approval gate:
github-release: runs-on: ubuntu-latest needs: [publish] permissions: contents: write id-token: write attestations: write artifact-metadata: write steps: - uses: actions/download-artifact@v8 with: name: build path: dist/ - run: gh api repos/${{ github.repository }}/dependency-graph/sbom --jq .sbom > dist/sbom.spdx.json env: GH_TOKEN: ${{ github.token }} - run: (cd dist && sha256sum *) > SHA256SUMS - uses: actions/attest@v4 with: subject-checksums: SHA256SUMS - run: gh release upload "$GITHUB_REF_NAME" dist/* SHA256SUMS env: GH_TOKEN: ${{ github.token }}State both of the export’s limits to the maintainer rather than leaving them to be discovered. It is GitHub only, so a GitLab project keeps this gap and R-PUB-05 stays unmet there with the reason named. And it covers the repository’s declared dependency graph rather than what is inside the asset, which is close for a compiled release, because it is assembled from the applications in that same graph, and silent about the Erlang and Elixir runtime bundled beside them. The graph resolves past the direct dependencies only where mix.lock is committed, which R-SEC-08 already requires. Read the output back once with gh api repos/<owner>/<repo>/dependency-graph/sbom --jq '.sbom.packages | length' before reporting the rule met: a graph the forge does not parse for this ecosystem returns a near-empty package list rather than an error.
sha256sum runs from inside dist/ so the names it writes are the names the assets carry on the release. subject-checksums makes every file in the manifest a subject of the attestation in its own right, by name and digest; attesting SHA256SUMS itself with subject-path would leave a consumer able to verify the manifest and nothing about the assets it lists.
A consumer verifies an asset, then checks the rest of the download against the manifest:
gh attestation verify <asset> --repo <owner>/<repo>sha256sum -c SHA256SUMSRun the first command against each asset downloaded, never against SHA256SUMS, which is a subject of nothing. The four grants above are copied exactly, on this job only, and the workflow’s top-level block stays contents: read. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s.
The one-hour window
Section titled “The one-hour window”A published version can be republished for up to one hour after its first publication, with --replace, and reverted entirely with mix hex.publish --revert VERSION; reverting the only version removes the package. After that hour the version is fixed.
Treat this as a window and not as an undo. It is measured from the first publish, not from when somebody noticed, and it does nothing about a version already fetched into somebody’s mix.lock inside it. oss-changelog owns how a withdrawn version gets recorded, under R-CHG-01. What the window is genuinely good for is the case an approval gate cannot catch, a correct release with a broken artifact, and the release process should not be designed around having it.
Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.
Step 2 is deliberately not verified against Hex, Publishing a package, because that page is the document that is wrong. It is verified against the client and the hex.pm source instead.
Verified 2026-07-31 against the 2.4.0 entry in hexpm/hex, CHANGELOG, recording the OAuth device flow that replaced password authentication; hexpm/hex, lib/mix/tasks/hex.user.ex, which declares @switches [] and dispatches whoami, auth, and deauth alone; hexpm/hex, lib/hex/state.ex, which still reads HEX_API_KEY; hexpm/hex, lib/mix/tasks/hex.organization.ex, which still documents key ORGANIZATION generate with --key-name and --permission and defaults to repository:ORGANIZATION; hexpm/hexpm, lib/hexpm_web/controllers/dashboard/key_controller.ex, whose create action is routed at /dashboard/keys, whose munge_expiry accepts 7, 30, 60, 90, and 365 days or a custom date, and whose munge_permissions accepts a package domain per owned package; Hex docs, mix hex.publish; Hex docs, mix hex.build; the package-domain validation in hexpm/hexpm, lib/hexpm/accounts/key_permission.ex; the authorization plug in hexpm/hexpm, lib/hexpm_web/controllers/api/release_controller.ex; and the checksum field returned by https://hex.pm/api/packages/<name>/releases/<version>.
Version sources (R-CHG-03)
Section titled “Version sources (R-CHG-03)”mix.exs is the source: the :version key in the project list returned by project/0. Hex reads it when publishing, so what the registry shows is whatever that key held at publish time.
An umbrella project has one mix.exs per application under apps/, each with its own :version. Those are separate release units unless the project versions them together, so compare each against the tag and the changelog section that names it. mix.lock records resolved dependencies and never the project’s own version, so it is not a version source.
Where the project computes the version rather than writing a literal, for example by reading a file at the top of mix.exs, follow the expression to whatever it reads and treat that as the source.
Version syntax (R-CHG-02)
Section titled “Version syntax (R-CHG-02)”Hex is the strictest SemVer on this roster, because the tooling rejects anything else. Elixir’s Version module implements the SemVer 2.0 schema with MAJOR.MINOR.PATCH mandatory, and each numeric component limited to at most 14 digits. A two-part version does not parse: Version.parse("2.0-alpha1") returns :error.
The one place a component may be omitted is a requirement, not a version: the operand after ~> may leave out the patch, which is what makes ~> 1.14 the idiomatic dependency constraint. So R-CHG-02’s syntax half is enforced by the ecosystem here, and what remains to check is the bump decision behind the number.
Major version in package identity (R-CHG-07)
Section titled “Major version in package identity (R-CHG-07)”Hex does not encode the major version in package identity. A package name is fixed at first publish and holds every version, so R-CHG-07 does not reach this ecosystem. Elixir projects that rename a module namespace across a major are changing source, which R-CHG-02 already covers as an incompatible change.
Withdrawing a release (R-CHG-01)
Section titled “Withdrawing a release (R-CHG-01)”Hex has two mechanisms separated by a hard one-hour boundary, re-verified upstream on 2026-07-31.
Inside one hour of the first publication, mix hex.publish --revert VERSION removes the release, and Hex documents that the package may be published again for up to one hour after first publication. That is the only removal Hex offers.
After that hour the version stays forever and the mechanism is retirement: mix hex.retire PACKAGE VERSION REASON, where the reason is one of renamed, deprecated, security, invalid, or other, each requiring a message that explains it. mix hex.retire PACKAGE VERSION --unretire reverses it. Retirement changes nothing about availability: “A retired package is still resolvable and usable but it will be flagged as retired in the repository and a message will be displayed to users when they use the package.”
So a Hex withdrawal after the first hour is a label, not a removal, and the reason plus message is the whole of what a user sees. Write them to say what a reader of the changelog would need. [YANKED] on the changelog heading maps to a retirement, or to a revert inside the hour, and comes off again after an --unretire.
Verified 2026-07-31 against Publishing a package, mix hex.retire, and Elixir Version.