diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1eaaa943..b2555e6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,6 +212,69 @@ jobs: continue-on-error: true run: pytest crates/mds-python/tests -q -m perf + # 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 + - 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: | + 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/ gitignore coverage check passed — all compiler outputs are gitignored." + python-wheel: name: Python — wheel install smoke runs-on: ubuntu-latest 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..33a3ee6e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,30 @@ 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. +# +# 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. 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. +# +# 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 +!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. @@ -31,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 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 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/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 } } 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) {