Skip to content

Releasing

A release is cut by pushing a v* tag on a commit of main; nothing else publishes. Until then the release machinery is dormant: it can be rehearsed on GitHub without publishing anything, and everything except the signature and the attestations can be checked locally without credentials.

Terminal window
make release-check # goreleaser check, actionlint, pin and notices checks
make release-snapshot RELEASE_TAG=v0.1.0 # every asset in dist/, nothing signed or published
make release-preflight # what preflight decides for the version in VERSION (REHEARSAL=true: as a rehearsal)
make image-smoke # the container image for linux/amd64, run through the smoke test
make check-server-json # server.json against the registry's schema and rules (offline)
make check-mcpb # the extension manifests against the MCPB schema, and the two scripts
make mcpb # the .mcpb bundles in dist/assets, after make release-snapshot
make check-npm # the npm launcher, the platform lists and both npm scripts (offline)
make npm # the npm packages in dist/npm, validated and run from their tarballs
make check-pypi # the PyPI README line, both wheel scripts and the workflow wiring (offline)
make pypi # the PyPI wheels in dist/pypi, validated, installed into a venv and run
make check-nuget # the NuGet README line, both package scripts and the workflow wiring (offline)
make nuget # the NuGet packages in dist/nuget, validated, installed with dotnet tool and dnx and run
make check-homebrew # the formula renderer, the tap dry run and the workflow wiring (offline)
make homebrew # the formula in dist/homebrew; with Docker, brew style, audit, install and test
make check-winget # the winget manifests against the winget schemas, and the workflow wiring (offline)
make winget # the snapshot's winget manifests in dist/winget, checked against the schemas

release-check and release-snapshot need GoReleaser and syft on PATH, at the versions .github/workflows/release.yml pins (mk/release.mk names the same ones, and a test holds the two lists equal); actionlint is optional.

.github/workflows/release.yml runs on a pushed v* tag or a manual dispatch:

JobWhat it does
preflightResolves the version: the pushed tag, or on a dispatch the VERSION file (an optional tag input must match it). scripts/release-order.sh refuses anything but a plain vMAJOR.MINOR.PATCH, a VERSION file that disagrees, and a tag whose version has no section on the changelog page in English or Spanish (a ## vX.Y.Z heading, optionally followed by the date: ## v0.1.0 (2026-11-20); in a rehearsal a missing section is a warning and the run goes on), and decides whether the tag is the newest stable release (a lower one is a backport). A pushed tag must point at a commit on main.
gateCalls ci.yml on the tagged commit: tests and coverage, transport e2e, generated code and parity, cross-compilation, lint and govulncheck.
buildGoReleaser (.goreleaser.yml) builds every asset into a draft release, signs checksums.txt, then the job verifies that signature, checks that the binary reports the release version, attests build provenance for every asset and keeps the assets as the workflow artifact release-dist. It also packs the Claude Desktop extensions from the raw binaries, attaches them to the draft and attests them, then writes both attestations into arcane-mcp.intoto.jsonl, checks that it names exactly the files the release ships, verifies every file against it and attaches it to the draft (see Verifying a release).
reproduceRebuilds every binary from a fresh checkout of the commit build built (full history, the go.mod toolchain, no Actions cache, the assets downloaded outside the checkout) with the recipe .goreleaser.yml declares, assembles the universal macOS binary from the two darwin rebuilds in the published slice order, and compares all seven byte for byte with checksums.txt (cmd/reproduce_release). Runs in a rehearsal too; publish waits for it.
publishThe last step: turns the draft into the release, marked Latest only when preflight says it is the newest stable one.
publish-imageAfter publish: publish-image.yml builds, smoke-tests, pushes, signs and attests the container image (see Container images).
publish-npmAfter publish: publish-npm.yml builds, validates and publishes the npm packages (see npm).
publish-pypi, upload-pypiAfter publish: publish-pypi.yml builds and validates the PyPI wheels, and upload-pypi uploads them (see PyPI).
publish-nuget, upload-nugetAfter publish: publish-nuget.yml builds and validates the NuGet packages, and upload-nuget attests and pushes them (see NuGet).
verify-publishedAfter every publisher: reads every channel back and holds it to the signed checksums.txt, with no write permission (see Verifying what was published).
publish-homebrewAfter verify-published, newest release only: publish-homebrew.yml commits the formula to the tap jmrplens/homebrew-tap (see Homebrew).
publish-wingetAfter verify-published: publish-winget.yml validates the winget manifests and opens the version’s pull request to microsoft/winget-pkgs (see winget).
publish-registryAfter verify-published: publish-registry.yml stamps server.json and publishes it to the MCP Registry (see MCP Registry).

The draft keeps a partial release out of sight: installers and package managers never see a release whose assets are still being uploaded, and with immutable releases a release is published once, whole. Any job that adds an asset to the release (the .mcpb bundles, for instance) must therefore finish before publish.

Run the workflow by hand (Actions → Release → Run workflow, or gh workflow run release.yml --ref <branch>). A dispatch is always a rehearsal: it runs preflight, the gate and build on the dispatched ref, as the version VERSION holds, in the release-rehearsal environment. A changelog page without the version’s section only gets a warning here, so main can be rehearsed before the version is chosen, but the tag would be refused. GoReleaser runs as a snapshot with --skip=publish,sign, so there is no GitHub release, no tag, no signature or attestation in a public transparency log, and no image push; publish is skipped, publish-image builds and smoke-tests the image without logging in to a registry, publish-npm builds, validates and packs the npm packages, publish-pypi and publish-nuget build and validate the wheels and the NuGet packages (publish-pypi also runs the PyPI upload script with --dry-run; upload-pypi and upload-nuget are skipped), verify-published reads the run’s own packages back from local stand-ins for the registries, publish-homebrew commits the formula to a local clone of the tap and shows it, publish-winget validates the manifests and runs komac dry, and publish-registry stamps server.json with the rehearsal’s bundles and asks the registry to validate it (read-only). The assets stay in the run’s release-dist artifact for seven days, and the job summary lists them with their sizes.

Each distribution channel (container images, MCP Registry and .mcpb, npm, PyPI, NuGet, Homebrew, winget) is a reusable workflow of its own, .github/workflows/publish-<channel>.yml with on: workflow_call, called from release.yml with the inputs release_tag, version, newest, rehearsal and artifact. All of them exist; PyPI and NuGet upload from a job of release.yml itself, because their trusted publishing refuses a reusable workflow’s token. Homebrew and winget announce a version to repositories other than this one; once a post-publish verification of every channel exists (verify-published), both will wait for it. Assets of the release itself (the .mcpb bundles) are attached by build, before publish, because an immutable release takes none afterwards; channels go after publish, and in a rehearsal take their input from the release-dist artifact and publish nothing.

AssetWhat it is
arcane-mcp-<os>-<arch> (.exe on Windows)Raw binaries for linux, darwin and windows on amd64 and arm64, the files package managers consume
arcane-mcp-darwin-universalOne macOS binary for both architectures
arcane-mcp_<version>_<os>_<arch>.tar.gz (.zip on Windows)Archives with the binary, LICENSE, README.md and THIRD_PARTY_NOTICES
<binary>.sbom.jsonSPDX SBOM of each raw binary (syft, licenses read from the module cache)
THIRD_PARTY_NOTICESLicense, notice and patent texts of the Go standard library and of every module the binaries link
checksums.txtSHA-256 of every asset above
checksums.txt.sigstore.jsonKeyless cosign signature of checksums.txt (Sigstore bundle: signature and certificate)
arcane-mcp-{darwin,windows,linux}.mcpb, arcane-mcp.mcpbClaude Desktop extensions, one per system and one for all three (see Claude Desktop extension); not in checksums.txt (built after GoReleaser signs it), covered by their own provenance attestation and, for the three per-system ones, by the SHA-256 in server.json
arcane-mcp.intoto.jsonlThe SLSA build provenance of every file above as a release asset: one line per attestation (the files of checksums.txt, then the extensions), each the Sigstore bundle the attestation step wrote, the form gh attestation verify --bundle reads

The binaries are static (CGO_ENABLED=0), built with -trimpath, and stamped with the version, the short commit and the commit’s date (arcane-mcp --version), with file times set to the commit’s time. Two builds of the same commit give byte-identical binaries, archives and notices. Every release run proves it for the binaries: the reproduce job rebuilds them from a fresh checkout and compares each with checksums.txt (go run ./cmd/reproduce_release -published <dir with checksums.txt and the universal binary> -tag vX.Y.Z does the same from any clean checkout of the commit, with the toolchain go.mod selects; an untracked file in the checkout makes Go stamp vcs.modified=true and every binary differ. The abbreviated commit the binaries carry is git’s %h, whose length grows with the repository’s object count, so a later clone may abbreviate the same commit differently: the command finds the release’s length by rebuilding the first binary with the commit abbreviated to 7 to 16 characters until one matches, and only a prefix of the checkout’s commit can match. At a tag the job first verifies checksums.txt against its cosign signature). The universal binary is assembled as GoReleaser lays it out (a big-endian fat header, each slice at the next multiple of 2^14), in the slice order the published one carries, so only the two darwin rebuilds have to match. GoReleaser builds one target at a time (--parallelism=1) because the universal binary stacks the two darwin builds in the order they finished. The SBOM documents differ in their creation time and namespace, not in their contents.

THIRD_PARTY_NOTICES is committed and generated by cmd/gen_third_party_notices from what ./cmd/arcane-mcp links on each release target (go list -deps), not from go.mod, which also lists modules only tests and tools use. make check fails when it is stale; after a dependency change run make gen-third-party-notices.

Terminal window
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity "https://github.com/jmrplens/arcane-mcp/.github/workflows/release.yml@refs/tags/vX.Y.Z" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
checksums.txt
sha256sum --check --ignore-missing checksums.txt
gh attestation verify arcane-mcp-linux-amd64 --repo jmrplens/arcane-mcp \
--signer-workflow jmrplens/arcane-mcp/.github/workflows/release.yml

The provenance is also a release asset, arcane-mcp.intoto.jsonl, so a download can be verified against the release’s own file rather than GitHub’s attestation store (no gh auth login; Sigstore’s public trust root is still fetched):

Terminal window
gh attestation verify arcane-mcp-linux-amd64 --bundle arcane-mcp.intoto.jsonl --repo jmrplens/arcane-mcp \
--cert-identity "https://github.com/jmrplens/arcane-mcp/.github/workflows/release.yml@refs/tags/vX.Y.Z"

Before attaching it, the build job holds it to the release (scripts/release_provenance.py): its statements must name exactly the lines of checksums.txt and the SHA-256 of every .mcpb, nothing missing and nothing over, every one of those files must have that digest, and then every file is verified against the bundle under the release workflow’s identity at the tag. A rehearsal writes the same subjects as unsigned statements and runs the same subject and digest checks, so a checksums.txt with a line added or a file that differs from it fails there too; only the signatures wait for a tag.

The checks above prove what the build made. After a real release, the verify-published job reads every channel back from where users get it and holds it to the release’s signed checksums.txt, in a job holding no write permission, no secret and no OIDC identity (contents: read only):

  1. the checksums.txt the release serves, held to its cosign signature (identity release.yml@refs/tags/vX.Y.Z): the reference for everything below;
  2. the image: the digest of ghcr.io/jmrplens/arcane-mcp:X.Y.Z must carry publish-image.yml’s signature and print this version, and Docker Hub, when it has the tag, must serve the same index (the image’s binary is built apart from the release binaries, so checksums.txt cannot name it);
  3. scripts/verify_published.py, standard library only and anonymous:
    • GitHub release: every asset checksums.txt names, downloaded and hashed, each .mcpb bundle compared with the build’s copy, and arcane-mcp.intoto.jsonl, whose statements must name exactly those files with those digests (before the script, the job also downloads every asset and verifies each against that file with gh attestation verify --bundle);
    • npm: the launcher at this version pins the six platform packages to it, each platform package’s binary and THIRD_PARTY_NOTICES are the signed ones, and all seven tarballs are the very files publish-npm validated (its npm_sha256 output; publish-npm.sh packs again to publish, and the packs are byte-reproducible), so an added install script or a changed cli.js fails, with npm’s dist.integrity (sha512) cross-checked against each download;
    • PyPI: each of the six wheels carries the signed binary and notices, and is the very file publish-pypi validated (its wheels_sha256 output);
    • NuGet: the pointer is listed at this version, each runtime package carries the signed binary, and all seven packages, with nuget.org’s repository signature (.signature.p7s) removed the way NuGet defines an unsigned package, are the files publish-nuget validated and upload-nuget attested (its packages_sha256 output).

The MCP Registry, the Homebrew tap and winget wait for it, so an outage of any one registry delays all three until a re-run (intended: they announce the version only once every channel serves it), because each announces the version to people and services outside this repository: none of them should name packages that were not read back and found to be the signed bytes. That costs the registry publish the verification’s time (minutes; NuGet’s validation is the long part), which a listing can afford.

A registry that has not finished publishing answers 404 (nuget.org lists a version only after validating its push), so downloads are retried, 30 seconds apart, against wall-clock deadlines from the step’s start: GitHub, npm and PyPI until 15 minutes, NuGet until 40, and no request at all after 45, each request timing out after at most 60 seconds, so even a host that hangs ends in the problem list inside the job’s 50-minute timeout. A digest that does not match is never retried: the job fails at once and names the channel, the file and both digests.

A failure means a channel serves something other than what the release signed, or never served it. Nothing announces the version (the registry, the tap and winget are skipped), but what was already published stays: a wrong npm, PyPI or NuGet version cannot be replaced, only deprecated, yanked or unlisted, and the fix is a new release. A failure that only says a download never came back (the allowance spent) is lag or an outage: re-run the failed jobs from the run’s page, which re-runs verify-published and then the jobs that wait for it. The script also runs by hand at any time, against any published release: python3 scripts/verify_published.py X.Y.Z checksums.txt --retry-budget 0 (the image step is the workflow’s).

In a rehearsal nothing is published, so the job serves the run’s own packages (the release-dist, npm-packages, pypi-wheels and nuget-packages artifacts) from local stand-ins for the registries (scripts/fake_registries.py, which adds a repository signature to every NuGet package as nuget.org does) and runs the same comparison against the snapshot’s checksums.txt; the image and the checksums signature are not checked, and it says so. Locally, make verify-published does the same after make release-snapshot, make npm, make pypi, make nuget and make mcpb; make check-verify-published runs the script against stand-in packages built by the real build scripts (part of go test ./...).

publish-image.yml publishes ghcr.io/jmrplens/arcane-mcp and, when the DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets exist, docker.io/jmrplens/arcane-mcp (without them Docker Hub is skipped with a warning). The image is the static binary on gcr.io/distroless/static-debian12:nonroot, built from the Dockerfile for linux/amd64 and linux/arm64, with the license and THIRD_PARTY_NOTICES in /usr/share/licenses/arcane-mcp/, the OCI labels and io.modelcontextprotocol.server.name=io.github.jmrplens/arcane-mcp. Its binary is stamped like the release binaries (arcane-mcp --version gives the version without its v, the short commit and the commit’s date).

TagWhen
X.Y.ZEvery release
X.Y, latestOnly the newest stable release; a backport moves neither

Two jobs, so that the job running build tooling never holds the signing identity, and a failed signature is retried against the pushed digest without rebuilding:

  • build: each platform is built, loaded and run through scripts/smoke-test-image.sh (arm64 under QEMU); then one multi-arch build with BuildKit’s SBOM and provenance inside the index is pushed to both registries at once, so the digest is the same on both. syft writes an SPDX SBOM per platform, and the job fails if it does not name the server at v<version>. On the newest release the Docker Hub description is replaced with docker/README.md.
  • sign-attest: signs the index and its platform manifests keyless with cosign on both registries, and attests SLSA provenance and the SBOMs for the index and both platforms, attached to ghcr.io and kept in GitHub’s attestation store.

The image build keeps -ldflags in the binary’s build information (no -trimpath), because that is where SBOM scanners read the server’s version: the build context has no .git, and with -trimpath syft reports the module as UNKNOWN.

The smoke test needs no Arcane and no credentials. For each image it checks --version, the labels and the non-root user, the version in the build information, the license files, an MCP initialize and tools/list over stdio, and /healthz plus an initialize over Streamable HTTP. docker-ci.yml runs hadolint and the smoke test for both platforms on every pull request that touches the image; locally, make image-smoke (add IMAGE_PLATFORM=linux/arm64 where QEMU is registered).

Terminal window
cosign verify ghcr.io/jmrplens/arcane-mcp:X.Y.Z \
--certificate-identity "https://github.com/jmrplens/arcane-mcp/.github/workflows/publish-image.yml@refs/tags/vX.Y.Z" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
gh attestation verify oci://ghcr.io/jmrplens/arcane-mcp:X.Y.Z --repo jmrplens/arcane-mcp

The certificate names publish-image.yml, not release.yml: a reusable workflow signs with its own identity.

Distroless has no shell or HTTP client, so the image’s HEALTHCHECK runs the binary itself: arcane-mcp --probe asks the listener the container’s environment describes for /healthz, and a stdio container probes healthy (see Listener, probes and shutdown).

scripts/build-mcpb.sh packs four .mcpb bundles from the release’s raw binaries, mcpb/manifest.json, mcpb/platform.jq, mcpb/linux/launch.sh, the 512×512 icon (site/public/logo-512.png), LICENSE and THIRD_PARTY_NOTICES:

BundleCarries
arcane-mcp-darwin.mcpbthe universal macOS binary
arcane-mcp-windows.mcpbthe Windows amd64 executable (Windows on Arm runs it under emulation)
arcane-mcp-linux.mcpblaunch.sh and both Linux binaries; the launcher picks one by uname -m
arcane-mcp.mcpball three, a single download for any system

A manifest chooses its command by operating system only, so each per-system bundle’s manifest (derived by mcpb/platform.jq) lists only its own platform, and Claude Desktop refuses it on another system with a clear message. The extension settings are the Arcane URL and API key (required; the key is a sensitive field), the default environment, the tool surface, read-only and safe mode, skipping TLS verification, the log level and icons, each passed to the server as its environment variable and each with a default, since the server reads an empty surface or environment as an error. Entries are written in a fixed order with fixed modes and timestamps, so the same binaries give byte-identical bundles; the script then reads each archive back and checks its entries, the execute bits and every path its manifest names. make check-mcpb validates the universal and the three derived manifests against the MCPB manifest schema (v0.3, vendored in test/release/testdata), checks every setting against server.json, and runs both scripts on stand-in binaries.

npx -y @jmrp.io/arcane-mcp runs the server with nothing else installed. The launcher @jmrp.io/arcane-mcp (npm/arcane-mcp: package.json, cli.js, README.md) depends on six platform packages as optional dependencies, and npm installs only the one whose os and cpu match:

PackageBinary
@jmrp.io/arcane-mcp-linux-x64, -linux-arm64arcane-mcp-linux-amd64, -arm64
@jmrp.io/arcane-mcp-darwin-x64, -darwin-arm64arcane-mcp-darwin-amd64, -arm64
@jmrp.io/arcane-mcp-win32-x64, -win32-arm64arcane-mcp-windows-amd64.exe, -arm64.exe

Nothing runs at install time and nothing is downloaded but the packages. cli.js resolves the platform package, runs its binary with standard input and output inherited, passes SIGTERM, SIGINT and SIGHUP on to it (and stops it if npx’s shell dies), and exits with its exit code or signal; on a platform without a package it says which ones exist. The binaries are static, so the Linux packages work on musl (Alpine) too. The launcher carries mcpName: io.github.jmrplens/arcane-mcp, which the MCP Registry checks before it accepts the npm entry of server.json.

publish-npm.yml:

  1. takes the raw binaries from the release-dist artifact and, outside a rehearsal, verifies the cosign signature of checksums.txt;
  2. builds the seven packages with scripts/build-npm.mjs, which holds every binary and THIRD_PARTY_NOTICES to checksums.txt and stamps the version into a copy of the committed launcher;
  3. validates them with scripts/validate-npm.mjs: names, one version, MIT, the repository URL trusted publishing requires, os/cpu, the binaries and their execute bit, the launcher’s bin, mcpName and six pins, each tarball’s file list, byte-identical tarballs from two packs, and, from an offline install of the seven tarballs, npx arcane-mcp --version and an MCP initialize and tools/list through the launcher;
  4. publishes with scripts/publish-npm.sh: the six platform packages first, the launcher last, with --provenance, under the dist-tag latest for the newest release and release-X.Y for a backport; a version already on npm is skipped, so a re-run completes a partial publish. A rehearsal packs instead.

Publishing uses npm trusted publishing: no npm token exists anywhere. npm matches the job’s OIDC token against each package’s trusted publisher, which names the calling workflow (release.yml, not publish-npm.yml) and the release environment. It needs npm 11.5.1 or later (the workflow installs a pinned 12.x, held to the SHA-512 the registry lists) and a public repository for provenance.

A trusted publisher can only be configured on a package that exists, so the seven names are created once with placeholder packages (0.0.0-bootstrap.0: a README and the license, no binary), leaving the first real release, with provenance, to the workflow. From a checkout of main, logged in to npm as the owner of the @jmrp.io scope:

Terminal window
npm login
node scripts/build-npm.mjs --bootstrap --out /tmp/arcane-mcp-npm-bootstrap
node scripts/validate-npm.mjs /tmp/arcane-mcp-npm-bootstrap
bash scripts/publish-npm.sh /tmp/arcane-mcp-npm-bootstrap 0.0.0-bootstrap.0 --tag bootstrap

Then, for each of the seven packages (@jmrp.io/arcane-mcp and @jmrp.io/arcane-mcp-{linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64,win32-arm64}), on npmjs.com → the package → Settings:

  1. Trusted publisher → GitHub Actions: organization or user jmrplens, repository arcane-mcp, workflow filename release.yml, environment release, allowed action npm publish.
  2. Publishing access: require two-factor authentication and disallow tokens, so the workflow is the only way to publish.

npm may point latest at the placeholder until the first release moves it. After that release, retire the placeholders:

Terminal window
for p in arcane-mcp arcane-mcp-linux-x64 arcane-mcp-linux-arm64 arcane-mcp-darwin-x64 arcane-mcp-darwin-arm64 arcane-mcp-win32-x64 arcane-mcp-win32-arm64; do
npm deprecate "@jmrp.io/$p@0.0.0-bootstrap.0" "placeholder; install a released version"
npm dist-tag rm "@jmrp.io/$p" bootstrap
done

uvx arcane-mcp runs the server with nothing else installed (as do pipx run arcane-mcp, uv tool install arcane-mcp and pip install arcane-mcp). The PyPI project is arcane-mcp; the name was free when this was written, but a pending trusted publisher does not reserve it. Each of six wheels carries one release binary in its .data/scripts directory, the way ruff, uv and ziglang ship theirs: the installer puts it on the environment’s scripts path with the execute bit set, so the arcane-mcp command is the release binary itself, byte for byte, with no Python process in between (its stdio, signals and exit status are its own). There is no console-script entry point (it would collide with the binary, which already has the distribution’s name, the one uvx arcane-mcp looks for). A small arcane_mcp module adds python -m arcane_mcp (which execs the binary, or on Windows runs it and passes on its exit code) and arcane_mcp.find_binary().

Wheel platform tagBinary
manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_1_x86_64 (and the aarch64 twin)arcane-mcp-linux-amd64 (-arm64)
macosx_13_0_x86_64, macosx_13_0_arm64arcane-mcp-darwin-amd64, -arm64
win_amd64, win_arm64arcane-mcp-windows-amd64.exe, -arm64.exe

The Linux binaries are static (no program interpreter, no dynamic section), so one wheel with a compressed tag set serves glibc and musl (Alpine) systems, as ziglang does; manylinux_2_17 (manylinux2014) is the oldest manylinux policy still maintained; the static binary needs nothing from glibc. The macOS tag is the minimum system Go writes into the binaries (macOS 13), which the validation reads back from each binary, so a Go upgrade that raises it fails the validation instead of shipping a wheel that claims too much. There is no source distribution: building one would mean building the Go binary, which a Python installer cannot do, and neither PyPI nor the MCP Registry needs one (the registry reads the mcp-name: io.github.jmrplens/arcane-mcp line from the version’s description, which the wheels carry from pypi/README.md).

publish-pypi.yml:

  1. takes the raw binaries from the release-dist artifact and, outside a rehearsal, verifies the cosign signature of checksums.txt;
  2. builds the six wheels with scripts/build_pypi.py (standard library only), which holds every binary and THIRD_PARTY_NOTICES to checksums.txt and writes the entries in a fixed order with fixed timestamps and modes, so the same binaries give byte-identical wheels;
  3. validates them with scripts/validate_pypi.py: the six file names and their tags, the entries, their modes and RECORD, the core metadata (2.4, License-Expression: MIT, LICENSE and THIRD_PARTY_NOTICES as License-File, the issue and security URLs, pypi/README.md as the description), each binary against checksums.txt and against its tag (format, CPU, static linking on Linux, minimum macOS), a second byte-identical build, twine check --strict when twine is installed, and then the host’s wheel installed into a fresh virtual environment with pip --no-index: the installed command must be the release binary, and arcane-mcp --version, python -m arcane_mcp --version and an MCP initialize and tools/list must answer (plus uvx --offline --from <wheel> arcane-mcp --version when uv is on PATH);
  4. records their SHA-256s as the workflow’s wheels_sha256 output;
  5. runs the upload script’s first half, scripts/upload-pypi.sh dist/pypi/wheels <version> --dry-run, on every run, rehearsals included: it installs twine and pypi-attestations from the hash-pinned lock .github/requirements/pypi-upload.txt and runs twine check --strict over the six wheels, so the lock and the check are exercised before a tag needs them;
  6. hands the wheels on as the pypi-wheels artifact. It does not upload.

PyPI’s trusted publishing does not accept a token minted inside a reusable workflow (pypa/gh-action-pypi-publish#166; pypa’s advice is to keep the publishing job in the top-level workflow), so release.yml uploads the wheels in its own upload-pypi job, which holds only id-token: write (and contents: read, for the script and its lock), runs only on a tag push, in the release environment, checks out the tag without credentials, holds the downloaded artifact to the wheels_sha256 digests with sha256sum -c (any job of the run that holds the runtime token could replace an artifact, but not another job’s output, so what is uploaded is what was validated), and runs scripts/upload-pypi.sh. The script holds the directory to exactly the six wheels of the version, installs twine and pypi-attestations into a throwaway virtual environment of the runner’s python3 from .github/requirements/pypi-upload.txt (--require-hashes --only-binary :all:: nothing resolved or built at run time), signs a PEP 740 publish attestation per wheel with the workflow’s identity, then exchanges the job’s OIDC token for a short-lived upload token as PyPI documents trusted publishing by hand (https://pypi.org/_/oidc/audience, then https://pypi.org/_/oidc/mint-token) and uploads wheels and attestations with twine upload --skip-existing --attestations (a re-run completes a partial upload). It replaced pypa/gh-action-pypi-publish, whose composite step runs a container image named by a registry tag (docker://ghcr.io/pypa/gh-action-pypi-publish:<ref>), which the action’s SHA pin does not fix. No PyPI token exists anywhere. The lock is compiled with pip-compile --generate-hashes (its header names the command), so Dependabot’s pip entry for /.github/requirements recompiles it after its 7-day cooldown, transitive pins and hashes included. To move a pin by hand, edit .github/requirements/pypi-upload.in and recompile under Python 3.12 with pip 26 or later, setting PIP_UPLOADED_PRIOR_TO to seven days before the day you run it (the dependency cooldown), as the .in file shows. The run’s artifacts last seven days: re-running upload-pypi after that needs publish-pypi re-run too, as with release-dist for every channel. PyPI has no dist-tags: pip installs the highest version, so a backport never displaces the newest release.

make pypi, after make release-snapshot RELEASE_TAG=v$(cat VERSION), builds and validates the wheels from the snapshot exactly as the workflow does (it needs python3; the smoke test runs the wheel of the machine it runs on).

The project does not exist yet, so its trusted publisher is created as a pending publisher, which creates the project on the first upload: no token and no first upload by hand. On pypi.org, logged in as the owner (two-factor authentication is mandatory on PyPI): Account → Publishing → Add a new pending publisher → GitHub, with PyPI project name arcane-mcp, owner jmrplens, repository name arcane-mcp, workflow name release.yml, environment name release. A pending publisher does not reserve the name: if someone registers arcane-mcp first, the pending publisher stops working and the channel needs another name. After the first release, the project’s Publishing page lists the publisher as active; do not create API tokens for the project.

dnx arcane-mcp runs the server with the .NET 10 SDK and nothing else (as does dotnet tool install -g arcane-mcp, which puts the command on PATH). The packages form a .NET tool whose command is the release binary, in the layout the .NET 10 SDK uses for platform-specific tools: the pointer package arcane-mcp, whose tools/net10.0/any/DotnetToolSettings.xml (version 2) names one package per runtime identifier, and six runtime packages arcane-mcp.<rid>, each carrying one release binary under tools/any/<rid>/ with a settings file that runs it directly (Runner="executable"). The SDK picks the package for the host, so the command is the release binary byte for byte, with no .NET runtime in between. The pointer also carries .mcp/server.json (the McpServer package type, from which nuget.org renders an MCP configuration) and nuget/README.md, with the mcp-name: io.github.jmrplens/arcane-mcp line the MCP Registry reads from the version’s README.

PackageBinary
arcane-mcp.linux-x64, arcane-mcp.linux-arm64arcane-mcp-linux-amd64, -arm64
arcane-mcp.osx-x64, arcane-mcp.osx-arm64arcane-mcp-darwin-amd64, -arm64
arcane-mcp.win-x64, arcane-mcp.win-arm64arcane-mcp-windows-amd64.exe, -arm64.exe

The SDK chooses through the runtime identifier graph, in which linux-musl-x64 falls back to linux-x64; the Linux binaries are static, so Alpine gets the linux-x64 package and it runs (checked in the mcr.microsoft.com/dotnet/sdk:10.0-alpine image). When its standard input is not a terminal, as when an MCP client starts it, dnx installs the tool without asking; arguments for the server go after -- (dnx arcane-mcp -- --version), since dnx reads its own options anywhere on the line.

publish-nuget.yml:

  1. takes the raw binaries from the release-dist artifact and, outside a rehearsal, verifies the cosign signature of checksums.txt;
  2. installs the .NET SDK at the exact version the workflows pin, checked against the SHA-512 Microsoft publishes for it;
  3. builds the seven packages with scripts/build_nuget.py (standard library only), which holds every binary and THIRD_PARTY_NOTICES to checksums.txt and writes the entries in a fixed order with fixed timestamps and modes, so the same binaries give byte-identical packages;
  4. validates them with scripts/validate_nuget.py: the seven file names and the push order, the entries and their modes, the OPC parts, a layout nuget.org’s repository signature can be added to and stripped from without touching anything else (no data descriptor, zip64 or comment), each nuspec (id, version, package types, the MIT expression with the licenseUrl nuget.org requires beside it, readme, icon, repository), the tool settings of the pointer and of each runtime package, .mcp/server.json against server.json, each binary against checksums.txt and against its runtime identifier (format, CPU, static linking on Linux, minimum macOS), a second byte-identical build, and then, for the runner’s platform, dotnet tool install from the packages alone (the installed command must be the release binary) and dotnet dnx, each answering --version and an MCP initialize and tools/list;
  5. hands the packages on as the nuget-packages artifact, and their SHA-256s as the workflow’s packages_sha256 output. It does not push; a rehearsal lists the pushes it would make.

nuget.org’s trusted publishing does not work from a reusable workflow (NuGet/login#6 and, for a reusable workflow in the same repository, NuGet/login#9: nuget.org matches the policy against the token’s job_workflow_ref, which names the called workflow, not release.yml; NuGetGallery#11000), so release.yml pushes in its own upload-nuget job, which runs only on a tag push, in the release environment, holds only id-token: write and attestations: write, checks out nothing, and runs only pinned actions and the pinned SDK: it holds the downloaded artifact to the packages_sha256 digests with sha256sum -c (as upload-pypi does, so what is pushed is what was validated), attests the seven packages as packed (SLSA provenance; nuget.org adds a repository signature on top, which a verifier removes first), exchanges the job’s OIDC token for a one-hour API key with NuGet/login, pushes the six runtime packages before the pointer that names them with --skip-duplicate, the key read from NUGET_API_KEY rather than given on the command line, (a re-run completes a partial push), and stops there; verify-published, with no permission to write, then waits up to 40 minutes until nuget.org lists the version (it validates every push first, which takes minutes) and checks what it serves, and the MCP Registry, which fetches the README, waits for it. No NuGet API key is stored anywhere. As for PyPI, re-running upload-nuget after the seven-day artifact retention needs publish-nuget re-run too. NuGet has no dist-tags: the highest version is the default, so a backport never displaces the newest release.

make nuget, after make release-snapshot RELEASE_TAG=v$(cat VERSION), builds and validates the packages from the snapshot exactly as the workflow does (it needs python3 and the .NET SDK the workflows pin; DOTNET=<path> names one that is not on PATH).

nuget.org policies belong to an account, not to a package, so nothing has to exist first and there is no first push by hand. On nuget.org, logged in as jmrplens: your user name → Trusted Publishing → Create, with package owner jmrplens, repository owner jmrplens, repository arcane-mcp, workflow file release.yml (the file name only) and environment release. A policy created while the repository is private starts active for seven days only and becomes permanent after its first successful login; if those days pass, reactivate it from the same page before tagging. The policy lets that workflow push any package the account owns, which is why it names the environment. Do not create API keys for these packages; the ids arcane-mcp and arcane-mcp.<rid> were free when this was written, and nothing reserves them until the first release.

brew install jmrplens/tap/arcane-mcp installs the server on macOS and Linux. The formula Formula/arcane-mcp.rb lives in the existing tap jmrplens/homebrew-tap and is written by scripts/update-homebrew-tap.sh: for each system (on_macos, on_linux) and CPU (on_arm, on_intel) it downloads the raw release binary and pins it to its SHA-256 in checksums.txt, installs it as arcane-mcp, puts LICENSE (from the tag’s tree, pinned to this repository’s file) and THIRD_PARTY_NOTICES (a release asset, pinned to its checksum) in the keg, and its test block runs arcane-mcp --version. It requires macOS 13 (Ventura) or later (depends_on macos: :ventura), the minimum Go writes into the darwin binaries; on Linux there is no such requirement. Windows has no Homebrew; it has winget.

publish-homebrew.yml runs for the newest release only (the tap serves one version, and a backport would downgrade every brew upgrade; the script also refuses to write an older version over a newer one). It verifies the cosign signature of checksums.txt and that it is the very file the release serves, then renders the formula, checks it with ruby -c, commits it to a clone of the tap and pushes it with an SSH deploy key (TAP_DEPLOY_KEY_B64, in the release environment; GitHub’s Ed25519 host key is pinned in the workflow, not scanned). Without the secret the job warns and pushes nothing; the key file is removed when the step ends, whatever its outcome. The tap is shared with other projects’ releases, so a push refused because the tap moved is rebased once onto its main and pushed again; re-running an old release after a newer one shipped leaves the formula alone and ends with a notice. A rehearsal clones the tap read-only over HTTPS, commits locally and shows the commit; the key never reaches it.

make homebrew, after make release-snapshot RELEASE_TAG=v$(cat VERSION), renders the snapshot’s formula into dist/homebrew and, when Docker is available, runs scripts/check-homebrew-formula.sh in the homebrew/brew image pinned by digest: brew style and brew audit --strict on the formula as the tap will hold it, then brew install and brew test of the same formula with file:// URLs to the snapshot’s assets, and a check that the installed command is the release binary byte for byte (HOMEBREW_DOCKER_FLAGS passes extra docker run flags, such as a proxy’s CA bundle).

  1. Create a key pair for this repository alone: ssh-keygen -t ed25519 -N "" -C "arcane-mcp release" -f tap_deploy_key.
  2. In jmrplens/homebrew-tap → Settings → Deploy keys → Add deploy key: the contents of tap_deploy_key.pub, title arcane-mcp release, Allow write access ticked.
  3. In this repository → Settings → Environments → release → Add secret: TAP_DEPLOY_KEY_B64, the output of base64 -w0 tap_deploy_key (base64 -i tap_deploy_key on macOS). Then delete both local files.
  4. Add arcane-mcp to the tap’s README.md list of formulae (the workflow writes only Formula/arcane-mcp.rb). The formula pushes to the tap’s main branch.

winget install jmrplens.arcane-mcp installs the server on Windows (x64 and arm64) from microsoft/winget-pkgs, as a portable package: the raw release binary arcane-mcp-windows-<arch>.exe, run as arcane-mcp. Every stable release is submitted, backports included: winget-pkgs keeps every version and installs the highest.

publish-winget.yml has two jobs:

  1. manifests verifies the cosign signature of checksums.txt, renders the version’s three manifests (schema 1.12.0, the one komac writes) with scripts/build_winget.py, checks them against the winget schemas vendored in test/release/testdata/winget, and keeps them as the winget-manifests artifact;
  2. submit installs komac at a pinned version, held to its SHA-256 (komac publishes no signature), syncs the fork jmrplens/winget-pkgs with upstream (komac branches from it, and a stale fork fails), and runs komac update jmrplens.arcane-mcp --version X.Y.Z --urls <the two release URLs> --submit, which opens the version’s pull request from the fork; then it deletes the fork’s merged branches. Every write uses WINGET_TOKEN (the release environment); without it the job warns and submits nothing.

komac update adds a version to a package that winget-pkgs already has, so until the first version is merged the submit job warns that the package is missing and submits nothing; the first version is submitted by hand (below). A rehearsal renders and validates the manifests and, once the package exists, runs komac update --dry-run for the latest published release (the rehearsed version is not published), with the job’s own token. make winget renders the snapshot’s manifests into dist/winget and checks them against the schemas.

The first winget version and the token (once, by hand)

Section titled “The first winget version and the token (once, by hand)”
  1. Token: a classic personal access token of jmrplens with the public_repo scope (komac creates the branch on the fork and the pull request on microsoft/winget-pkgs, which a fine-grained token cannot do for a repository it does not own), stored as WINGET_TOKEN in the release environment. Such a token can write to every public repository of jmrplens, and the workflow hands it to komac; a machine-user account that owns the winget-pkgs fork, with its own token, would narrow that to the fork. The fork jmrplens/winget-pkgs must exist (komac also creates it on first use).

  2. First version, once the first GitHub release is published, from any machine with komac 2.17.1 and that token in GITHUB_TOKEN (komac asks for anything it cannot detect; answer that the installer is portable, with the command arcane-mcp):

    Terminal window
    V=0.1.0 # the first release
    BASE=https://github.com/jmrplens/arcane-mcp/releases/download/v$V
    komac new jmrplens.arcane-mcp --version "$V" \
    --urls "$BASE/arcane-mcp-windows-amd64.exe" "$BASE/arcane-mcp-windows-arm64.exe" \
    --package-locale en-US --publisher "José M. Requena Plens" --publisher-url https://github.com/jmrplens \
    --publisher-support-url https://github.com/jmrplens/arcane-mcp/issues --author "José M. Requena Plens" \
    --package-name arcane-mcp --package-url https://docs.jmrp.io/arcane-mcp/ --moniker arcane-mcp \
    --license MIT --license-url "https://github.com/jmrplens/arcane-mcp/blob/v$V/LICENSE" \
    --copyright "Copyright (c) 2026 José M. Requena Plens" \
    --short-description "MCP server exposing every Arcane (Docker management) capability as tools" \
    --description "A Model Context Protocol server for Arcane, the Docker management UI: containers, images, Compose projects, volumes, networks, Swarm, environments, registries and templates as tools for MCP clients, over stdio or Streamable HTTP. A single static binary; configure it with ARCANE_URL and ARCANE_API_KEY." \
    --release-notes-url "https://github.com/jmrplens/arcane-mcp/releases/tag/v$V" \
    --submit

    Compare what komac shows with the release run’s winget-manifests artifact, which holds the same fields already checked against the schemas; or submit that artifact itself, unchanged: komac submit manifests/j/jmrplens/arcane-mcp/$V from inside it (--dry-run first). Once Microsoft merges the pull request, every later release is submitted by the workflow.

server.json is the registry entry io.github.jmrplens/arcane-mcp: the three per-system bundles (release-asset URL and SHA-256) and the image on ghcr.io and docker.io, pinned by digest, the npm launcher @jmrp.io/arcane-mcp (run with npx), the PyPI package arcane-mcp (run with uvx) and the NuGet tool arcane-mcp (run with dnx, on registryBaseUrl https://api.nuget.org/v3/index.json), each with the same environment variables. There is no remotes entry: Arcane instances live on private networks, so there is no hosted server to point at. The committed file is a template with all-zero placeholders; publish-registry.yml, on the newest stable release only:

  1. takes the bundles from the release-dist artifact and checks they are the bytes published on the release;
  2. resolves the digest of ghcr.io/jmrplens/arcane-mcp:<version>, checks it carries publish-image.yml’s signature, and declares Docker Hub only if it serves the same index;
  3. stamps a copy with scripts/update-server-json-sha.sh, which refuses a set without exactly one bundle per system or with a placeholder left;
  4. checks the stamped file against the registry schema (2025-12-11, vendored) and rules (make check-server-json with SERVER_JSON set);
  5. publishes it with mcp-publisher (pinned, SHA-256 and signature verified), logging in with the workflow’s GitHub OIDC token, which proves the io.github.jmrplens namespace; no secret is involved.

The registry fetches every package it lists when it accepts a version, so the entry can only be published once the repository and the ghcr.io package are public. In a rehearsal the digest is a stand-in and mcp-publisher validate replaces the publish. The stamped file is kept as the server-json workflow artifact; the committed template is not rewritten.

Registries and directories (Glama, for instance, which builds the Dockerfile) start the server to list its tools without access to any Arcane. The catalog is static, so initialize, tools/list, prompts/list and resources/list answer with any URL, reachable or not; over stdio an API key must be set, but it is not checked until a tool runs:

Terminal window
export ARCANE_URL=http://arcane.invalid:3552 ARCANE_API_KEY=introspection
arcane-mcp # stdio
docker run -i --rm -e ARCANE_URL -e ARCANE_API_KEY ghcr.io/jmrplens/arcane-mcp:latest
ARCANE_MCP_HTTP_ADDR=:8080 arcane-mcp # Streamable HTTP on /mcp; /healthz answers ok

A tool call then fails with an error saying Arcane cannot be reached, which is the expected answer.

Every uses: in .github/workflows/ names a full commit SHA with the version in a comment (actions/checkout@<sha> # v7), so a moved tag cannot change what runs in a job that holds a write token or the signing identity; Dependabot updates the SHA and the comment together. cmd/audit_workflow_pins (make check-workflow-pins, part of make check and CI) fails on any other reference, and holds seven more rules. It reads each workflow with its YAML aliases resolved, so an aliased permissions or env block counts as written out.

  • No credential left behind. Every actions/checkout sets persist-credentials: false. A job that really pushes with the checkout’s token is declared in cmd/audit_workflow_pins/policy.go with its reason; today none does.
  • Cooldown. Every entry of .github/dependabot.yml waits at least 7 days after a release before proposing it.
  • No code resolved at run time in a credentialed job. A job is credentialed when its token holds any permission at write (or write-all, or no permissions block at all), or when the job or the workflow-level env reads a secret that policy.go does not declare read-only. Such a job runs, in its run: blocks and in the scripts/*.sh they name (and the scripts those name), no npx, npm exec, pnpm dlx, yarn dlx or bunx without an exact version, no @latest, no download piped into a shell or interpreter (by name or path, behind sudo or env, or through $(…) or <(…)), no go install or go run at anything but an exact version, no pip install without --require-hashes, no uvx or uv tool run without an exact version, no docker run, docker pull or docker create of an image written out without a digest (an image held in a $variable is not judged), and no make, directly or behind sh -c, bash -c, xargs or timeout (its recipes are not followed, so they cannot be judged: such a job writes the commands out, as sonar.yml does for make cover, and a test holds that copy equal to the Makefile’s recipe). A goreleaser/goreleaser-action step has its configuration read (--config/-f in args, or GoReleaser’s own file names in workdir): its hooks and the command of every sign, sbom and publisher are judged the same way, and a configuration that cannot be read is a finding. Not read there: universal_binaries and archives hooks, dockers/kos (their base images) and includes; .goreleaser.yml uses none of them.
  • No image by tag in a credentialed job. Its container: and services: images name a digest (name:tag@sha256:…). docker/setup-qemu-action gets image: and docker/setup-buildx-action an image= in driver-opts (unless its driver starts no builder) pinned by digest, because their defaults are tags (tonistiigi/binfmt:latest, started with --privileged, and moby/buildkit:buildx-stable-1). Actions that run an image their SHA pin does not fix are refused there (pypa/gh-action-pypi-publish). Dependabot does not read these inputs: publish-image.yml and docker-ci.yml record the tag beside the digest, to refresh by hand with docker buildx imagetools inspect <image>:<tag>.
  • No Actions cache in a credentialed job, as far as the gate knows the actions. Any run of the repository can write the Actions cache. In such a job actions/cache (restore and save included) is refused; setup-go sets cache: false, setup-node package-manager-cache: false and no cache:, setup-python no cache:, setup-qemu-action cache-image: false and setup-buildx-action cache-binary: false; and no step input names a type=gha BuildKit cache (build-push-action’s cache-from and cache-to). A cache another action opens on its own is not seen.
  • Shapes the other rules would misread. A permissions block that is neither a mapping nor a scalar (judged as able to write), a YAML merge key (<<) and an alias nested in itself are findings.
  • Downloaded tools are pinned and checked, as far as the gate can read. In every job:
    • A file a run: block saves with curl or wget must be named, in the same step, by a sha256sum/sha512sum -c line or by cosign verify-blob (or gh attestation verify, minisign -V, gpg --verify).
    • A download piped into anything but a text reader (tar, tee, an interpreter) is refused, because nothing can check it first.
    • A global npm/pnpm install must be of a checked local file.
    • pip needs --require-hashes. gem, cargo, pipx and uv tool installs are refused.
    • go install names an exact version, which the Go checksum database fixes.
    • Env values whose name ends in _VERSION are exact versions.
    • Tool-installing actions get an exact version: golangci-lint is installed with go install. setup-go and setup-node take one from go.mod and site/.node-version, but neither checks a hash of the toolchain it downloads.

What the gate does not read:

  • make targets outside credentialed jobs (inside them make is refused, unless it is reached in a way the gate does not read, such as a variable holding its name), the Node and Python scripts under scripts/, and scripts reached through $(dirname "$0");
  • what the packages of a go run or go build import (only the version is judged);
  • a command kept in a variable or a function and run later;
  • the code inside actions, local composite actions (uses: ./…) included;
  • version pins in env names that do not end in _VERSION.

It is offline. A check counts when it names the file. Whether the checksum is the right one, or comes from the same server as the file, is for review.

The tools the release jobs download are pinned that way: GoReleaser and cosign through their actions (which verify the download), syft against its SHA-256 and its signed checksums, mcp-publisher against its SHA-256 and its signature, komac against its SHA-256, npm against the SHA-512 the registry lists, and the .NET SDK against the SHA-512 Microsoft publishes. Dependabot does not manage these pins; bump them by hand with the new checksum taken from the project’s own published checksums.

The version (VERSION holds the candidate, 0.1.0) and whether the repository becomes public are the owner’s decisions. On GitHub:

  1. Environments. release, with a deployment policy that admits only tags matching v*; release-rehearsal, with no secrets. Later channels add their secrets and trusted publishers to release only.
  2. Settings → General → Releases: enable immutable releases.
  3. Rulesets: protect tags v* from deletion, update and force-push, and restrict who may create them.
  4. Actions → General: workflow permissions stay read-only by default (each job asks for what it needs).
  5. Docker Hub (optional): create the repository jmrplens/arcane-mcp and an access token with read, write and delete scope, and store them as DOCKERHUB_USERNAME and DOCKERHUB_TOKEN in the release environment. After the first push, make the ghcr.io package arcane-mcp public and link it to the repository.
  6. MCP Registry and the Claude Desktop extension: nothing to create (the namespace is proven by the workflow’s OIDC token), but both need the repository and the ghcr.io package to be public. Listing the extension in Claude Desktop’s directory is a separate submission form (it asks for a privacy policy, which is Privacy, and test credentials for a reachable Arcane).
  7. npm: the one-time first publish and the trusted publisher of each package (see First publish).
  8. PyPI: the pending trusted publisher for arcane-mcp (see Trusted publisher). On the very first release PyPI’s JSON API may still answer 404 for the brand-new project when publish-registry runs, which then fails without retrying; one manual re-run of publish-registry fixes it.
  9. NuGet: the trusted publishing policy of the jmrplens account (see Trusted publishing policy).
  10. Homebrew: the tap’s deploy key and the README entry (see The tap’s deploy key).
  11. winget: WINGET_TOKEN, and the first version by hand after the first release (see The first winget version and the token).
  12. Directories: after the first release, the listings submitted by hand (see Directories and install buttons).
  13. Rehearse once from main and check the artifact; then bump VERSION in a commit on main (with the files that carry the version: server.json, mcpb/manifest.json, npm/arcane-mcp/package.json, plugin.json, .plugin/plugin.json and lhm.plugin.json; make check names any left behind) that also renames Unreleased on the changelog page, English and Spanish, to ## vX.Y.Z (YYYY-MM-DD) under a new empty Unreleased (preflight refuses the tag otherwise; make release-preflight shows it before you push), tag that commit vX.Y.Z and push the tag.

Signing (Sigstore) and provenance attestations need no secret: they use the workflow’s OIDC identity (id-token: write). Both are meant for a public repository: artifact attestations on a private repository need GitHub Enterprise Cloud, and a keyless signature records the repository and workflow names in Sigstore’s public transparency log.