From 2b3844139056fc38e84ee7ba4db1acd3e8ecdf68 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 09:45:58 +0200 Subject: [PATCH 1/4] chore: harden release machinery (5 fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 123 +++++++++++++++++----------------- .gitignore | 12 ++++ CLAUDE.md | 15 ++--- RELEASING.md | 43 +++++++----- scripts/bump-version.mjs | 14 ++-- 5 files changed, 112 insertions(+), 95 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3f012e3..e389c2cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,13 @@ name: Release -# Three entry points: +# Two entry points: # * push a version tag (v*) -> full coordinated release -# * workflow_dispatch with version -> automated release (bump, commit, tag, publish) -# * workflow_dispatch without version -> DRY RUN (build + verify, no publish) +# * workflow_dispatch (no inputs) -> dry run (build + verify, no publish) on: push: tags: - "v*" - workflow_dispatch: - inputs: - version: - description: "Version to release (e.g. 0.2.0). Leave empty for dry-run." - required: false - type: string + workflow_dispatch: {} permissions: contents: read @@ -22,51 +16,16 @@ env: CARGO_TERM_COLOR: always jobs: - # --------------------------------------------------------------------------- - # Prepare — only runs on workflow_dispatch with a version input. - # Bumps all manifests, stamps CHANGELOG, commits to main, creates the tag. - # Subsequent jobs see the updated code via the tag ref. - # --------------------------------------------------------------------------- - prepare: - name: Prepare v${{ inputs.version }} - if: inputs.version != '' - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - tag: v${{ inputs.version }} - steps: - - uses: actions/checkout@v6 - with: - ref: main - token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/setup-node@v6 - with: { node-version: 22 } - - name: Bump versions and stamp CHANGELOG - run: node scripts/bump-version.mjs "${{ inputs.version }}" - - name: Verify version consistency - run: node scripts/verify-versions.mjs - - name: Commit, tag, and push - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "release: v${{ inputs.version }}" - git tag "v${{ inputs.version }}" - git push origin main "v${{ inputs.version }}" - # --------------------------------------------------------------------------- # D1 — version-consistency gate. Cheap; fails fast before any build/publish. # --------------------------------------------------------------------------- version-gate: name: Version gate - needs: [prepare] - if: ${{ !cancelled() && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.version && format('v{0}', inputs.version) || github.ref }} + ref: ${{ github.ref }} - uses: actions/setup-node@v6 with: { node-version: 22 } - name: "Assert synchronized versions, no file: refs" @@ -120,7 +79,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.version && format('v{0}', inputs.version) || github.ref }} + ref: ${{ github.ref }} - uses: actions/setup-node@v6 with: { node-version: 22, cache: npm } - uses: dtolnay/rust-toolchain@stable @@ -157,7 +116,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.version && format('v{0}', inputs.version) || github.ref }} + ref: ${{ github.ref }} - uses: actions/setup-node@v6 with: { node-version: 22, cache: npm } - run: npm ci @@ -185,8 +144,9 @@ jobs: crates/mds-napi/*.node # =========================================================================== - # Everything below publishes — gated to tag pushes or dispatch with version. - # Plain workflow_dispatch (no version) stops above (dry run). + # Everything below publishes — gated to tag pushes only. + # workflow_dispatch (no inputs) stops above; publish jobs see a non-tag ref + # and their startsWith(github.ref, 'refs/tags/v') condition evaluates false. # =========================================================================== # --------------------------------------------------------------------------- @@ -196,17 +156,17 @@ jobs: publish-crates: name: Publish to crates.io needs: [version-gate] - if: ${{ !cancelled() && needs.version-gate.result == 'success' && (startsWith(github.ref, 'refs/tags/v') || inputs.version != '') }} + if: ${{ !cancelled() && needs.version-gate.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.version && format('v{0}', inputs.version) || github.ref }} + ref: ${{ github.ref }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Verify tag matches workspace version run: | - TAG="${{ inputs.version || github.ref_name }}" + TAG="${{ github.ref_name }}" TAG="${TAG#v}" CRATE_VER="$(cargo metadata --no-deps --format-version 1 \ | grep -o '"name":"mds-core","version":"[^"]*"' \ @@ -215,14 +175,53 @@ jobs: test "$TAG" = "$CRATE_VER" || { echo "::error::tag v$TAG != crate version $CRATE_VER"; exit 1; } - name: Publish mds-core run: | - cargo publish -p mds-core --token "${{ secrets.CARGO_REGISTRY_TOKEN }}" 2>&1 \ - || { echo "::warning::mds-core publish failed (may already exist)"; true; } + # Idempotency: a re-run where the version is already published should pass. + # Any other failure (bad token, outage, genuine version conflict) must fail loudly. + if OUTPUT=$(cargo publish -p mds-core --token "${{ secrets.CARGO_REGISTRY_TOKEN }}" 2>&1); then + echo "$OUTPUT" + elif echo "$OUTPUT" | grep -qE "already (uploaded|exists|published)"; then + echo "::notice::mds-core already published — idempotent re-run, skipping" + echo "$OUTPUT" + else + echo "::error::mds-core publish failed" + echo "$OUTPUT" + exit 1 + fi - name: Wait for crates.io index to update - run: sleep 30 + run: | + # Bounded poll — max 20 attempts × 15 s = 300 s (5 min), then fail with + # a clear message. Avoids the former unbounded sleep 30 that silently + # masked races on slow registry propagation. + VERSION=$(cargo metadata --no-deps --format-version 1 \ + | grep -o '"name":"mds-core","version":"[^"]*"' \ + | grep -o '[0-9][^"]*' | head -1) + echo "Polling crates.io for mds-core@${VERSION} (max 20 × 15 s)" + MAX=20 + for i in $(seq 1 "$MAX"); do + if curl -sf "https://crates.io/api/v1/crates/mds-core/${VERSION}" \ + -H "User-Agent: mds-release-ci/1.0" \ + | grep -qF "\"num\":\"${VERSION}\""; then + echo "mds-core@${VERSION} is visible in crates.io (attempt $i/$MAX)" + exit 0 + fi + echo " Attempt $i/$MAX: not yet visible — sleeping 15 s..." + sleep 15 + done + echo "::error::mds-core@${VERSION} not visible in crates.io after $((MAX * 15)) s" + exit 1 - name: Publish mds-cli run: | - cargo publish -p mds-cli --token "${{ secrets.CARGO_REGISTRY_TOKEN }}" 2>&1 \ - || { echo "::warning::mds-cli publish failed (may already exist)"; true; } + # Same idempotency guard as mds-core above. + if OUTPUT=$(cargo publish -p mds-cli --token "${{ secrets.CARGO_REGISTRY_TOKEN }}" 2>&1); then + echo "$OUTPUT" + elif echo "$OUTPUT" | grep -qE "already (uploaded|exists|published)"; then + echo "::notice::mds-cli already published — idempotent re-run, skipping" + echo "$OUTPUT" + else + echo "::error::mds-cli publish failed" + echo "$OUTPUT" + exit 1 + fi # --------------------------------------------------------------------------- # A7/B4/D3 — publish all npm packages with provenance (OIDC). @@ -232,7 +231,7 @@ jobs: publish-npm: name: Publish to npm needs: [stage-and-verify-napi, publish-crates] - if: ${{ !cancelled() && needs.stage-and-verify-napi.result == 'success' && needs.publish-crates.result == 'success' && (startsWith(github.ref, 'refs/tags/v') || inputs.version != '') }} + if: ${{ !cancelled() && needs.stage-and-verify-napi.result == 'success' && needs.publish-crates.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest permissions: id-token: write # OIDC for npm provenance @@ -244,7 +243,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.version && format('v{0}', inputs.version) || github.ref }} + ref: ${{ github.ref }} - uses: actions/setup-node@v6 with: node-version: 22 @@ -293,17 +292,17 @@ jobs: github-release: name: GitHub Release needs: [publish-crates, publish-npm] - if: ${{ !cancelled() && needs.publish-crates.result == 'success' && needs.publish-npm.result == 'success' && (startsWith(github.ref, 'refs/tags/v') || inputs.version != '') }} + if: ${{ !cancelled() && needs.publish-crates.result == 'success' && needs.publish-npm.result == 'success' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.version && format('v{0}', inputs.version) || github.ref }} + ref: ${{ github.ref }} - name: Create release run: | - TAG="${{ inputs.version && format('v{0}', inputs.version) || github.ref_name }}" + TAG="${{ github.ref_name }}" gh release create "$TAG" --title "$TAG" --generate-notes 2>&1 \ || gh release edit "$TAG" --generate-notes env: diff --git a/.gitignore b/.gitignore index 9ef23324..b1fac126 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,18 @@ packages/*/dist-cjs/ examples/*/dist/ examples/*/package-lock.json +# Generated compiler output — each tracked .mds source compiles to a sibling +# .md (or .json for @message templates). READMEs and config/data JSON files +# are tracked and excluded from the ignore via negation patterns below. +examples/**/*.md +!examples/**/README.md +examples/**/*.json +!examples/**/package.json +!examples/**/mds.json +!examples/**/vars.json +!examples/**/vars-minimal.json +!examples/**/tsconfig.json + # Python bindings (crates/mds-python) build/test artifacts. # `maturin develop` drops the compiled extension into the source tree; caches and # venvs are local-only. Sources (.py/.pyi/py.typed) are tracked. diff --git a/CLAUDE.md b/CLAUDE.md index 219ee68f..316cd0c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,8 +19,7 @@ maturin develop -m crates/mds-python/Cargo.toml && pytest crates/mds-python/test ## Release All packages ship as a single coordinated release at the same version, driven by -`release.yml`. **Release via tag-push** (the `workflow_dispatch -f version=` path is -currently blocked by branch protection — see #127): +`release.yml`. **Release via tag-push:** ```bash node scripts/bump-version.mjs X.Y.Z # bump all manifests + stamp CHANGELOG @@ -28,14 +27,10 @@ node scripts/bump-version.mjs X.Y.Z # bump all manifests + stamp CHANGELOG git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z ``` -Pushing the `vX.Y.Z` tag triggers `release.yml` (prepare is skipped): build 7 native -targets + WASM, A3 name-gate, publish to crates.io and npm (with provenance), create a -GitHub Release. Run `gh workflow run release.yml` (no version) first for a dry-run that -validates the build + A3 gate and publishes nothing. - -> The `workflow_dispatch -f version=X.Y.Z` "one command" path is **currently broken** -> (#127): its prepare job can't push the release commit to protected `main` (GH006), -> so it leaves an orphaned tag and publishes nothing. Use tag-push until #127 is fixed. +Pushing the `vX.Y.Z` tag triggers `release.yml`: build 7 native targets + WASM, +A3 name-gate, publish to crates.io and npm (with provenance), create a GitHub Release. +Run `gh workflow run release.yml` (no inputs) for a dry-run that validates the build + +A3 gate and publishes nothing. See @RELEASING.md for the full runbook. diff --git a/RELEASING.md b/RELEASING.md index 3a3e9a73..9385b043 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -82,9 +82,9 @@ runtime on the affected platform. Do not proceed past a failing gate. ## Release -### Tag-push (current working path) +### Tag-push (the only path) -The release is driven by pushing a `vX.Y.Z` tag. This is how v0.1.0–v0.3.0 shipped. +The release is driven by pushing a `vX.Y.Z` tag. This is how all versions have shipped. 1. **Bump versions:** `node scripts/bump-version.mjs X.Y.Z` (updates all manifests and stamps the CHANGELOG, opening a fresh `[Unreleased]`). @@ -96,21 +96,30 @@ The release is driven by pushing a `vX.Y.Z` tag. This is how v0.1.0–v0.3.0 shi git tag -a vX.Y.Z -m vX.Y.Z git push origin vX.Y.Z ``` - The tag push triggers `release.yml`; the `prepare` job is skipped and the - build+publish jobs run from the tag. - -### Automated `workflow_dispatch` — currently BLOCKED (#127) - -```bash -gh workflow run release.yml -f version=X.Y.Z # DOES NOT WORK YET — see #127 -``` - -Intended to do everything in one command, but its `prepare` job pushes the release -commit directly to protected `main`, which branch protection rejects for the Actions -bot (`GH006`). It leaves an orphaned tag and publishes nothing. Use tag-push until -#127 is fixed. - -See @RELEASING.md for the full runbook. + The tag push triggers `release.yml`; the build+publish jobs run from the tag. + +### What happens after tagging + +The `release.yml` workflow runs, in order: + 1. **version-gate** — synchronized-version check (fails fast). + 2. **build-napi** — cross-compiles the addon for all 7 targets. + 3. **stage-and-verify-napi** — `napi create-npm-dirs` + `artifacts`, copies + LICENSE into each platform dir, runs the **A3 name-gate**. + 4. **publish-crates** — `cargo publish` `mds-core`, polls the crates.io index + for up to 5 min (bounded, max 20 × 15 s), then `mds-cli`. + 5. **publish-npm** — regenerate `index.d.ts`, re-run the A3 gate, then publish + (with provenance): the **platform packages** (`napi prepublish`), the + **host** `@mdscript/mds-napi`, **`@mdscript/mds-wasm`**, the **universal** + `@mdscript/mds`, and the **bundler** packages. + 6. **github-release** — `gh release create` with generated notes. + +## Post-release + +- Verify each package on its registry (crates.io, npmjs.com) and that npm shows + the **provenance** attestation. +- Smoke test a clean install on a fresh machine/container: + `npm i @mdscript/mds` then `node -e "import('@mdscript/mds').then(m=>m.init())"`. +- Open a fresh `## [Unreleased]` section in `CHANGELOG.md`. ## Notes diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 9fa19545..ba01d0aa 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -102,12 +102,14 @@ const stamped = cl `$1\n## [${version}] — ${today}\n`, ) .replace( - /^(\[Unreleased\]:.*\/compare\/)v[\d.]+(...HEAD)$/m, - `$1v${version}$2`, - ) - .replace( - /^(\[[\d.]+\]:.*\/releases\/tag\/)v[\d.]+$/m, - `$1v${version}\n[${version}]: https://github.com/dean0x/mdscript/releases/tag/v${version}`, + // Update [Unreleased] to reference the new tag, and insert a new [version] + // compare link immediately after it. Captures the repo base URL from the + // existing [Unreleased] line so the URL is never hardcoded in this script. + // The old approach repointed [0.1.0]'s releases/tag line instead of inserting + // a new compare link, corrupting the link table on every bump. + /^(\[Unreleased\]: (https:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+)\/compare\/)v([\d.]+)(\.\.\.HEAD)$/m, + (_, _prefix, baseUrl, prevVersion) => + `[Unreleased]: ${baseUrl}/compare/v${version}...HEAD\n[${version}]: ${baseUrl}/compare/v${prevVersion}...v${version}`, ); if (stamped !== cl) { From 15bee1340ae0aaf5068d105806e1b930d999a04a Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 10:45:19 +0200 Subject: [PATCH 2/4] chore: close gitignore footgun + fix CHANGELOG link table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++++++++ .gitignore | 10 ++++++++++ CHANGELOG.md | 3 +-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1eaaa943..e47b771c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,45 @@ jobs: continue-on-error: true run: pytest crates/mds-python/tests -q -m perf + # Lightweight guard: every file ignored under examples/ must have a sibling + # .mds source (generated output). If it doesn't, the file is hand-authored + # content being silently dropped by the .gitignore catch-alls — the author + # must add an explicit '!' exception. See the FOOTGUN WARNING comment in + # .gitignore. This job runs checkout-only; no build dependencies needed. + examples-guard: + name: examples/ gitignore guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Verify no hand-authored file is caught by the examples catch-alls + run: | + # Collect all files that .gitignore ignores under examples/. + # For each, check that a sibling .mds source exists. + # Bounded: MAX=500 is far above the real examples count; exits 1 if exceeded. + OFFENDERS=() + COUNT=0 + MAX=500 + while IFS= read -r path; do + COUNT=$((COUNT + 1)) + if [ "$COUNT" -gt "$MAX" ]; then + echo "::error::Ignored file count exceeded $MAX — review .gitignore examples patterns" + exit 1 + fi + sibling="${path%.*}.mds" + if [ ! -f "$sibling" ]; then + OFFENDERS+=("$path") + fi + done < <(git ls-files --others --ignored --exclude-standard -- examples/ || true) + if [ "${#OFFENDERS[@]}" -gt 0 ]; then + echo "::error::The following files under examples/ are ignored but have no .mds sibling." + echo " They look like hand-authored content being silently dropped by" + echo " the .gitignore catch-all patterns. Add an explicit '!examples/**/yourfile.ext'" + echo " exception in .gitignore for each one." + for f in "${OFFENDERS[@]}"; do echo " $f"; done + exit 1 + fi + echo "examples-guard passed: $COUNT ignored file(s) each have a sibling .mds source." + python-wheel: name: Python — wheel install smoke runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b1fac126..69f26c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,16 @@ examples/*/package-lock.json # Generated compiler output — each tracked .mds source compiles to a sibling # .md (or .json for @message templates). READMEs and config/data JSON files # are tracked and excluded from the ignore via negation patterns below. +# +# FOOTGUN WARNING: These are open-ended catch-alls. git cannot express +# "ignore X.md only when a sibling X.mds exists", so any hand-authored .md or +# .json added under examples/ is silently ignored unless you add an explicit +# '!examples/**/your-file.ext' exception here. +# +# A CI guard in .github/workflows/ci.yml (job: examples-guard) enforces this +# invariant: it fails if any ignored file under examples/ lacks a sibling .mds +# source. If you add a hand-authored file, add the '!' exception FIRST, before +# the CI guard can catch it. examples/**/*.md !examples/**/README.md examples/**/*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b46e3cd0..cb6a4bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1008,8 +1008,7 @@ First public release of the MDS (Markdown Script) compiler. - 590 Rust tests (integration, unit, and doc-tests across the workspace) plus the JavaScript package suites -[Unreleased]: https://github.com/dean0x/mdscript/compare/v0.4.0...HEAD -[0.4.0]: https://github.com/dean0x/mdscript/compare/v0.3.0...v0.4.0 +[Unreleased]: https://github.com/dean0x/mdscript/compare/v0.3.0...HEAD [0.3.0]: https://github.com/dean0x/mdscript/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/dean0x/mdscript/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/dean0x/mdscript/releases/tag/v0.1.0 From 6ad18a4a4f86668ba3227b9e2bdffd01faf54c56 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 11:01:42 +0200 Subject: [PATCH 3/4] style: fix rustfmt violation in output.rs introduced by #240 PR #240 (de8857da) 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. --- crates/mds-cli/src/output.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs index f8251506..6bcd46d4 100644 --- a/crates/mds-cli/src/output.rs +++ b/crates/mds-cli/src/output.rs @@ -462,7 +462,10 @@ pub(crate) fn atomic_write_file(path: &Path, content: &str) -> Result<()> { match std::fs::metadata(path) { Ok(m) => Some(m.permissions().mode()), Err(e) => { - eprint_error(miette::miette!("cannot get metadata for {}: {e}", path.display())); + eprint_error(miette::miette!( + "cannot get metadata for {}: {e}", + path.display() + )); None } } From 82896aebd98e4fc377b4f9bdac94c285e07cc412 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 9 Aug 2026 11:02:22 +0200 Subject: [PATCH 4/4] chore: replace examples-guard with build-then-check gitignore coverage job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .github/workflows/ci.yml | 88 +++++++++++++++++++++++++--------------- .gitignore | 26 +++++++++--- 2 files changed, 76 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e47b771c..b2555e6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,44 +212,68 @@ jobs: continue-on-error: true run: pytest crates/mds-python/tests -q -m perf - # Lightweight guard: every file ignored under examples/ must have a sibling - # .mds source (generated output). If it doesn't, the file is hand-authored - # content being silently dropped by the .gitignore catch-alls — the author - # must add an explicit '!' exception. See the FOOTGUN WARNING comment in - # .gitignore. This job runs checkout-only; no build dependencies needed. - examples-guard: - name: examples/ gitignore guard + # Build the CLI, compile every example that is expected to succeed, then assert + # that git sees no untracked files under examples/. If the compiler ever starts + # emitting a new extension that is not covered by the .gitignore catch-alls, this + # job fails with a concrete list of the uncovered files. + # + # What this catches: a new output extension added by the compiler that is not yet + # covered by .gitignore. + # What this does NOT catch: a hand-authored file being silently dropped by the + # catch-alls (that is a local-discipline concern; see the FOOTGUN WARNING in + # .gitignore). + # + # examples/stress-test/errors/ contains five intentionally-failing fixtures + # (bad-arity, bad-circular-a/b, bad-type, bad-undefined) — those are skipped. + examples-gitignore-coverage: + name: examples/ gitignore coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - name: Verify no hand-authored file is caught by the examples catch-alls + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build mds CLI + run: cargo build -p mds-cli + - name: Compile examples (excluding intentionally-failing fixtures) run: | - # Collect all files that .gitignore ignores under examples/. - # For each, check that a sibling .mds source exists. - # Bounded: MAX=500 is far above the real examples count; exits 1 if exceeded. - OFFENDERS=() - COUNT=0 - MAX=500 - while IFS= read -r path; do - COUNT=$((COUNT + 1)) - if [ "$COUNT" -gt "$MAX" ]; then - echo "::error::Ignored file count exceeded $MAX — review .gitignore examples patterns" - exit 1 - fi - sibling="${path%.*}.mds" - if [ ! -f "$sibling" ]; then - OFFENDERS+=("$path") - fi - done < <(git ls-files --others --ignored --exclude-standard -- examples/ || true) - if [ "${#OFFENDERS[@]}" -gt 0 ]; then - echo "::error::The following files under examples/ are ignored but have no .mds sibling." - echo " They look like hand-authored content being silently dropped by" - echo " the .gitignore catch-all patterns. Add an explicit '!examples/**/yourfile.ext'" - echo " exception in .gitignore for each one." - for f in "${OFFENDERS[@]}"; do echo " $f"; done + MDS=./target/debug/mds + + # Directories that compile cleanly without runtime variables. + for dir in \ + examples/ai-agent \ + examples/api-docs \ + examples/blog-generator \ + examples/formatting \ + examples/inheritance \ + examples/linting \ + examples/prompt-library \ + examples/source-maps \ + examples/stress-test/agents \ + examples/stress-test/edge \ + examples/stress-test/lib \ + examples/stress-test/shared; do + $MDS build "$dir" + done + + # stress-test root entry point. + $MDS build examples/stress-test/main.mds + + # edge-cases: supply vars.json so the runtime-vars example (08) compiles; + # vars are silently ignored by the other 30 templates. + $MDS build examples/edge-cases --vars examples/edge-cases/vars.json + + - name: Assert all outputs are gitignored + run: | + UNTRACKED=$(git status --porcelain --untracked-files=all -- examples/) + if [ -n "$UNTRACKED" ]; then + echo "::error::examples/ has untracked files after mds build." + echo " A compiler output extension is not covered by .gitignore." + echo " Add a catch-all pattern AND any needed '!' exceptions." + echo "" + echo "$UNTRACKED" exit 1 fi - echo "examples-guard passed: $COUNT ignored file(s) each have a sibling .mds source." + echo "examples/ gitignore coverage check passed — all compiler outputs are gitignored." python-wheel: name: Python — wheel install smoke diff --git a/.gitignore b/.gitignore index 69f26c5e..33a3ee6e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,12 +22,14 @@ examples/*/package-lock.json # FOOTGUN WARNING: These are open-ended catch-alls. git cannot express # "ignore X.md only when a sibling X.mds exists", so any hand-authored .md or # .json added under examples/ is silently ignored unless you add an explicit -# '!examples/**/your-file.ext' exception here. +# '!examples/**/your-file.ext' exception here. This is LOCAL discipline only — +# CI cannot detect it. Always add the '!' exception before adding a hand-authored +# file; otherwise git will silently drop it. # -# A CI guard in .github/workflows/ci.yml (job: examples-guard) enforces this -# invariant: it fails if any ignored file under examples/ lacks a sibling .mds -# source. If you add a hand-authored file, add the '!' exception FIRST, before -# the CI guard can catch it. +# What CI DOES enforce (job: examples-gitignore-coverage in ci.yml): after +# running mds build over the examples tree, git must report no untracked files +# under examples/. This catches a new compiler output extension that is not yet +# covered by the patterns below — it does NOT catch the hand-authored-file footgun. examples/**/*.md !examples/**/README.md examples/**/*.json @@ -53,4 +55,16 @@ crates/mds-python/dist/ .cargo/ # devflow local state — local-only, not shared via git (reverses ADR-019; see ADR-023) -.devflow/ + +# Devflow runtime data — local by default (memory, learning, docs, locks). +# Exception: feature knowledge bases under .devflow/features/ are shared via git — +# index.md and every {slug}/KNOWLEDGE.md are tracked and committed; everything else +# under .devflow/features/ stays local. To stop sharing, re-add `.devflow/features/` +# to your own .gitignore. +.devflow/* +!.devflow/features/ +.devflow/features/* +!.devflow/features/index.md +!.devflow/features/*/ +.devflow/features/*/* +!.devflow/features/*/KNOWLEDGE.md