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