Skip to content

chore: harden release machinery (5 independent fixes) - #287

Merged
dean0x merged 4 commits into
mainfrom
chore/release-machinery-hardening
Aug 9, 2026
Merged

chore: harden release machinery (5 independent fixes)#287
dean0x merged 4 commits into
mainfrom
chore/release-machinery-hardening

Conversation

@dean0x

@dean0x dean0x commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

Five independent release-machinery and repo-hygiene fixes, plus two rounds of coordinator follow-up.

Hard constraints respected: crates/mds-core/**, crates/mds-cli/**, and the [Unreleased] body of CHANGELOG.md are untouched.


Fix 1 — Restore accidentally deleted sections from RELEASING.md

Commit acb8a86 (ancestor on main) deleted "What happens after tagging" and "Post-release" from RELEASING.md. Both sections are restored verbatim, with step 4 updated to reference the bounded crates.io poll added in Fix 2.

Fix 2 — Stop release.yml from swallowing genuine publish failures

The publish-crates step used cargo publish || true, which turned every failure (network error, bad token, crates.io down) into a silent no-op. Replaced with an explicit idempotency check:

  • If cargo publish succeeds → proceed normally.
  • If it fails with an "already uploaded" signal → log a notice and continue (idempotent re-run).
  • Any other failure → exit 1 (actual error, stop the workflow).

Also replaced sleep 30 with a bounded crates.io sparse-index poll (max 20 × 15 s = 5 min) so the subsequent mds-cli publish doesn't race a propagation delay or hang indefinitely.

Fix 3 — Fix bump-version.mjs CHANGELOG link-table corruption

The third .replace() in bump-version.mjs used a regex that matched the [0.1.0] releases/tag line and overwrote it with a compare link, corrupting the table. Removed the buggy third replace; combined old second + third into one that captures the repo base URL from the existing [Unreleased] line and correctly inserts both the updated [Unreleased] pointer and the new version's compare link in one operation.

Dry-run verified with 0.3.0 → 0.4.0 transition against the branch's own CHANGELOG copy:

  • [0.1.0] tag line: untouched ✓
  • New [0.4.0] compare link: inserted exactly once ✓
  • [Unreleased] pointer: updated to v0.4.0...HEAD ✓

Fix 4 — Remove the broken workflow_dispatch -f version= path

The workflow_dispatch -f version=X.Y.Z path was broken (#127): the prepare job pushed a release commit directly to protected main, which branch protection rejects (GH006). It left orphaned tags and published nothing. Rather than patch around protected-branch restrictions, removed prepare entirely and simplified the workflow to two entry points:

  • Tag push (refs/tags/vX.Y.Z) → full release (version-gate → build-napi → stage-and-verify-napi → publish-crates → publish-npm → github-release).
  • Manual dispatch (no inputs) → dry-run that stops after stage-and-verify-napi, publishing nothing.

All job needs:/if:/ref: expressions updated for consistency. CLAUDE.md and RELEASING.md updated to remove the broken path references.

Fix 5 — Gitignore the 78 generated examples/ output files

mds build writes compiled output next to each .mds source: .md for Markdown templates, .json for @message templates. These 78 generated files were untracked noise. Added catch-all patterns with negation exceptions protecting all hand-authored files (7 README.md files, 10+ config JSON files).

Follow-up: CHANGELOG link table

Commit 3aef465 (ancestor) had introduced a premature [Unreleased] pointer at v0.4.0...HEAD and a [0.4.0] compare link. Both were wrong (the current release is v0.3.0). Corrected: [Unreleased] now points at v0.3.0...HEAD; the premature [0.4.0] line is removed. (Only the link-reference table was touched; the [Unreleased] body is untouched per hard constraint.)

Follow-up: Replace examples-guard with build-then-check CI job

The original examples-guard job iterated git ls-files --others --ignored to find gitignored files lacking a sibling .mds source. In CI (fresh checkout, no generated files committed), this set is always empty — the guard unconditionally passed and never caught anything.

Replaced with examples-gitignore-coverage: build the mds CLI, run mds build over every example directory expected to compile cleanly (excluding stress-test/errors/ which contains intentionally-failing fixtures by design), then assert git status --porcelain --untracked-files=all -- examples/ is empty. If the compiler ever starts emitting an output extension not covered by the .gitignore catch-alls, the new untracked file appears and the job fails with a concrete file list.

Proved both ways locally:

  • PASSING: 73 outputs written across 14 directories/files; git status empty ✓
  • FAILING: removing examples/**/*.md from .gitignore → 61 generated .md files appeared as ?? in git status ✓

Also updated the FOOTGUN WARNING comment in .gitignore to accurately describe what CI does and does not enforce.


Breaking Changes

None. All changes are to release tooling, CI, and documentation.

Reviewer Focus Areas

  • release.yml: publish-crates idempotency guard and crates.io index poll logic
  • scripts/bump-version.mjs: single combined .replace() covering both the [Unreleased] update and new version link insertion
  • .github/workflows/ci.yml: examples-gitignore-coverage job — confirm the directory list covers the full clean-compiling surface and the assertion is sound
  • RELEASING.md: restored sections and removal of the broken workflow_dispatch -f version= path

Closes #127

dean0x and others added 4 commits August 9, 2026 09:45
Fix 1 — Restore RELEASING.md sections deleted by acb8a86
Commit acb8a86 (PR #239) accidentally removed the "What happens after
tagging" numbered breakdown and the "Post-release" checklist, replacing
them with a self-referential "See @RELEASING.md for the full runbook."
line. Both sections are restored verbatim (with step 4 updated to mention
the bounded crates.io poll). The atomic_write_file note from PR #239 is
kept unchanged.

Fix 2 — Stop publish-crates from swallowing genuine failures
Both cargo publish steps previously exited 0 regardless of the actual
error. Now output is captured; the job continues only when the error
matches an already-published signal (idempotent re-run), and exits 1 on
all other failures. The bare sleep 30 index wait is replaced with a
bounded poll of the crates.io API (max 20 × 15 s = 5 min, then fail).

Fix 3 — Fix bump-version.mjs CHANGELOG link-table corruption
The third regex matched the [0.1.0] releases/tag line and silently
repointed it to the new version while adding a duplicate entry.
The fix: merge the old second + third replacements into one that captures
the repo base URL from the existing [Unreleased] line and inserts a new
[version] compare link directly after it, leaving all prior entries
untouched. Dry-run diff verified against a CHANGELOG copy.

Fix 4 — Remove the broken workflow_dispatch -f version= path (Closes #127)
The prepare job pushed to protected main via the Actions bot, which
branch protection rejected (GH006). It left an orphaned tag and published
nothing while still being invocable. The prepare job and version input
are deleted. Tag-push remains the single release path; plain
workflow_dispatch (no inputs) remains the dry-run path. All downstream
if: conditions and ref: checkouts simplified to use github.ref directly.
RELEASING.md and CLAUDE.md updated to remove the broken path docs.

Fix 5 — Gitignore generated examples/ output
78 compiler-output files (foo.mds → foo.md, @message → foo.json) were
untracked. Added minimal pattern set with negation guards to protect the
120 currently-tracked files (README.md, package.json, mds.json, vars.json,
vars-minimal.json, tsconfig.json). Verified: no tracked file became
ignored; file count unchanged at 120.

Co-Authored-By: Claude <noreply@anthropic.com>
Fix 1 (follow-up to gitignore) — Add footgun comment and CI guard
The examples/**/*.md + **/*.json catch-alls silently ignore any
hand-authored .md/.json added under examples/ unless an explicit '!'
exception exists. Added a warning comment in .gitignore explaining
the invariant and pointing at the CI guard. Added an 'examples-guard'
job in ci.yml that iterates the set of ignored files under examples/
(bounded at MAX=500, linear pass) and fails if any has no sibling .mds
source — meaning the file is hand-authored content being silently
dropped. No existing jobs were an appropriate home; the guard is
checkout-only and fast.

Demonstration:
  PASS: 0 ignored files on current tree
  FAIL: examples/edge-cases/GUIDE.md (no GUIDE.mds sibling) → guard
        exits 1 naming the offender; dummy then removed

Fix 2 (follow-up to bump-version.mjs) — Correct premature CHANGELOG
link-reference table. Commit 3aef465 introduced two premature entries:
  [Unreleased]: .../compare/v0.4.0...HEAD  (wrong; v0.4.0 not yet tagged)
  [0.4.0]: .../compare/v0.3.0...v0.4.0    (wrong; pre-release)
Corrected to the proper pre-bump state:
  [Unreleased]: .../compare/v0.3.0...HEAD
  [0.4.0] line removed

The 0.3.0→0.4.0 dry-run with the branch's own bump-version.mjs script
against the corrected CHANGELOG confirms the fix produces:

  8a9,10          <- ## [0.4.0] — 2026-08-09 inserted
  1011c1013,1014  <- [Unreleased] updated to v0.4.0...HEAD
                  <- [0.4.0] compare link inserted
  [0.1.0] releases/tag line untouched

Co-Authored-By: Claude <noreply@anthropic.com>
PR #240 (de8857d) added a `miette::miette!` call that exceeded the
line-length limit and was not run through `cargo fmt` before merging.
PRs #239 and #240 each passed CI on their own branches, but the
combination left `main` (c8b4062) red on the `cargo fmt --check` gate.

This commit applies the corrective reformat so that PR #287 can pass CI
independently of the merge order for PR #286, which incidentally carries
the same fix on its branch.
…e job

The old `examples-guard` job iterated `git ls-files --others --ignored`
to find files under examples/ that were gitignored but lacked a sibling
.mds source. This set is always empty in a fresh CI checkout (generated
files are never committed), so the job passed unconditionally and never
caught anything.

Replace it with `examples-gitignore-coverage`: build the mds CLI, run
`mds build` over every example directory expected to compile cleanly
(excluding `stress-test/errors/` which contains intentionally-failing
fixtures by design), then assert `git status --porcelain --untracked-files=all
-- examples/` is empty. If the compiler ever emits an output extension
not covered by the .gitignore catch-alls, the untracked file appears and
the job fails with a concrete list.

Local proof:
  PASSING — mds build wrote 73 outputs across 14 directories/files; git
  status was empty (all covered by .gitignore).
  FAILING — temporarily commenting out `examples/**/*.md` caused 61
  generated .md files to appear as `?? examples/...` in git status.

Also update the FOOTGUN WARNING comment in .gitignore to accurately
describe what CI does and does not enforce: the build-then-check job
catches new compiler output extensions; it cannot catch a hand-authored
file being silently swallowed by the catch-alls (that remains
local discipline).
@dean0x
dean0x merged commit cb44b56 into main Aug 9, 2026
20 checks passed
@dean0x
dean0x deleted the chore/release-machinery-hardening branch August 9, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

release: workflow_dispatch path fails GH006 on protected main (prepare can't push)

1 participant