Repository navigation
fix(release): the v0.26.0 defects — config-derived signature names, OIDC-safe npm preflight, propagation-tolerant landing probes, pinned Zola - #96
Merged
Conversation
…ntry A crate with several archives: entries registers several archives per target, and the sign stage took whichever the registry listed first. In a publish-only run the registry is the preserved manifest, where the extra entry's tar.xz sorts ahead of the primary tar.gz, so every binary signature on the v0.26.0 release was uploaded as anodizer-0.26.0-<os>-<arch>-extra.sig beside archives nothing installs from. The stem is now the first archives: entry in config order, the primary that chocolatey and scoop bind to with ids: [default], matched on the id the archive stage records in each archive's metadata. The first registered archive still stands in when the crate is not in the config or the primary produced nothing for the target, and a target with no archive keeps the target-qualified name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…as several Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…per target A `binary_signs:` signature was named after an archive read back from the artifact registry, so the same binary got different asset names depending on which command produced it: `anodizer build` signs before any archive exists, and `--publish-only` rehydrates its registry from the preserved manifest, where the `-extra` archive sorts ahead of the primary one. v0.26.0 shipped `anodizer-0.26.0-linux-amd64-extra.sig` for that reason. The name now comes from the crate's `archives:` config alone. A target covered by the primary entry — the first entry in config order whose `ids:`/`binaries:` filters take the binary — takes that entry's `name_template`, rendered in the archive stage's own per-target scope. A target no entry covers takes `<binary>-<version>-<triple>`; the whole triple is required because `Os`/`Arch` render identically for a gnu and a musl build. A `formats: [binary]` entry publishes the executable itself, so the signature takes that asset's own name and a `signs:` signature over the same bytes resolves to one name. `binary_signs[].asset_name_template:` overrides the derivation. It names the asset's base; the suffix the `signature:`/`certificate:` template appended to the binary's file name still carries over, so one template names both assets. There is one derivation: the sign stage and the verify-release gate both call `binary_sign_asset_base`. The per-target scope is spelled once as `archive_name::seed_archive_name_vars`, which the archive stage's `seed_target_context` now calls too, and the format/default-template resolvers move beside it out of `binstall.rs`. `no_signature_name_is_derived_from_a_registered_archive` walks the crate's production sources and pins the artifact-kind filter resolver as the only reader of `ArtifactKind::Archive` in stage-sign. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The npm preflight treated a 401 from `/-/whoami` as a Blocker whatever the
entry's auth mode was. On a job with `id-token: write` and `auth: auto` every
package that already exists publishes through Trusted Publishing and the token
is only the fallback for a brand-new package, so a stale secret aborted the
whole preflight and stranded PyPI and crates.io, which never touch it.
Severity now follows the auth mode:
auth: token -> Blocker (unchanged)
auth: auto, no OIDC context -> Blocker (unchanged)
auth: auto, OIDC context -> Warning naming the brand-new-package gap
auth: oidc -> the whoami probe does not run at all; a
configured token gets a verbose "ignored"
note
The OIDC context is the same predicate the publish path uses
(`npm/auth.rs::resolve_oidc_env`), not a second copy.
Also seal the npm tests that build a context and call preflight or run: they
fell through to the process env and probed the real registry on any machine
exporting NPM_TOKEN. A structural pin walks the module's test sources and
fails any preflight/run test whose context is not sealed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…OIDC Both release.yml env blocks and publish-oidc.yml carried the secret for a publisher configured `auth: auto` on jobs that grant `id-token: write`, so every package that exists already publishes through Trusted Publishing. The token's only remaining job there was to fail the whoami probe once it went stale and take PyPI and crates.io down with it. manual-publish.yml keeps its token: that workflow is token-only by design and is where a brand-new package name gets its first publish. The --preflight-secrets requirement derivation already matched this: a token is required only under `auth: token`, is one branch of an any-of under `auto`, and is not consulted under `oidc`. No code change was needed there. Docs: the release-pipeline and preflight job snippets lose the token line, and the npm page gains the preflight severity table for a bad token. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a publish Each landing probe asked its registry once, immediately after the publisher returned. A registry that has accepted a publish and not yet served it answers 404, and the run failed on that: the v0.26.0 OIDC leg published cargo, npm and PyPI successfully, then reported six of nine npm packages "not visible on registry.npmjs.org". All nine were visible a minute later. All four probes -- cargo, npm, blob and snapcraft -- now go through one helper that keeps asking until the answer is yes or the propagation window closes: 5 seconds backing off to a 30-second cap over 8 attempts inside a 3-minute budget, well past the worst lag observed. An error answer retries on the same ladder. The window is shrunk to whatever is left of the run's retry.max_elapsed budget, so an operator who bounded the release's retry time bounds this too, and a dry run probes once without waiting. A target that needed more than one ask prints one status line naming it; the per-attempt detail comes from the retry driver. Every message after the window closes is unchanged, so a genuine absence reads exactly as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pagation The assert_landing rustdoc is the source the configuration reference and the JSON schema are generated from, so the retry window is described there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs.yml installed Zola unpinned while docs-check.yml pinned 0.22.1, so every docs deploy on master since 2026-09-11 picked up 0.23.x and failed on `Unknown filter 'concat'` from the sidebar template. The local gate could not catch it: this machine runs 0.22.1. One version now, in `.zola-version` at the repo root. The setup-docs composite reads it and installs that release (a `zola-version` input overrides it), both docs workflows get Zola from the composite instead of their own install step, docs.yml builds through `task docs:site` like every other caller, and that task refuses to run when the local `zola --version` differs from the pinned one. The sidebar no longer uses the `concat` filter either: the per-section markup moved into partials/sidebar_group.html, included once per `docs` subsection and once for `migration`. The rendered HTML is unchanged apart from three whitespace-only lines per page that the old set/concat statements emitted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he run deadline The clamp was proven only by the suite's wall-clock dropping; this asks it directly: no deadline keeps the 3-minute budget, a far deadline keeps it, a near one shrinks it, a passed one zeroes it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The instant-child half shared the 40ms cadence with the slow half, so under load a plain 'true' that took 41ms to spawn and exit logged one heartbeat and failed the coverage gate. The instant half now runs on a 5-second cadence no process start can reach; the slow half keeps 40ms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ndow across the whole sweep `uploaded_file_live_on_index` answers, for one recorded upload, whether the index it was uploaded to now lists that exact filename. The project name and version come from the filename itself and the page comes from `released_files_body`, so it asks the same index question the publisher's own reconcile asks: a file reconcile would call already-published is exactly the file this reports as present. Per file rather than per version, because the index accepts one wheel at a time — a partial upload leaves the version present and one platform's wheel missing. The window was re-anchored on every call, so it was a per-target budget: 43 targets against a registry that never serves them spent 43 x 155s, over an hour inside a 20-minute job, while the wiring comment claimed one budget for the sweep. `PropagationRetry::starting_now` now anchors one absolute `sweep_deadline` in `VerifyReleaseStage::run`, capped by the run's own `retry.max_elapsed`, and every probe of that run shares it. Two more ways the ladder spent time it should not have: - Every `Err` was re-asked. A 401/403, or a store that could not be built, now breaks the ladder at once through `ControlFlow::Break`, since re-asking cannot change that answer. - Every attempt printed a warning, and the announce line fired even when no window remained and said "not yet visible" for what was a transport error. Waiting out propagation is the expected case, so the per-attempt lines go to a quiet logger at default verbosity and to the run's logger under `-v`. How much of a publish arrived late is said once, on the publisher's own result line: `npm: 9/9 ... (6/9 needed a propagation wait)`. Landing checks also probe PyPI now, per uploaded file, through the publisher's own index derivation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An npm token that fails to render, or that the registry rejects, was a
preflight Blocker no matter which credential the entry actually publishes
with. A Blocker aborts the whole pre-publish gate, so a stale NPM_TOKEN on a
job that publishes through Trusted Publishing also stopped PyPI and
crates.io, which never read that token.
The gate now decides from the entry's auth mode and the runner's OIDC
context, once per run:
auth: token unusable token is a Blocker (it is the only credential)
auth: auto, no OIDC Blocker, same reason
auth: auto + OIDC Warning — every existing package still publishes via
Trusted Publishing; only a brand-new package needs it
auth: oidc no token is resolved and none is probed
A token whose template cannot render is graded the same way as one the
registry rejects, and a whoami that cannot reach a verdict stays a Warning
under --strict wherever OIDC already covers the run. Publish-time resolution
follows the same rule: `oidc` mode resolves no token at all, so an
unrenderable `token:` no longer fails a publish that never reads it. Each
message names the package and registry it is about.
The publish subprocess now drops inherited npm_config_* credential and
config-path variables. npm ranks them above --userconfig, so an ambient one
published with a credential anodizer did not choose.
Also: the sealed-env pin walks inline test modules as well as whole test
sources, and no longer accepts a subprocess Command::env as proof that a
test context was sealed; the canned HTTP response builder moved to the
shared test responder; the lockstep audit fails if publish-oidc.yml ever
carries an npm token again; and the npm docs say which gate runs the whoami
probe and what a tokenless OIDC job gives up.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… pypi probe The propagation section said "up to 3 minutes per target", which was wrong on both halves: the ladder is 8 attempts over 155s, and the window now belongs to the whole sweep. The clamp to `retry.max_elapsed` only ever shortens that window, the example result lines are re-rendered from the real output (no warnings, one propagation-wait tally per publisher), and the pypi probe joins the probe table, the example issue list and the `assert_landing` reference. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lver Three copies of the format-override rule had drifted apart. The archive stage's own resolver honored `defaults.archives.format_overrides` and fell through an override carrying empty `formats:`; the copy in `archive_name` that binstall and the sign stage read saw only an entry's own overrides, stopped at the first matching one whatever it held, and answered with a single format rather than the list. A project that set its overrides globally therefore got derived asset names for a format the stage never wrote. `archive_name::archive_formats_for_target` is now the only resolver, taking the global list alongside the entry, and `archive_format_for_target` is its first element. The archive stage plans its outputs through it, and the two unused copies in `stage-archive`'s public surface are gone; the cases that pinned them drive the shared resolver instead. `resolve_global_archive_defaults` reads the same two core helpers rather than re-spelling the `defaults.archives` lookup. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A binary signature's asset name was unique per crate and target only, so two
binaries of one crate packed into one archive both resolved to that archive's
name and the release kept a single `.sig`. The derivation now reads the whole
group the archive stage assembles for the target — `build_plan::archive_target_binaries`,
of which the existing `archive_binary_name` is the first element — and falls
back to the whole `{{ Binary }}-{{ Version }}-{{ Target }}` triple whenever the
covering entry packs more than one binary.
Four more cases were wrong with it:
- a lipo-merged universal binary was signed by neither the stage nor the
verify-release gate, though the shared `binary` filter takes it; both slices
now narrow through `should_sign_artifact` instead of spelling the kinds, and
its `darwin-universal` target — which no build entry names — takes the
covering entry's template rendered with the binary's own name;
- the multi-crate decision read the artifact registry, so an artifact-only
sibling crate moved the name between `anodizer build` and
`anodizer release --publish-only`; it is answered from config now;
- an entry listing `binary` anywhere in its resolved formats publishes the
executable itself, not only one listing it first;
- a base that rendered empty uploaded a bare `.sig` every binary collided on;
it fails the stage.
The entry's `if:` stays deliberately unevaluated — a gate that reads the
environment would name one signature two ways — and the rule doc, the sign
docs page and the `binary_signs` rustdoc all say so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…igns The field parses on every sign config but only the `binary_signs:` slice reads it, so a `signs:` entry that set it got the derived name with no error at all. `anodizer check config` now names it, for the top-level slice and every workspace's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The docker stage files no publish report row, so nothing in a run said which image references actually reached their registry — which left the verify-release landing gate with no way to probe them. Each image artifact now carries `pushed` (artifact::PUSHED_META) written at the moment the push returned: after a buildx `--push` build or podman's explicit push loop for `dockers_v2` tags, and after `docker manifest push` for `docker_manifests` lists. A snapshot, a dry run and a `skip_push: true` manifest leave it unset. The key rides the artifact metadata map, so it round-trips through `artifacts.json` and a `--publish-only` run rehydrated from the preserved manifest still knows what was pushed. `ArtifactRegistry::pushed_images` reads that set back, de-duplicated by reference and carrying each reference's recorded digest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ding The landing gate probed every registry publisher except docker images: a `buildx build --push` returning OK proved the client finished its upload, not that the registry serves the tag afterwards, so a garbage-collected half-upload or a proxy that accepted and dropped one shipped a release whose images cannot be pulled. Each image reference the run pushed is now asked of the registry's own distribution API (`GET /v2/<repo>/manifests/<tag>`), through the same `probe_with_propagation` helper and the one sweep window every other probe shares, and reported in the same landing-issue shape. No docker daemon is involved: the probe answers a `WWW-Authenticate: Bearer` challenge by exchanging it for a token at the named realm, presenting the credential `docker login` stored for that registry when there is one and asking anonymously when there is not. Presence alone is not the verdict. When the push recorded a digest, the served manifest must hash to it — the SHA-256 over the response bytes is what a content digest is defined to be — so a tag overwritten since the push is reported as a mismatch rather than passing. A registry that could not be consulted reads as unverifiable, never as an absence: a pushed tag hidden behind a credential the probe lacks is still live for everyone holding one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…record The probe table, the config reference and the landing-probes rule now carry the sixth probe: what it asks, how it authenticates, why a digest mismatch is its own finding, and why its targets come from the artifacts rather than the publish report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rom config alone A v1 and a v3 build of one binary share a target triple, so the fallback name an uncovered target renders gave both signatures one asset name and the release kept one. The fallback now appends the same amd64 clause every default name template does, so `v3` reaches the name and the baseline `v1` keeps its historical, suffix-free one. The default archive template the derivation falls back to is now chosen from the config-only multi-crate answer, not from the registry-aware resolver: the registry differs between `anodizer build` and `anodizer release --publish-only`, which is the class of defect this derivation exists to avoid. An empty rendered stem is refused before the Windows `.exe` is appended, so a bare `.exe.sig` cannot reach a release. The archive stage's equal-binary-count check now resolves each target's formats through the shared resolver, so an entry whose `binary` format arrives through `format_overrides` (its own or `defaults.archives.format_overrides`) is exempt exactly as an entry-level `formats: [binary]` is. `anodizer check config` names `defaults.sign` when that block filled an empty `signs:`, instead of pointing at a `signs[0]` the user never wrote. Testing: - `every_landing_probe...`-style structural pin extended: the derivation may call neither `archives_more_than_one_crate(` nor `default_archive_name_template(`. - new: `the_uncovered_target_template_ends_with_the_shared_amd64_suffix`, a v1/v3 pair in `two_binaries_of_one_crate_register_distinct_signature_assets`, `a_global_format_override_to_binary_exempts_the_binary_count_check`, `test_format_override_empty_formats_stops_at_the_first_match`, `asset_name_template_under_defaults_sign_names_the_defaults_block`. - the two format rows and the universal-binary row now assert a name the archive default cannot also produce (`app_1.0.0_windows_amd64.exe`, `app-1.0.0-darwin-universal-uni.sig`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The propagation window is anchored before the probe closures are built, so each probe's transport retries end where the whole sweep does. A probe that kept retrying on the run's budget could spend the window the remaining targets are queued behind. The pypi result line prints one numerator over the target count, reads a single file as itself rather than `1/1 uploaded file(s)`, and names every index the run uploaded to instead of the first file's. On the npm side, one `npm_command` helper builds every npm invocation, so the idempotency probe and the rollback unpublish drop the ambient `npm_config_*` credential variables the publish already dropped — npm ranks those above `--userconfig`, so an inherited one answers for another registry's view of the package, which is the answer that decides whether a publish happens at all. Promotion's two spawn sites strip them too. The classifier now also admits `cert` and `cafile`: a client certificate authenticates on its own and a CA bundle decides which registry certificate is trusted. A preflight Blocker on an unusable token names the way out, and the `auth: oidc` "token ignored" note is silent when there is no token to ignore. Testing: - new structural pins: `every_npm_spawn_is_built_by_the_shared_command` (every `Command` in the npm module goes through the helper or the stripper) and `the_default_propagation_window_is_always_anchored_before_use`. - new behaviour tests: the shared npm command's flags and stripped env, `preflight_oidc_note_is_silent_without_a_token`, the pypi result line's singular and multi-index forms, and the landing probe's own parsing (`distribution_filenames_parse_into_name_and_version`, `a_page_lists_only_the_exact_file_probed`, `a_non_distribution_filename_is_unverifiable_not_absent`). - the fake `npm` scripts read the subcommand out of the argv rather than `$1`, which the pinned flags now precede. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`release.discussion_category_name` was copied into the resolved release flags whatever the run mode, so every nightly run asked GitHub to open an Announcements Discussion for the release it had just created. Nightly retention (`nightly.retention.keep_last` / `keep_single_release`) then deleted that release, and GitHub deleted the Discussion with it. Discussions draw from the same number sequence as issues and pull requests, and a deleted number is never reissued. A repo with several nightly tracks therefore burned five to six numbers a night, permanently: cfgd has lost 231 of its first 280 numbers this way. Nightly runs now resolve `discussion_category_name` to `None` beside the other nightly-gated flags (`prerelease`, `make_latest`, retention, `publish_repo`), so neither the create POST nor the publish PATCH carries the key. A configured category is reported as withheld under `-v`. Stable releases are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`--iidfile` holds the image CONFIG digest, so the digest the docker stage
recorded for a pushed image named different content than the registry
serves under that tag: `{{ Digest }}`, the `.digest` artifact file and the
verify-release comparison were all pinned to a value no registry answers
with. buildx writes the registry's own digest to `--metadata-file` under
`containerimage.digest` — the image manifest digest for a single-platform
build, the index digest for a multi-platform one. `podman build` supports
neither key, so a podman build now records no digest at all rather than
one that names different content.
A snapshot manifest no longer pushes: `dockers_v2` builds without
`--push`, so the per-architecture tags a manifest list points at exist
only in the local store, and pushing the list would put a release the
operator did not cut into the registry. With no push there is no pushed
marker, which is what keeps a snapshot out of the verify-release
population.
`PushedImage` drops its unused `crate_name` field.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The docker manifest probe hashed a decoded `String` of the response body, so any non-UTF-8 byte in a manifest produced a digest no registry would agree with. It now prefers the `Docker-Content-Digest` header the registry names, and hashes the raw bytes only when the header is absent. The probe also assumed every `WWW-Authenticate` challenge was a Bearer token exchange: a registry answering `Basic` got a token request it does not serve. The challenge scheme now decides, a Basic challenge replays the stored credential, and challenge parameters are read whole — quoted or bare, with a repeated `scope` sent as repeated query parameters. Docker findings are routed by what they prove, since the stage files no publish report and so carries no `required` flag: an absence and a digest mismatch fail the gate, while a registry that could not be consulted is a recorded warning. The probe reads only the plain credentials `~/.docker/config.json` stores and never executes a credential helper, so a private repository behind one answers 401 to a push that succeeded. The axis is skipped when the operator deselected the docker publisher — its targets ride in `artifacts.json` and would otherwise outlive the selection — and a tag serving a different digest ends its ladder after one re-ask instead of holding the sweep's shared window open. `is_loopback` now parses the host as an IP address, so `::1` is plain-HTTP and `127.example.com` is not. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`{{ Digest }}` and the `.digest` file are the manifest digest buildx
reports for the push, and a podman build records neither. The
verify-release page says which docker findings fail a release and which
are warnings, and that the probe reads stored credentials only — a
repository behind a credential helper is reported as unverifiable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…set name
A `name_template` that drops the dimension separating two binaries — a
`{{ Os }}-{{ Arch }}` name across a v1 and a v3 amd64 build, say — rendered
one asset base for both. The release then carried one signature for two
binaries and the verify gate silently agreed with it, because its expectation
side de-duplicated the pair into a single name.
`binary_sign_asset_naming` now returns the rendered base together with the
template it came from, and `BinarySignAssetBases::claim` refuses a base a
different binary file already claimed, naming both binaries and the template
the operator has to change. The sign stage and the verify gate both claim
through it, so the collision fails at registration on either path.
`anodizer check config` now reads where a `signs:` slice came from —
`Config::filled_from_defaults`, recorded by `apply_defaults` for `signs`,
`binary_signs` and `docker_signs` — instead of inferring `defaults.sign` from
value equality, so an entry the operator wrote that happens to repeat the
default value is still named as the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ult verbosity A nightly release never opens a GitHub Discussion, and the line saying so was only visible under `-v` while the sibling suppression in the announce stage reported at default. Both are a configured behaviour withheld because the run is a nightly, so both now speak at `status`. The wording no longer reads as a garden path: retention deletes the release, and the number its Discussion took from the issue/PR sequence is never reissued. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s report the created images
A podman build recorded no digest at all: it wrote no `.digest` artifact, left
`{{ Digest }}` empty and printed no `created images` line, so a release built
with podman lost the one value a consumer pins an image by. `podman push` and
`podman manifest push` both take `--digestfile`, so both now write one and the
stage reads the manifest digest back per tag.
The `created images` result line no longer depends on a digest having been
recorded — a build that records none still reports what it created.
`{{ Digest }}`'s rustdoc in `config/docker.rs` now describes what is actually
recorded, so `schema.json` and the configuration reference carry the same
answer as the package docs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… landing result line in singular and plural `npm_config_ca` and `npm_config_key` reached the child alongside the `auth`, `token` and `cert` variables that were already stripped: both decide who npm trusts, and an ambient one outranks the `--userconfig` the publisher wrote. `npm_config_cache` is a directory setting and stays. The four flags npm reads before its subcommand now come from one `npm_config_flags` helper that every npm spawn uses, so publish, unpublish, view and dist-tag all carry them. The npm and blob landing result lines gained the same shape their pypi and docker siblings already had: a singular branch for one artifact, and every host named once in sorted order. An image the probe could not reach is now counted separately and named in a trailing clause instead of suppressing the result line for the images that did verify, and the docker absence line uses the same verb its result line does. A container manifest is not required to be valid UTF-8, so the responder that serves one in tests now writes raw bytes; the digest the probe answers is the SHA-256 over exactly what was served. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The claim that no two binaries name one signature asset was keyed on the binary's file path, which does not separate the pair it was written for: two `builds:` entries differing only in `amd64_variant:` compile to one path when neither declares its own `CARGO_TARGET_DIR`, and that is the configuration the Rust builds page documents. `no_unique_dist_dir: true` is the same hole from the other side — it flattens every binary of a crate onto `dist/<file name>`. The claimant is now the identity the artifact registry holds: crate, build id, target, binary, amd64 level and path, with an absent level reading as the baseline `v1`. What is claimed is the full uploaded asset name rather than the base it is built from. Two configs whose `signature:` templates append different suffixes to one base name two distinct release assets, and both were being refused. The stage and the verify gate now claim over the same population: the gate claims before its release `ids:` narrowing and only then decides whether the name is an expectation, because the stage refuses a run before any `ids:` filter is known. `check config --workspace` labelled a workspace's own `signs:` entry as `defaults.sign`. The overlay replaces the slice the `defaults:` fold filled, so it now drops that provenance record along with it. The sign page carries the collision failure beside the empty-name one, with the message it prints and the two ways out, and its multi-entry example says it is single-variant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ides
`check config` read an `if:` gate as present whenever the field was set.
The run path does not: `evaluate_if_condition` treats `if: ""` exactly like
an absent one, so both entries below always run and both write
`dist/<artifact>.sig`, yet the pair went unreported because `Some("")` and
`Some("{{ IsSnapshot }}")` compared as two different gates.
binary_signs:
- cmd: cosign
if: ""
- cmd: gpg
if: "{{ IsSnapshot }}"
Both readers now go through `anodizer_core::config::active_if_gate`, which
is the one place the "absent or empty means always" rule is spelled;
`evaluate_if_condition` and `env_preflight::entry_inactive` call it too.
The same check compared `signature:` templates as text, so
`dist/sigs/app.sig` against `./dist/sigs/app.sig` — the pair the sign stage
proves is one file — warned nothing. A template holding no `{{` is a literal
path and is now folded through `fold_dot_components` before the comparison,
the same answer the stage reaches. A template it cannot resolve is still
compared as text.
The overlay walk's lexer swallowed a body on three shapes. A raw string was
read with escapes, so `let re = r"a\";` consumed the closing quote and
everything after it, including the `apply_env_overlay(config, ws)` handoff
the walk exists to catch; a raw string carrying a quote of its own reported
a misleading line; a nested block comment ended at the first `*/`, leaving
the outer comment's tail read as code. A line continuation also lost its
newline, so every line the walk reported after one was off by one.
Tests: `entries_under_different_gates_warn_nothing` (empty-gate cases),
`two_spellings_of_one_signature_path_warn` and
`a_config_handed_to_a_helper_fails_the_overlay_walk` (raw string with a
trailing backslash, raw string with an inner quote, nested block comment,
line continuation) fail without the change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ches A body line sits at `indent()` plus the 3-space BODY_INDENT, so it is at 3 ungrouped and at 5 inside a stage section. Pages quoting a publish or release stage line put it at 3, a column those lines never reach: the created-release and published-release lines, the publisher deselect lines, the run-report line, the Homebrew tap line, the gemfury push line, the already-published reconcile lines, and the `--preflight-secrets` line, which prints inside the `setup` group. Each is re-rendered at 5. `selecting-publishers.md` also quoted `• publishing npm` / `• publishing cargo` / `• publishing homebrew`, three lines no code emits. They are replaced with what the publishers print: the npm publish line, the cargo `published crate` line and the Homebrew tap line. The audit's parity rule could only ask whether a bullet's column was even, which passes a stage line quoted at 3. It now reads the fence's `$ anodizer <subcommand>` line: only release, publish, continue and check determinism reach a `log.group`, so under every other subcommand 3 is the only column a body line can sit at. Those four keep the parity rule alone — a release prints its stage lines inside a section and its `--split` / `--merge` orchestration lines outside one, and the fence cannot tell which a given line is. A TOML section header carrying a trailing comment (`[package] # ...`) read as a per-line stage prefix, so the next page to write ordinary TOML would have failed the audit naming the wrong cause. `toml` and `ini` fences and a `#` tail are skipped. `--self-test` runs both rules over a fixture tree and holds them to their verdicts, and runs ahead of every real scan so a rule that stops firing fails there rather than in the next docs review. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sets an empty one
An `if:` that is absent and one that is empty both mean "always run", and
`evaluate_if_condition` has always folded the two together. Two fallback
chains read the raw `Option` instead, so an empty child condition won the
`.or()` and the parent's real gate was silently dropped:
homebrew:
- if: "{{ IsSnapshot }}" # meant to cover the formula AND the cask
cask:
if: "" # imposes nothing — yet it won the fallback
The cask published on a non-snapshot run. `schemastore.resolved_if` dropped
a block-level gate the same way for an entry carrying `if: ""`, which a
round-tripped config emits for a field nobody set.
Both now fold each side through `active_if_gate` before the `.or()` picks a
winner. Folding afterwards is too late — by then the empty condition has
already shadowed the parent's.
`every_if_condition_presence_test_reads_the_active_gate` walks the
production half of every workspace crate through the shared source scanner
and fails a function body that hangs `.is_some()`, `.is_none()` or `.or()`
off an `if_condition` without naming `active_if_gate`. Both sites fail it
with the old code; the population size is pinned so a rename cannot empty
the walk.
Tests: `an_empty_cask_if_keeps_the_formulas_gate`,
`an_empty_entry_if_keeps_the_block_gate` and the walk above all fail
without the change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…is compared
`fold_dot_components` is the shared answer to "do these two spellings name
one file", and a Windows verbatim path (`\\?\…`) must survive it unfolded:
Windows normalizes nothing behind that prefix, so `..` there is an ordinary
directory name and folding it calls two files one. Only `lexical_absolute`
carried that guard. `dist_joined`'s join and `anodizer check config` folded a
raw rendering with no guard, so on Windows a `signature:` rendering
`\\?\C:\a\..\b.sig` was handed to the signer — and registered — as
`\\?\C:\b.sig`, a different file from the one configured.
The guard now lives inside `fold_dot_components`, so all three callers share
it and `lexical_absolute` drops its copy. A `..` straight after a bare drive
prefix is kept as well: `C:..` names the parent of the current directory on
drive C, which the old arm turned into the current directory itself.
Measured on Windows (rustc 1.97.0), old → new:
\\?\C:\a\..\b \\?\C:\b → \\?\C:\a\..\b
C:..\x C:x → C:..\x
C:\a\..\b C:\b → C:\b (unchanged)
C:\..\x C:\x → C:\x (unchanged)
`check config`'s duplicate-output warning also under-warned. The sign stage
places a rendering that is not under `dist` under it, so `app.sig` and
`dist/app.sig` are one file the second `cmd:` overwrites — and two strings
that never matched here. It now asks the stage itself through
`sign_outputs_are_one_file` (`dist_joined` on each side, then `same_file`),
so there is one derivation rather than a second copy that can drift. A
template still holding `{{` is compared as a path whose placeholder runs are
opaque components, which folds the `.` and `..` in the literal segments
around an identical placeholder and keeps two different placeholders apart.
Tests: `a_verbatim_path_is_returned_unfolded`,
`a_verbatim_prefix_is_told_apart_from_a_plain_drive`,
`a_drive_relative_parent_hop_survives` and `dot_and_parent_components_fold`
(the middle two discriminate on Windows only, per the probe above),
`a_signature_outside_dist_names_the_same_file_as_its_dist_spelling` and
`two_spellings_of_one_templated_signature_path_warn` — the last two fail
without the change on any host.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…et audit about announce
`hooks.md` showed `built binary myapp (x86_64-unknown-linux-gnu)`, a string
no code emits. The build stage's only per-binary result line is
`built {}/{} for {}` (crates/stage-build/src/run_helpers.rs), which renders
`built myapp/myapp for x86_64-unknown-linux-gnu`. Every other `•` line in
every fence under docs/site/content was checked the same way — 118 bullets,
98 distinct, each grepped against the crate sources by a literal prefix with
the placeholders resolved by hand — and this was the only invented one.
`audit-log-labels.sh` held that only `release`, `publish`, `continue` and
`check determinism` reach a `log.group`. `anodizer announce` runs
`Pipeline::run` too (commands/announce_cmd.rs), so it prints its body lines
at column 5 and a correct `$ anodizer announce` fence would have been
reported as an unreachable column, pushing the next author to write the line
where the command never prints it. `announce` joins the list, the derivation
is recorded beside it as the grep that produced it, and the self-test fixture
gains a `•` at 5 under `$ anodizer announce` that fails without the entry.
The header also now says what a fence with no `$ anodizer <cmd>` opener is
held to — the parity rule alone, because with no command named there is
nothing to resolve the depth against, and 30 of the 118 bullets sit in such
a fence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ide dist
`anodizer check config` placed a literal `signature:` under `dist` before
comparing two entries, but returned one statement earlier for a template
still holding `{{`. So `{{ ProjectName }}.sig` and
`dist/{{ ProjectName }}.sig` — one file, since the sign stage places the
first under `dist` — went unwarned, and the second `cmd:` overwrote the
first's bytes.
Both spellings now go through `sign_outputs_are_one_file`, the stage's own
answer, with the placeholders masked into opaque path components first.
`{{ .Artifact }}` is the exception: it expands to the artifact's whole path,
which already carries `dist`, so a pair holding it is folded without the
join and stays two files.
The mask also trims the padding just inside `{{ … }}`, so `{{ .Artifact }}`
and `{{.Artifact}}` — identical renderings — are one placeholder rather than
two files, and its behaviour on a malformed template is written down: an
unterminated `{{` is opaque to the end of the string.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`active_if_gate` filtered on `is_empty()`, so `if: " "` was an ACTIVE gate that rendered to blank, trimmed to empty and skipped — one added space flipping an entry from "always run" to "never run", and disagreeing with the hook runner's literal path, which proceeded on the same value. It filters on `trim().is_empty()` now, so absent, empty and blank are one answer wherever the gate is read. The hook runner's literal path asks presence through `active_if_gate` too, rather than testing the raw `Option`, which makes it the fifth member of the class. The structural pin reads two more shapes: `.is_some_and(`, and a `.or(` in the 60 flattened characters BEFORE a read, which is where a fallback chain assembled across two statements puts the operator. `.map(` stays out — every one near an `if_condition` here is the iterator after a gate call, not a read of the field. The population it walks is 3. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… placeholders, and stop warning when a placeholder renders a path
`check config` decided whether two sign entries write one file by placing
each rendered spelling under `dist`, and exempted only the literal
`{{ .Artifact }}`. Anodizer has three more spellings that expand to a whole
path — `${artifact}`/`$artifact`, `${signature}` and `${certificate}`, all
expanded after the render — and a user-supplied `{{ .Env.X }}` or `{{ Var.x }}`
can hold a directory or an absolute path of its own. Each of those already
carries `dist`, so joining `dist` a second time called two different files
one and warned about a pair that is correct. `${artifact}.sig` is the
default an imported GoReleaser config arrives with, so the false warning met
the common case.
The exemption is now a property rather than a list of names: a spelling is
compared without the join when it holds one of the path-carrying spellings or
any `{{ … }}` run that is not a bare name-only variable the sign and archive
stages seed. Both halves of that derivation are one const each beside the
comparison.
Two more entries of the same class:
`signs:` carries the identical overwrite shape — the same `SignConfig`, the
same output resolver, the same `dist` — and was never checked, so two
`signs:` entries rendering one path overwrote each other silently. The check
now walks `signs:`, `binary_signs:` and both of their per-crate slices, with
the warning naming the slice the operator wrote.
An unpadded `{{.Artifact}}` was documented as rendering like `{{ .Artifact }}`
and does not: anodizer substitutes the placeholder by exact single-spaced
literal, so the unpadded spelling survives into the template engine as an
undefined variable and hard-errors the sign stage. `check config` now warns
on it wherever the substitution runs — `signature:`, `certificate:` and
`args:` on every sign slice, and `docker_signs[].args`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ways runs Every user-facing description of `if:` — 24 schema descriptions, ten hand-written tables across the sign, package and publisher pages, and the generated configuration reference and JSON schema built from them — said only that a falsy render skips. An operator reading any of them concluded that `if: ""` and `if: " "` skip too. They do not, and one of the two spellings changed direction in this release: a blank `if:` used to render, trim to empty and skip, and now imposes no gate at all, so a config using `if: " "` as an off switch runs its publisher. An empty `if:` was already a no-op and now also never renders. Reaching the blank spelling needs an explicitly quoted blank — YAML strips a plain scalar's trailing spaces and a bare `if:` is null — and `skip:` is the documented off switch, but for the publishers whose upload cannot be undone the direction of the change is toward publishing, so it is worth saying plainly. One sentence, identical at every site, now says both halves: an absent, empty or blank `if:` imposes no gate and always runs, and the falsy test applies to what a non-blank gate renders. The msi example that wrote `if: ""` said the opposite in its comment and now says what the empty spelling does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t different artifacts
Two `signs:` entries whose `artifacts:` filters admit no common artifact
kind were reported as resolving one signature file, so the documented
gpg-archive plus cosign-checksum pair warned on a config that writes two
files. The overlap question now asks the sign stage's own artifact-kind
resolver first, with each slice's own filter default applied, and the
message says the artifacts both select rather than the binaries.
The same walk answers four more defects in `anodizer check config`:
- the duplicate-output warning carried a 26-space run from a string
continuation that never closed its indentation;
- `{{ .Artifact}}` and the rest of the mis-padded spellings went
unwarned, since only the two fully unpadded forms were matched;
- `signature:`, `certificate:` and `stdin:` were checked against the
argv substitution set, so `{{ .Signature }}` in a `signature:` and any
placeholder in a `stdin:` passed while the sign stage hard-errors on
them, and `stdin:` was not checked at all;
- `$signature` and `$certificate` were not read as rendering a path, and
`$artifactName` was read as `$artifact`.
`check_sign_artifact_filters` and `check_sign_asset_name_templates` now
walk the same slice list, so the filter check reaches `binary_signs:`
and the per-crate slices too. Those slices refuse any filter but
`binary` or `none` at load time, so the only value the check can find
there is one that arrived through `defaults.binary_signs:`. A slice
filled from `defaults.sign:` is named as that block rather than an
index the operator never wrote.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lways runs Eleven of the thirty-five documented `if:` fields still described only the falsy test, so the generated schema and configuration reference told an operator reading a signing, installer, hook, nfpm or schemastore field that an empty gate skips the config. The sentence the other fields carry is now on all of them, the reference and schema are regenerated from it, and the DMG page's inline comment matches the MSI page's. A pinning test walks every `#[serde(rename = "if")]` field in the workspace and fails a documented one that does not carry the sentence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`anodizer check config` read a sign template's placeholders by exact
spelling, so `{{ Artifact | upper }}` and `{{ Artifact.path }}` passed the
check and then failed the sign stage as undefined variables. The scan now
matches any `{{ … }}` run that holds the name as a whole word, and skips
`{# … #}` comments, which Tera strips before it evaluates anything.
A placeholder the field cannot substitute got a remedy that does not work
when the name is the field's own: `resolve_output_paths` binds the
`signature` variable to the `signature:` template's own unexpanded text, so
`${signature}` written there produces a file name carrying a literal `$`
instead of failing. That case now asks for the reference to be removed; the
sibling output's `${…}` name still works and is still offered.
`binary_signs[].artifacts` is checked against `binary` and `none`, the two
values its own loader accepts. A wider value can only reach the slice
through `defaults.binary_signs:`, which is a plain sign config the defaults
fold copies in without that loader, and the run then signs binaries
whatever it says.
`docker_signs[].signature:` is a field a config can set and the stage reads nowhere: a
container signature is stored in the registry, and the docker sign stage
synthesizes the name its argv substitutes. Check now says the field is
ignored.
The sign checks move into `check/config/sign.rs`. `content.rs` was eight
lines under the god-file ceiling, and the cluster has no caller outside
itself. The message-shape test that used to read only `content.rs` reads
every production source under `check/config/`.
`docs/site/content/docs/sign/binaries-archives.md` quoted a warning the
binary stopped printing two commits ago. Every warning block on that page
is now output captured from the binary, and a test parses the page's own
YAML blocks as fixtures and fails when a quoted line is not a message the
checks produce for one of them.
`the_overlap_universe_holds_every_kind_a_filter_selects` asserts the kind
universe behind the `artifacts:` overlap answer holds every kind a filter
selects, read out of `ArtifactKind::as_str`'s own exhaustive match. Its
sibling asserted only that each filter selects something, and accepts a
universe missing `Library`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`DockerSignConfig`'s doc comments generate `docs/site/static/schema.json` and
the configuration reference, and four of them described a field the stage
does not have:
- `signature:` was called a filename template. The stage reads it nowhere —
a container signature is stored in the registry beside the image, so
anodizer synthesizes the `<image>@<digest>.sig` name its argv substitutes.
- `certificate:` was called a file embedded in the signature. Only its
presence is read, to select cosign's bundle verification mode; the path
never reaches the signing command and `{{ .Certificate }}` in `args:`
renders empty.
- `artifacts:` named `image` and `manifest`, which the stage refuses. The
accepted values are `all`, `images`, `manifests`, `none` and empty, and
empty is the default, not `none`.
- `args:` and `DEFAULT_ARGS` led with `${artifact}@${digest}`, a spelling
nothing on the docker path expands; the const holds
`{{ .Artifact }}@{{ .Digest }}`.
The accepted `artifacts:` values are now one constant the stage's refusal
message renders through, and a test holds the field's first sentence against
it. The hand-written docker page carried the same three claims plus an
example whose `${artifact}` would have reached cosign as literal text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A `docker_signs:` entry's `args:` and `stdin:` are rendered and given three
literal substitutions, and that is all: the `${artifact}` variables the
binary and archive path expands after its render reach cosign as that text,
and `{{ .Certificate }}` is substituted with the empty string because a
docker certificate path is read nowhere. Both now warn, with the spelling
that works named where one exists.
Two constructs the placeholder scan read wrongly are fixed with it. A name
inside a Tera string literal — `{{ "Artifact" }}`,
`{{ Version | replace(from="Artifact", to="x") }}` — renders fine and no
longer warns, which is the same reason a `{# … #}` comment is skipped. A
`{% … %}` statement naming the placeholder does fail the render, the way an
expression does, and now warns.
The sign page's quoted output is pinned per block and in both directions:
each YAML block is a fixture and what the checks produce for it has to be
exactly what the text block under it quotes, so an output shown under the
wrong config, a dropped line and a reworded message all fail. The same shape
now covers the announce secret-exposure lint on the resilience page and the
four `Error sign:` collisions the sign page quotes, which come from the sign
stage and are reproduced on fixtures there. One of those four was written
with elisions and is now the message the stage really prints.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The sign and release-resilience pages already have tests that drive the real producer and compare the quoted output byte for byte. Three more pages quoted output that nothing checked, so a reword left them showing a line the binary no longer prints. Each new test reproduces the situation the page describes and compares: - advanced/verify-release.md — a produced package absent from the published release, driven through VerifyReleaseStage against a loopback GitHub responder. The page's one Warning line is the recorded issue and both Error lines are the aggregate bail's first line. - general/version-files.md — a bare enrollment that matched nothing, an anchored enrollment that selected no region, and the drift guard over a stale chart. One Warning line and three Error lines. - packages/snapcraft.md — a store upload answered with a manual-review hold, driven through SnapcraftPublishStage against a stubbed snapcraft. The snapcraft block was stale: a real hold prints the pending-review line carrying the store's own text before the two HELD lines, so the page now shows all three. check version-files' run_guard is pub(crate) so the version-files test can drive the guard rather than a copy of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A page that quotes a rendered `Warning` or `Error` line is claiming what the binary prints, and until now only three of those claims were checked. This sweeps the whole `docs/site/content/docs/` tree: every quoted status line now has a test in the crate that emits it, driving the real code on a fixture that reproduces the documented situation and comparing the page's line with what was captured. Each test also pins how many lines its page quotes, so a newly quoted line fails the suite until somebody pins it too. Three pages were already wrong and are corrected to the real output: the preflight page showed a tool-missing line and an SSH-key line that no longer match what the checks produce (a key missing only its trailing newline is accepted, because the key writer adds it back), and the rollback refusal on the resilience page had drifted by a word. A line carrying the ellipsis character stands for a long list or a digest rather than exact bytes, so it stays outside the comparison. Five lines are in that shape today. Two messages were built inline in a `bail!` no test could reach without running a whole release, so each is now a named function the test calls: `preflight_failure_message` and `promote_failure_message`. The wording is unchanged. The rule and the full page-by-page table live in `.claude/rules/docs-quoted-output-pins.md`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`${digest}` and `${artifactID}` expand nowhere on the docker sign path, the
same as `${artifact}` — the path seeds those two as template variables and
expands no shell variable at all. `check config` warned about one of them
and left an operator holding `${artifact}@${digest}` with half the story.
Both now warn, each offering the spelling that renders (`{{ .Digest }}`,
`{{ .ArtifactID }}`).
Two holes in how a template is scanned for placeholder names close with it.
A quoted literal is now read the way tera's lexer reads one — a backtick
opens a literal, and a backslash inside one escapes the next character — so
`` {{ `Artifact` }} `` and `{{ "he said \"Artifact\"" }}` render fine and
no longer warn. And an opener whose closing delimiter is missing is stepped
over instead of ending the scan, because the three openers close on three
different delimiters: `{{ oops {% set x = Artifact %}` holds a complete
statement that went unread.
`DockerSignConfig::ARTIFACT_FILTERS` becomes crate-private. The sign stage
reads the rendered sentence through `artifact_filters_phrase`, never the
slice, so the slice was public API nobody asked for.
The docker sign page's `args:` Default column prints `DEFAULT_ARGS` in a
spelling the const does not hold; the cell now shows its bytes and a test
holds it there. The sign page's two `archives:` fragments get their real
parent — `archives:` is a field of a crate block, never a top-level key, so
either one copied out of the page failed to load — and the pin that parses
those blocks now counts the refused ones and requires none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Five messages an operator reads said "mints" where they meant creates, and
"a green determinism check" where they meant one that passed. The rollback
next-step line is quoted on the release-resilience page, so that page
follows the binary rather than the other way round.
tag rollback auto-tag mints it -> auto-tag creates it
tag may mint the wrong ... -> may resolve the wrong ...
check determinism mints a fresh cert -> gets a fresh cert
publish-only on a green determinism -> on a determinism check that
check first passed first
Two rustdoc runs lose the same vocabulary: a "freshly-minted" token and a
"spawn-seam sibling".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "complete configuration" on the reproducible-builds page put `archives:` at the top level, where no config field of that name exists, so copying the whole block gave `unknown field 'archives'`. It moves under the crate it belongs to, and the three `format: tar.gz` entries on that page become `formats: [tar.gz]` — the singular key still loads but prints a deprecation warning. Three sentences elsewhere say what the tool does rather than describing it in borrowed vocabulary: release walks / releases every crate, anodizer walks / reads the vendored schema, anodizer surfaces / names the version requirement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`docker_signs[].stdin:` is rendered, and the docker sign path seeds `Digest`
and `ArtifactID` as template variables immediately before it renders. A
config writing `${digest}` or `${artifactID}` there was told the text
reaches the signing command as literal text and was offered nothing to write
instead, because the arm that blanks the remedy on `stdin:` was reached
first. It now runs after the two rendered names, so both are offered
`{{ .Digest }}` and `{{ .ArtifactID }}` on `stdin:` as well as on `args:`.
The blank remedy stays right for `artifact`, `signature` and `certificate`:
those three are substituted into `args:` alone and have no spelling that
works on `stdin:`.
The masker's documentation also records that a template run ends at the
first `}}`, which is found before quoted literals are blanked, so a `}}`
written inside a literal ends the run early and a name after it goes
unwarned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The verify-release skip lines, the cargo simulation's verbose note and the `--publish-only` help on `check` and `healthcheck` all called the operator's publisher selection a "publish surface". They now say "selected publishers" or "publish-time stages", which is what the operator picked with `--publishers` / `--skip`. The generated CLI reference follows the help text. The two `publish-only` recovery hints describe the same fallback and had drifted apart by one word; both now read "or use `anodizer publish`". In the docs, npm's unpublish window, preflight's missing-scope report, podman's load refusal, verify-release's failing check, the Chocolatey resubmit warning, the determinism `--strict` note, the changelog range arg, the promote miss and the action's retry note all said a command "surfaces" something; each now says what it does. The lockstep workflow strategy says `anodizer release` publishes every crate rather than releases them. The archives and reproducible-builds pages state, above their first standalone `archives:` fragment, that those fragments sit under `crates[].archives:` or `defaults.archives:`, so a reader landing mid-page knows the fragment needs a parent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lines "no verifiable publisher in the selected publishers" reads as a typo on a status line an operator sees; "among" is the word. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tj-smith47
force-pushed
the
fix-binary-sign-primary-archive
branch
from
September 13, 2026 16:56
de5286d to
26df6af
Compare
Two tests this branch added failed on the platform runners. The two npm tests that assert the shared command pins its userconfig and strips ambient credentials use `EnvGuard`, which is imported under `#[cfg(unix)]`, so the Windows build did not compile the test target. Both are now gated the same way as the import. `the_ladder_stops_at_the_sweep_deadline` asserted exactly three asks in a 25ms window at a flat 10ms backoff. A loaded macOS runner overran one sleep and fit only two. The property under test is the upper bound, so the assertion now accepts two or three asks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Carries dependabot's two open PRs into this branch so one merge lands everything. #98: aws-lc-rs 1.17.0 -> 1.18.1, aws-lc-sys 0.41.0 -> 0.45.0, rustls 0.23.43 -> 0.23.44, rustls-webpki 0.103.13 -> 0.103.15, toml 1.1.2 -> 1.1.3. #97: taiki-e/install-action v2.87.9 -> v2.87.10 in both composite actions that pin it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sign path per platform Eight tests this branch added failed on the Windows runner. Six docker tests route `docker` and `podman` through a `FakeToolDir` stub on PATH. The stub is a shell script, so on Windows the podman stub's `.script()` panics and the docker stub is never found: the build test reached a real `docker buildx` and the manifest tests reached ghcr.io. The two helpers and the six tests now carry the same `#[cfg(unix)]` the file's other PATH-stub test carries. `an_absolute_artifact_signature_resolves_to_one_path_on_both_sides` built its binary path from a POSIX literal, which is relative on Windows and comes back drive-prefixed from `std::path::absolute`. It now builds the path under the platform's temp dir. `the_collision_errors_quoted_in_the_sign_docs_are_the_messages_the_stage_produces` compares the two output paths in each message against the docs page, which quotes the POSIX rendering. Windows prints the same paths with backslashes, so the pin is unix-only, with the helper it alone calls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes for everything that went wrong in the v0.26.0 release, plus a broken docs deploy. No behaviour change for a repository that has one
archives:entry per crate and noNPM_TOKEN.What broke in v0.26.0
anodizer-0.26.0-<os>-<arch>-extra.sig-extra.tar.xzsorts before.tar.gzarchives:config (primary entry'sname_templaterendered per target), with the sign stage and the verify-release gate sharing one derivation;asset_name_template:override onbinary_signs:npm token invalid or expiredalthough npm publishes over OIDCNPM_TOKENsecret was still injected and the npm preflight treated a dead token as a blocker whatever the auth modeauth: autowith an OIDC context a dead token is a warning; underauth: oidcthe token is never probed;NPM_TOKENremoved frompublish-oidc.ymlandrelease.yml(manual-publish.ymlkeeps it for a brand-new package)slow_subprocess_heartbeats_fast_subprocess_does_notfails on a loaded boxtruethat took 41ms to spawn logged a heartbeatNPM_TOKENexporteddocs.ymlinstalled Zola unpinned and got 0.23 (Tera 2), which drops theconcatfilter and parses{{ … }}inside markdown prose (1432 occurrences across 100 docs pages describe that syntax).zola-version, 0.22.1) read by thesetup-docscomposite and by the localtask docs:sitegate; sidebar rewritten withoutconcatSignature naming rule
archives:entryname_templatefor the target, plus thesignature:suffix:app-1.2.3-linux-amd64.sig{{ Binary }}-{{ Version }}-{{ Target }}plus suffix:app-1.2.3-x86_64-unknown-linux-musl.sigformats: [binary]entryTesting
cargo test --workspace -- --test-threads=4: 0 failures (agent run); affected packages re-run by the controller with and without an ambientNPM_TOKENtask audit:workflows,actionlint,task docs:site(0.22.1): passtask fmt:check clippy docs:check audit:deps audit:workflows audit:code,task test docs:validate-readme check:config snapshot,task coverage:gate(93.4% against the 92.5% floor) all exit 0 atde5286d2anodizer releaseend to end. The next release is the live proof; the v0.26.0 assets on GitHub were renamed by hand to the names this rule produces.Zola 0.23
Not adopted. Tera 2 parses template syntax inside markdown, and the escape (
{% raw %}) collides with the page that documents escaping. The pin fixes the deploy; moving to 0.23 needs a content-wide plan.🤖 Generated with Claude Code