Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 61 additions & 62 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
# ===========================================================================

# ---------------------------------------------------------------------------
Expand All @@ -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":"[^"]*"' \
Expand All @@ -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).
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 37 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 5 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,18 @@ 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
# land the bump on main via PR (CI-gated), then:
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.

Expand Down
Loading
Loading