diff --git a/.github/scripts/release/r2/publish.sh b/.github/scripts/release/r2/publish.sh
index 0262ea2..1bb23e3 100755
--- a/.github/scripts/release/r2/publish.sh
+++ b/.github/scripts/release/r2/publish.sh
@@ -117,6 +117,10 @@ metadata = {
},
}
+guard_hash = env.get("GUARD_VERSION_HASH", "")
+if guard_hash:
+ metadata["guard"] = {"version": {"hash": guard_hash}}
+
if env["RELEASE_CHANNEL"] == "beta":
match = re.match(r"^v?(\d+\.\d+\.\d+)-beta\.([1-9][0-9]*)$", env["RELEASE_VERSION"])
if not match:
@@ -131,6 +135,9 @@ if env["RELEASE_CHANNEL"] == "beta":
metadata["betaNumber"] = beta_number
metadata["betaVersion"] = env["RELEASE_VERSION"]
metadata["stateSource"] = env.get("STATE_SOURCE") or "workflow input"
+else:
+ metadata["stableVersion"] = env["RELEASE_VERSION"]
+ metadata["stateSource"] = env.get("STATE_SOURCE") or "workflow input"
Path(env["METADATA_PATH"]).write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
PY
diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml
index a55e9fc..9598160 100644
--- a/.github/workflows/guard.yml
+++ b/.github/workflows/guard.yml
@@ -30,15 +30,6 @@ jobs:
components: rustfmt, clippy
- name: Format
- run: cargo fmt --all --check
-
- - name: Clippy
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
-
- - name: Test
- run: cargo test --locked --workspace
-
- - name: Install Flavor
if: runner.os != 'Windows'
run: |
set -euo pipefail
@@ -55,5 +46,16 @@ jobs:
& (Join-Path $env:RUNNER_TEMP 'manage-flavor.ps1') install --bin-dir $binDir
$binDir | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- - name: Flavor self-check
- run: flavor check --root . --config flavor.toml
+ - name: Guard
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo build --quiet --locked -p runseal
+ exe=runseal
+ if [ "${RUNNER_OS:-}" = "Windows" ]; then
+ exe=runseal.exe
+ fi
+ bin_dir="$PWD/.local/tmp/ci-guard-bin"
+ mkdir -p "$bin_dir"
+ cp "target/debug/$exe" "$bin_dir/$exe"
+ PATH="$bin_dir:$PWD/target/debug:$PATH" "$bin_dir/$exe" :guard
diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml
index ad4f385..a9d567f 100644
--- a/.github/workflows/release-beta.yml
+++ b/.github/workflows/release-beta.yml
@@ -51,6 +51,12 @@ jobs:
id: beta
run: python3 .github/scripts/release/metadata/beta.py
+ - name: Guard version policy
+ shell: bash
+ run: |
+ set -euo pipefail
+ PATH="$PWD/target/debug:$PATH" cargo run --quiet --locked -p runseal -- :guard version-check
+
verify:
needs: metadata
runs-on: ubuntu-latest
@@ -73,6 +79,7 @@ jobs:
run: cargo clippy --locked --workspace --all-targets -- -D warnings
- name: Install Flavor
+ shell: bash
run: |
set -euo pipefail
curl -fsSL https://flavor.perish.uk/manage.sh -o "$RUNNER_TEMP/manage-flavor.sh"
@@ -157,6 +164,15 @@ jobs:
path: dist/${{ needs.metadata.outputs.release_version }}
merge-multiple: true
+ - name: Resolve guard version hash
+ id: guard_hash
+ shell: bash
+ run: |
+ set -euo pipefail
+ PATH="$PWD/target/debug:$PATH"
+ hash=$(cargo run --quiet --locked -p runseal -- :guard version-hash)
+ echo "value=$hash" >> "$GITHUB_OUTPUT"
+
- name: Create checksums
run: sh .github/scripts/release/assets/checksums.sh "${RELEASE_VERSION}" "${RELEASE_ROOT}"
@@ -168,6 +184,8 @@ jobs:
- name: Publish to R2
id: r2
+ env:
+ GUARD_VERSION_HASH: ${{ steps.guard_hash.outputs.value }}
run: bash .github/scripts/release/r2/publish.sh
- name: Verify R2 publish
diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml
index 442daf4..4b83397 100644
--- a/.github/workflows/release-stable.yml
+++ b/.github/workflows/release-stable.yml
@@ -50,6 +50,12 @@ jobs:
id: stable
run: python3 .github/scripts/release/metadata/stable.py
+ - name: Guard version policy
+ shell: bash
+ run: |
+ set -euo pipefail
+ PATH="$PWD/target/debug:$PATH" cargo run --quiet --locked -p runseal -- :guard version-check
+
verify:
needs: metadata
runs-on: ubuntu-latest
@@ -72,6 +78,7 @@ jobs:
run: cargo clippy --locked --workspace --all-targets -- -D warnings
- name: Install Flavor
+ shell: bash
run: |
set -euo pipefail
curl -fsSL https://flavor.perish.uk/manage.sh -o "$RUNNER_TEMP/manage-flavor.sh"
@@ -155,6 +162,15 @@ jobs:
path: dist/${{ needs.metadata.outputs.release_version }}
merge-multiple: true
+ - name: Resolve guard version hash
+ id: guard_hash
+ shell: bash
+ run: |
+ set -euo pipefail
+ PATH="$PWD/target/debug:$PATH"
+ hash=$(cargo run --quiet --locked -p runseal -- :guard version-hash)
+ echo "value=$hash" >> "$GITHUB_OUTPUT"
+
- name: Create checksums
run: sh .github/scripts/release/assets/checksums.sh "${RELEASE_VERSION}" "${RELEASE_ROOT}"
@@ -166,6 +182,8 @@ jobs:
- name: Publish to R2
id: r2
+ env:
+ GUARD_VERSION_HASH: ${{ steps.guard_hash.outputs.value }}
run: bash .github/scripts/release/r2/publish.sh
- name: Verify R2 publish
@@ -174,6 +192,7 @@ jobs:
run: bash .github/scripts/release/r2/verify.sh
- name: Create and push git tag
+ shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
diff --git a/.runseal/hooks/commit-msg b/.runseal/hooks/commit-msg
new file mode 100644
index 0000000..10f9150
--- /dev/null
+++ b/.runseal/hooks/commit-msg
@@ -0,0 +1,39 @@
+#!/usr/bin/env sh
+# runseal init hook: generated by runseal :init
+set -eu
+
+if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
+ echo "commit message file not found" >&2
+ exit 1
+fi
+
+subject=$(sed -n '
+/^[[:space:]]*#/d
+/[^[:space:]]/ {
+ s/^[[:space:]]*//
+ s/[[:space:]]*$//
+ p
+ q
+}
+' "$1")
+
+if [ -z "$subject" ]; then
+ echo "commit subject is empty" >&2
+ exit 1
+fi
+
+case "$subject" in
+ "Merge "*|"Revert "*|"fixup!"*|"squash!"*)
+ exit 0
+ ;;
+esac
+
+if ! printf '%s\n' "$subject" | grep -Eq '^[a-z][a-z0-9-]*(/[a-z0-9-]+)*: [^[:space:]].*'; then
+ echo "commit subject must look like ': '" >&2
+ echo "actual: $subject" >&2
+ exit 1
+fi
+
+if [ "${#subject}" -gt 72 ]; then
+ echo "warning: commit subject is ${#subject} chars; prefer <= 72" >&2
+fi
diff --git a/.runseal/hooks/pre-commit b/.runseal/hooks/pre-commit
new file mode 100644
index 0000000..1521564
--- /dev/null
+++ b/.runseal/hooks/pre-commit
@@ -0,0 +1,7 @@
+#!/usr/bin/env sh
+# runseal init hook: generated by runseal :init
+set -eu
+root=$(git rev-parse --show-toplevel)
+cd "$root"
+
+runseal :guard
diff --git a/.runseal/wrappers/guard.seal b/.runseal/wrappers/guard.seal
new file mode 100644
index 0000000..cbda19f
--- /dev/null
+++ b/.runseal/wrappers/guard.seal
@@ -0,0 +1,195 @@
+print() {
+ printf '%s\n' "$1"
+}
+
+error() {
+ printf '%s\n' "$1" >&2
+}
+
+fail() {
+ error "$1"
+ exit 1
+}
+
+usage() {
+ print "Usage: runseal :guard [version-check|version-hash]"
+ print ""
+ print "Run repository guard checks or one explicit version-policy helper."
+ print ""
+ print "Commands:"
+ print " version-check validate version policy against stable metadata"
+ print " version-hash print the current guard.version.hash value"
+}
+
+mode=full
+if [ "$#" -gt 0 ]; then
+ case "$1" in
+ version-check)
+ mode=version-check
+ shift
+ ;;
+ version-hash)
+ mode=version-hash
+ shift
+ ;;
+ -h|--help|help)
+ usage
+ exit 0
+ ;;
+ *)
+ fail "guard: unknown command: $1"
+ ;;
+ esac
+fi
+
+if [ "$#" -gt 0 ]; then
+ fail "guard: unexpected arguments"
+fi
+
+if [ "$mode" = version-hash ]; then
+ runseal @tool hash tree app/tests
+ exit 0
+fi
+
+public_url="${RUNSEAL_RELEASES_PUBLIC_URL:-}"
+if [ -z "$public_url" ]; then
+ public_url="https://releases.runseal.perish.uk"
+fi
+
+metadata_url="${RUNSEAL_STABLE_METADATA_URL:-}"
+if [ -z "$metadata_url" ]; then
+ metadata_url="$public_url/stable/latest/metadata.json"
+fi
+
+tmp_dir="${RUNSEAL_REPO_TMP_DIR:-.local/tmp}"
+if [ -n "${RUNNER_TEMP:-}" ]; then
+ tmp_dir="${RUNNER_TEMP}/runseal-guard"
+fi
+runseal @tool fs mkdir "$tmp_dir" 700
+metadata_file="$tmp_dir/runseal-guard-stable-metadata.json"
+cargo_metadata=$(cargo metadata --no-deps --format-version 1)
+current_version=$(runseal @tool json get "$cargo_metadata" '.packages[0].version')
+current_hash=$(runseal @tool hash tree app/tests)
+status=$(curl -sS -o "$metadata_file" -w "%{http_code}" "$metadata_url?version=$current_version")
+if [ "$status" = 404 ]; then
+ print "guard version policy: no stable metadata; skipping"
+ if [ "$mode" = version-check ]; then
+ exit 0
+ fi
+else
+ if [ "$status" = 200 ]; then
+ else
+ fail "guard version policy: failed to fetch stable metadata: HTTP $status"
+ fi
+
+ metadata=$(cat "$metadata_file")
+ has_prior_hash=$(runseal @tool json has "$metadata" .guard.version.hash)
+ if [ "$has_prior_hash" = true ]; then
+ prior_hash=$(runseal @tool json get "$metadata" .guard.version.hash)
+ else
+ prior_hash=
+ fi
+ if [ -z "$prior_hash" ]; then
+ print "guard version policy: stable metadata has no guard.version.hash; skipping"
+ if [ "$mode" = version-check ]; then
+ exit 0
+ fi
+ else
+ has_stable_version=$(runseal @tool json has "$metadata" .stableVersion)
+ if [ "$has_stable_version" = true ]; then
+ prior_version=$(runseal @tool json get "$metadata" .stableVersion)
+ else
+ prior_version=
+ fi
+ if [ -z "$prior_version" ]; then
+ has_release_version=$(runseal @tool json has "$metadata" .releaseVersion)
+ if [ "$has_release_version" = true ]; then
+ prior_version=$(runseal @tool json get "$metadata" .releaseVersion)
+ fi
+ fi
+ if [ -z "$prior_version" ]; then
+ fail "guard version policy: stable metadata is missing stableVersion/releaseVersion"
+ fi
+
+ current_order=$(runseal @tool version compare "$current_version" "$prior_version")
+ prior_major=$(runseal @tool version part "$prior_version" major)
+ prior_minor=$(runseal @tool version part "$prior_version" minor)
+ current_major=$(runseal @tool version part "$current_version" major)
+ current_minor=$(runseal @tool version part "$current_version" minor)
+ same_minor_lineage=false
+
+ if [ "$current_order" = lt ]; then
+ fail "guard version policy: version regressed below prior stable $prior_version"
+ fi
+ if [ "$current_order" = eq ]; then
+ fail "guard version policy: version matches prior stable $prior_version"
+ fi
+ if [ "$current_major" = "$prior_major" ]; then
+ if [ "$current_minor" = "$prior_minor" ]; then
+ same_minor_lineage=true
+ fi
+ fi
+
+ if [ "$current_hash" = "$prior_hash" ]; then
+ if [ "$same_minor_lineage" = false ]; then
+ fail "guard version policy: unchanged guard.version.hash requires a patch-only bump above $prior_version"
+ fi
+ print "guard version policy: hash unchanged -> patch bump ok ($prior_version -> $current_version)"
+ else
+ if [ "$same_minor_lineage" = true ]; then
+ fail "guard version policy: changed guard.version.hash requires a minor-or-higher bump above $prior_version"
+ fi
+ print "guard version policy: hash changed -> minor-or-higher bump ok ($prior_version -> $current_version)"
+ fi
+
+ if [ "$mode" = version-check ]; then
+ exit 0
+ fi
+ fi
+fi
+
+print "==> cargo fmt"
+cargo fmt --all --check
+
+print "==> cargo clippy"
+cargo clippy --locked --workspace --all-targets -- -D warnings
+
+print "==> cargo test"
+cargo test --locked --workspace
+
+print "==> seal wrappers"
+cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/cloudflare.seal
+cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/guard.seal
+cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/init.seal
+cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/pr.seal
+cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/release.seal
+
+print "==> flavor self-check"
+flavor check --root . --config flavor.toml
+
+print "==> shell syntax"
+sh -n manage.sh
+sh -n .github/scripts/release/assets/checksums.sh
+sh -n .github/scripts/release/assets/package.sh
+sh -n .github/scripts/release/assets/verify.sh
+sh -n .github/scripts/release/github/cleanup-artifacts.sh
+bash -n .github/scripts/release/r2/check.sh
+bash -n .github/scripts/release/r2/publish.sh
+bash -n .github/scripts/release/r2/summary.sh
+bash -n .github/scripts/release/r2/verify.sh
+sh -n .github/scripts/release/smoke/smoke.sh
+
+print "==> python syntax"
+python3 -m py_compile .github/scripts/release/metadata/beta.py
+python3 -m py_compile .github/scripts/release/metadata/stable.py
+
+has_pwsh=$(runseal @tool process exists pwsh)
+if [ "$has_pwsh" = true ]; then
+ print "==> PowerShell syntax"
+ pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw 'manage.ps1')) | Out-Null"
+ pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/assets/package.ps1')) | Out-Null"
+ pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/smoke/smoke.ps1')) | Out-Null"
+else
+ print "==> PowerShell syntax"
+ print "skip: pwsh not found"
+fi
diff --git a/.runseal/wrappers/init.seal b/.runseal/wrappers/init.seal
index cfc7cfb..904da5e 100644
--- a/.runseal/wrappers/init.seal
+++ b/.runseal/wrappers/init.seal
@@ -93,9 +93,10 @@ require_tool runseal
require_tool flavor
require_tool sh
require_tool bash
+require_tool cp
require_tool sed
require_tool grep
-print "ok: git, python3, cargo, runseal, flavor, sh, bash, sed, grep"
+print "ok: git, python3, cargo, runseal, flavor, sh, bash, cp, sed, grep"
print "==> checking repository entrypoints"
require_path Cargo.toml
@@ -104,7 +105,10 @@ require_path flavor.toml
require_path manage.sh
require_path manage.ps1
require_path runseal.toml
+require_path .runseal/hooks/pre-commit
+require_path .runseal/hooks/commit-msg
require_path .runseal/wrappers/cloudflare.seal
+require_path .runseal/wrappers/guard.seal
require_path .runseal/wrappers/init.seal
require_path .runseal/wrappers/pr.seal
require_path .runseal/wrappers/release.seal
@@ -123,13 +127,13 @@ runseal @tool fs mkdir "$hooks_dir" 700
prepare_hook "$pre_commit"
-runseal @tool fs write-base64 "$pre_commit" IyEvdXNyL2Jpbi9lbnYgc2gKIyBydW5zZWFsIGluaXQgaG9vazogZ2VuZXJhdGVkIGJ5IC5ydW5zZWFsL3dyYXBwZXJzL2luaXQuc2VhbApzZXQgLWV1CnJvb3Q9JChnaXQgcmV2LXBhcnNlIC0tc2hvdy10b3BsZXZlbCkKY2QgIiRyb290IgoKZWNobyAiPT0+IGNhcmdvIGZtdCIKY2FyZ28gZm10IC0tYWxsIC0tY2hlY2sKCmVjaG8gIj09PiBjYXJnbyBjbGlwcHkiCmNhcmdvIGNsaXBweSAtLWxvY2tlZCAtLXdvcmtzcGFjZSAtLWFsbC10YXJnZXRzIC0tIC1EIHdhcm5pbmdzCgplY2hvICI9PT4gY2FyZ28gdGVzdCIKY2FyZ28gdGVzdCAtLWxvY2tlZCAtLXdvcmtzcGFjZQoKZWNobyAiPT0+IHNlYWwgd3JhcHBlcnMiCmNhcmdvIHJ1biAtLXF1aWV0IC0tbG9ja2VkIC1wIHJ1bnNlYWwgLS0gQHRyYW5zcGlsZSAtLWlucHV0LWxhbmc9c2VhbCAtLW91dHB1dC1sYW5nPXNlYWxpciAucnVuc2VhbC93cmFwcGVycy9pbml0LnNlYWwgPi9kZXYvbnVsbApjYXJnbyBydW4gLS1xdWlldCAtLWxvY2tlZCAtcCBydW5zZWFsIC0tIEB0cmFuc3BpbGUgLS1pbnB1dC1sYW5nPXNlYWwgLS1vdXRwdXQtbGFuZz1zZWFsaXIgLnJ1bnNlYWwvd3JhcHBlcnMvY2xvdWRmbGFyZS5zZWFsID4vZGV2L251bGwKY2FyZ28gcnVuIC0tcXVpZXQgLS1sb2NrZWQgLXAgcnVuc2VhbCAtLSBAdHJhbnNwaWxlIC0taW5wdXQtbGFuZz1zZWFsIC0tb3V0cHV0LWxhbmc9c2VhbGlyIC5ydW5zZWFsL3dyYXBwZXJzL3ByLnNlYWwgPi9kZXYvbnVsbApjYXJnbyBydW4gLS1xdWlldCAtLWxvY2tlZCAtcCBydW5zZWFsIC0tIEB0cmFuc3BpbGUgLS1pbnB1dC1sYW5nPXNlYWwgLS1vdXRwdXQtbGFuZz1zZWFsaXIgLnJ1bnNlYWwvd3JhcHBlcnMvcmVsZWFzZS5zZWFsID4vZGV2L251bGwKCmVjaG8gIj09PiBmbGF2b3Igc2VsZi1jaGVjayIKZmxhdm9yIGNoZWNrIC0tcm9vdCAuIC0tY29uZmlnIGZsYXZvci50b21sCgplY2hvICI9PT4gc2hlbGwgc3ludGF4IgpzaCAtbiBtYW5hZ2Uuc2gKc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvYXNzZXRzL2NoZWNrc3Vtcy5zaApzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9hc3NldHMvcGFja2FnZS5zaApzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9hc3NldHMvdmVyaWZ5LnNoCnNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL2dpdGh1Yi9jbGVhbnVwLWFydGlmYWN0cy5zaApiYXNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL3IyL2NoZWNrLnNoCmJhc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvcjIvcHVibGlzaC5zaApiYXNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL3IyL3N1bW1hcnkuc2gKYmFzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9yMi92ZXJpZnkuc2gKc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2Uvc21va2Uvc21va2Uuc2gKCmVjaG8gIj09PiBweXRob24gc3ludGF4IgpweXRob24zIC1tIHB5X2NvbXBpbGUgLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvbWV0YWRhdGEvYmV0YS5weQpweXRob24zIC1tIHB5X2NvbXBpbGUgLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvbWV0YWRhdGEvc3RhYmxlLnB5CgppZiBjb21tYW5kIC12IHB3c2ggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgZWNobyAiPT0+IFBvd2VyU2hlbGwgc3ludGF4IgogIHB3c2ggLU5vUHJvZmlsZSAtTm9uSW50ZXJhY3RpdmUgLUNvbW1hbmQgICAgICcKJEVycm9yQWN0aW9uUHJlZmVyZW5jZSA9ICJTdG9wIgokcGF0aHMgPSBAKAogICJtYW5hZ2UucHMxIiwKICAiLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvYXNzZXRzL3BhY2thZ2UucHMxIiwKICAiLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2Uvc21va2Uvc21va2UucHMxIgopCmZvcmVhY2ggKCRwYXRoIGluICRwYXRocykgewogIFtzY3JpcHRibG9ja106OkNyZWF0ZSgoR2V0LUNvbnRlbnQgLVJhdyAkcGF0aCkpIHwgT3V0LU51bGwKfQonCmVsc2UKICBlY2hvICI9PT4gUG93ZXJTaGVsbCBzeW50YXgiCiAgZWNobyAic2tpcDogcHdzaCBub3QgZm91bmQiCmZp
+cp .runseal/hooks/pre-commit "$pre_commit"
runseal @tool fs chmod "$pre_commit" 755
print "installed $pre_commit"
prepare_hook "$commit_msg"
-runseal @tool fs write-base64 "$commit_msg" IyEvdXNyL2Jpbi9lbnYgc2gKIyBydW5zZWFsIGluaXQgaG9vazogZ2VuZXJhdGVkIGJ5IC5ydW5zZWFsL3dyYXBwZXJzL2luaXQuc2VhbApzZXQgLWV1CgppZiBbICIkIyIgLW5lIDEgXSB8fCBbICEgLWYgIiQxIiBdOyB0aGVuCiAgZWNobyAiY29tbWl0IG1lc3NhZ2UgZmlsZSBub3QgZm91bmQiID4mMgogIGV4aXQgMQpmaQoKc3ViamVjdD0kKHNlZCAtbiAnCi9eW1s6c3BhY2U6XV0qIy9kCi9bXls6c3BhY2U6XV0vIHsKICBzL15bWzpzcGFjZTpdXSovLwogIHMvW1s6c3BhY2U6XV0qJC8vCiAgcAogIHEKfQonICIkMSIpCgppZiBbIC16ICIkc3ViamVjdCIgXTsgdGhlbgogIGVjaG8gImNvbW1pdCBzdWJqZWN0IGlzIGVtcHR5IiA+JjIKICBleGl0IDEKZmkKCmNhc2UgIiRzdWJqZWN0IiBpbgogICJNZXJnZSAiKnwiUmV2ZXJ0ICIqfCJmaXh1cCEiKnwic3F1YXNoISIqKQogICAgZXhpdCAwCiAgICA7Owplc2FjCgppZiAhIHByaW50ZiAnJXMKJyAiJHN1YmplY3QiIHwgZ3JlcCAtRXEgJ15bYS16XVthLXowLTktXSooL1thLXowLTktXSspKjogW15bOnNwYWNlOl1dLionOyB0aGVuCiAgZWNobyAiY29tbWl0IHN1YmplY3QgbXVzdCBsb29rIGxpa2UgJzxhcmVhPjogPGltcGVyYXRpdmUgc3VtbWFyeT4nIiA+JjIKICBlY2hvICJhY3R1YWw6ICRzdWJqZWN0IiA+JjIKICBleGl0IDEKZmkKCmlmIFsgIiR7I3N1YmplY3R9IiAtZ3QgNzIgXTsgdGhlbgogIGVjaG8gIndhcm5pbmc6IGNvbW1pdCBzdWJqZWN0IGlzICR7I3N1YmplY3R9IGNoYXJzOyBwcmVmZXIgPD0gNzIiID4mMgpmaQo=
+cp .runseal/hooks/commit-msg "$commit_msg"
runseal @tool fs chmod "$commit_msg" 755
print "installed $commit_msg"
diff --git a/AGENTS.md b/AGENTS.md
index 4b47e32..b911150 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,7 +17,22 @@ This top-level `AGENTS.md` is the repository navigation and policy layer.
Core product stance:
-- Small CLI. Explicit profile. No hidden orchestration.
+- `runseal` is not just an operations toolkit. It is an operations methodology
+ plus a derived tool system: the main value is deciding which operational
+ complexity belongs in wrappers, atomic tools, repo/local artifacts, or
+ external scripts, then keeping those layers explicit.
+- `runseal` exists to reduce environment-dependency complexity in real
+ cross-platform operations work: too many environment variables, too many
+ machine-specific assumptions, and too much operational glue falling back to
+ Python or JavaScript even when the underlying workflow is clear and finite.
+- Explicit profile. No hidden orchestration.
+- Prefer a stable shared subset of `bash` and PowerShell for `.seal`, plus
+ explicit atomic `@tool` capabilities for needs that do not fit that shared
+ shell surface cleanly.
+- Preserve cross-shell semantic equivalence as the hard constraint. Do not
+ require local syntax symmetry or IR-level elegance when a `.seal` behavior is
+ still clear, finite, and can be translated reliably into a more awkward
+ PowerShell form.
- Keep the Rust core thin and concrete.
- Support only `env`, `symlink`, fixed-prefix `argv`, explicit `:wrapper`
resolution, direct `.seal` execution, and read-only `@internal`
@@ -142,11 +157,52 @@ Successful profile and wrapper paths are normalized absolute paths.
## 5. FAQ
-### Why keep the CLI surface small?
+### What defines the CLI surface?
-Because this repository is building explicit runtime glue, not a hidden
-orchestrator. New behavior should be added only when it fits the existing
-surface cleanly.
+This repository is building explicit runtime glue, not a hidden orchestrator.
+New behavior should be added only when it fits one of two shapes cleanly:
+
+- shared `bash` and PowerShell semantics that are worth making first-class in
+ `.seal`
+- an explicit atomic `@tool`
+
+`runseal` should not be treated as a grab-bag operations toolkit where every
+pain point becomes another command. Its value is methodological first:
+
+- decide what should be flow control in `.seal`
+- decide what should be an atomic `@tool`
+- decide what should be a visible repo or local artifact under `.runseal/` or
+ `.local/`
+- decide what should remain an external script because it carries the wrong
+ kind of complexity
+
+The concrete tools matter, but they are derived from that layering model rather
+than the other way around.
+
+This boundary comes from the actual problem `runseal` is trying to solve:
+clear operational workflows should not need to depend on heavyweight language
+runtimes or repository-local script stacks just to survive environment drift,
+cross-platform differences, and routine operator setup friction.
+
+The goal is not "be as small as possible". The goal is to absorb the
+right kind of complexity:
+
+- clear, finite, cross-platform operational flow control should fit in
+ `runseal`
+- shell-specific cleverness, open-ended scripting power, and accidental runtime
+ dependency sprawl should not
+
+That is why the product boundary is a shared shell subset plus explicit atomic
+tools, rather than a general scripting platform or a partial shell clone.
+
+The hard promise is behavioral equivalence across `bash` and PowerShell, not
+surface-level symmetry in generated code. Some worthwhile `.seal` semantics may
+compile into elegant `bash` and relatively ugly PowerShell. That tradeoff is
+acceptable when:
+
+- the `.seal` behavior is clear and finite
+- the translation is reliable and testable
+- the result still serves explicit cross-platform operations flow control
### When should behavior become Seal syntax?
@@ -156,14 +212,42 @@ worth making first-class.
### When should behavior become `@tool`?
When native CLI coverage is insufficient for an atomic, reusable operation and
-the result still fits the small explicit model.
+the result still fits the explicit atomic-tool model.
### When should logic stay outside runseal?
When the behavior cannot be described cleanly as shared shell-shape syntax or a
-small atomic tool, keep it in Python, Ruby, JavaScript, or another external
+clear atomic tool, keep it in Python, Ruby, JavaScript, or another external
script.
+### Should `.seal` wrappers build multi-line config or payload text inline?
+
+Usually no.
+
+For operations work, persistent or semi-persistent structured text should
+normally live as explicit repo material under `.runseal/` or `.local/`, not as
+inline heredoc-style wrapper content. That includes things like:
+
+- config templates
+- YAML or JSON fragments
+- kube-related files
+- long request bodies
+- other operator-facing text payloads
+
+The wrapper should usually do the smaller, clearer job:
+
+- validate preconditions
+- choose the right file or template
+- assemble paths and arguments
+- set environment for the invoked command
+- execute the operational flow
+
+This is an intentional product boundary. `runseal` is meant to reduce
+environment and runtime dependency complexity in operations workflows, not to
+turn `.seal` into a general inline text-construction language. If a multi-line
+artifact is important enough to exist, prefer making it a visible repo or local
+artifact first.
+
### Should `.seal` wrappers be treated as first-class runtime entrypoints?
Yes. Treat `.runseal/wrappers/*.seal` as first-class wrappers executed directly
diff --git a/Cargo.lock b/Cargo.lock
index a5514f0..818af29 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -100,6 +100,15 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "bstr"
version = "1.12.1"
@@ -191,12 +200,41 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "dirs"
version = "6.0.0"
@@ -321,6 +359,16 @@ dependencies = [
"slab",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1012,7 +1060,7 @@ dependencies = [
[[package]]
name = "runseal"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -1023,6 +1071,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
+ "sha2",
"shellexpand",
"tempfile",
"toml",
@@ -1165,6 +1214,17 @@ dependencies = [
"serde",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
[[package]]
name = "shellexpand"
version = "3.1.2"
@@ -1450,6 +1510,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1492,6 +1558,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "wait-timeout"
version = "0.2.1"
diff --git a/README.md b/README.md
index f0f7a26..ddf3d86 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,17 @@ runseal --profile ./runseal.toml bash -lc 'echo "$RUNSEAL_PROFILE_PATH"'
Use `runseal profile` without `@` to run an external command named `profile`.
+## Examples
+
+Repository-owned examples for canonical `.seal` and `@tool` shapes live under
+[docs/examples](./docs/examples/README.md).
+
+Start here when a wrapper shape feels "obvious in shell" but still needs the
+exact runseal form:
+
+- [Seal `case` / argv parser shapes](./docs/examples/seal/case.md)
+- [GitHub tool examples](./docs/examples/tools/github.md)
+
## Fit
Fits well:
@@ -272,22 +283,21 @@ round trips. For example, a wrapper can expose `:ssh --run