entwurf-release · git:20261015.c208d37 · 2026-10-15 · sha256 a583b8744ce274fb
entwurf-release git:20261015.c208d37A
Immutable. This exact content never changes and is served at /api/v1/blob/a583b8744ce274fb.
---
name: entwurf-release
description: "Operate entwurf SemVer releases through four explicit modes: land, prepare, make, and publish. Use for pre-version exact-SHA CI landing, CHANGELOG and package preparation, static and LIVE gates, prepared-HEAD CI, exact artifact acceptance, tag and GitHub release creation, repair-dist-tag publication, and post-publish registry proof. Each mode is a separate authority boundary. Triggers: release land, prepare-release, make-release, publish release, release cut, prerelease, repair release."
user_invocable: true
---
# entwurf-release
Repository: `~/repos/gh/entwurf`.
This skill is the shared release-operation SSOT that replaces the former
`.pi/prompts/prepare-release.md` and `.pi/prompts/make-release.md` files.
Claude Code discovers it natively under `.claude/skills/`; pi discovers the same
file through `.pi/settings.json` and its `"skills": ["../.claude/skills"]`
entry.
## Invocation
```text
# Claude Code - stable example
/entwurf-release land 0.12.8
/entwurf-release prepare 0.12.8
/entwurf-release make 0.12.8
/entwurf-release publish 0.12.8 /absolute/path/to/candidate.tgz latest
# pi - prerelease/repair example
/skill:entwurf-release land 0.12.8-repair.1
/skill:entwurf-release prepare 0.12.8-repair.1
/skill:entwurf-release make 0.12.8-repair.1
/skill:entwurf-release publish 0.12.8-repair.1 /absolute/path/to/candidate.tgz repair
```
Natural-language requests map to the same four modes.
- `land` pushes an already reviewed pre-version HEAD and waits for the required
exact-SHA CI jobs. It never edits, versions, tags, or publishes.
- `prepare` edits release records, runs deterministic and LIVE gates, and creates
the release-prep commit. It never pushes, tags, or publishes.
- `make` pushes the prepared HEAD, waits for exact-SHA CI, creates and accepts one
preserved candidate, then tags, stamps, and creates the GitHub release. It
never runs `npm publish`.
- `publish` publishes only the already accepted preserved candidate under an
explicitly supplied dist-tag and proves the registry-installed result.
The invocation authorizes only the named mode. `prepare` is not `land`
authorization. `make` is not `publish` authorization. If the mode, version, or a
mode-specific required argument is missing, ask for it and stop.
## Shared version contract
Accept a normal SemVer release or prerelease. Reject a leading `v`.
```bash
VERSION="<user argument>"
case "$VERSION" in
"") echo "ABORT: version required (for example 0.12.8 or 0.12.8-repair.0)"; exit 1 ;;
v*) echo "ABORT: drop the leading 'v'"; exit 1 ;;
esac
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
echo "ABORT: version must be SemVer, optionally with a prerelease suffix"
exit 1
fi
```
Valid examples: `0.12.8`, `0.12.8-repair`, and `0.12.8-repair.0`.
`npm version` remains the final package-version validator.
## Exact-SHA CI oracle
The shared helper is the only release instruction that classifies the required
GitHub Actions run:
```bash
CI_ORACLE=".claude/skills/entwurf-release/scripts/verify-exact-ci.sh"
bash "$CI_ORACLE" "$(git rev-parse HEAD)" wait
```
It selects only a push-triggered `ci.yml` run whose `headSha` is the supplied
full SHA, waits when requested, and requires these exact jobs to conclude
`success`:
- `check`
- `install-surface`
- `artifact-consumer`
- `macos-install-surface`
Use mode `verify` instead of `wait` when a prior run must already be complete.
Never replace this with a branch-level green badge or the newest unrelated run.
---
# LAND
`land` exists for a narrower release contract that requires a pre-version
implementation HEAD to receive its own CI run before release metadata changes.
It is not required for every ordinary release.
A `land <version>` invocation is explicit authorization for one ordinary push of
`main`. It is not authorization for version edits, tags, GitHub releases, or npm
publication.
## L0. Establish the landing boundary
1. Read `AGENTS.md`, `NEXT.md`, and `VERIFY.md` completely.
2. Read the `commit` skill because its push and post-push stamp rules remain in
force.
3. Confirm that the current narrower contract actually requires a pre-version
CI checkpoint. If it does not, stop and direct the operator to `prepare`.
4. Inspect and require a clean, non-diverged `main`:
```bash
git status --short --branch
git diff-index --quiet HEAD --
test "$(git branch --show-current)" = main
git fetch origin main
read -r BEHIND AHEAD < <(git rev-list --left-right --count origin/main...HEAD)
test "$BEHIND" = 0
test "$AHEAD" -gt 0
```
Confirm from the diff and log that HEAD contains only the reviewed landing set.
Do not absorb an unrelated local commit into a release push.
For a required pre-version checkpoint, the package must not already equal the
target version:
```bash
test "$(node -p "require('./package.json').version")" != "$VERSION"
```
## L1. Prove pushability and push main
```bash
SHA="$(git rev-parse HEAD)"
git push --dry-run origin main
git push origin main
test "$(git ls-remote origin refs/heads/main | cut -f1)" = "$SHA"
```
Never force and never bypass verification.
## L2. Stamp the pushed commit
Stamp only after the push succeeds, following the `commit` skill. If the stamp
fails, report the exact error and stop; do not write the agenda target by hand.
## L3. Require exact-SHA CI
```bash
CI_ORACLE=".claude/skills/entwurf-release/scripts/verify-exact-ci.sh"
bash "$CI_ORACLE" "$SHA" wait
```
The oracle judges the newest PUSH run at that exact SHA: the workflow conclusion
and the FOUR required job conclusions (`check`, `install-surface`,
`artifact-consumer`, `macos-install-surface`). `macos-install-surface` joined the
required set in 0.20.0 on its first green; a red macOS runner blocks a cut exactly
like any other required job.
The qualification body is not a release axis (#144): it runs only on the weekly
schedule or a manual `qualify=true` dispatch, as a drift diagnostic, and this
oracle neither reads nor reports it. A dispatch run is never the witness. If the
oracle finds no push run at the SHA (a `[skip ci]` commit has none), get a push run
whose head is that SHA, or re-run the existing push run with `gh run rerun`; do
not assume that pushing a SHA the remote branch already points at creates a run.
Report the SHA, workflow URL, the run event and all FOUR required job conclusions
(`check`, `install-surface`, `artifact-consumer`, `macos-install-surface`). End with:
```text
Landing checkpoint complete. Ready for /entwurf-release prepare <version>.
```
---
# PREPARE
`prepare` edits, verifies, and commits. It does not push, tag, create a GitHub
release, stamp a release, notify, or publish.
## P0. Establish the release boundary
1. Read `AGENTS.md`, `NEXT.md`, and `VERIFY.md` completely. A narrower current
release contract in those files overrides a generic instruction in this
skill.
2. Read the `commit` skill before creating any commit.
3. Inspect the current state:
```bash
git status --short --branch
git diff --check
```
Do not mix pre-existing implementation or review fixes into the release-prep
commit. If a completed, clearly scoped fix is present and GLG has approved its
commit, close it as a separate atomic commit first. If scope is ambiguous or
unrelated, stop and ask.
If the current contract requires a pre-version landing checkpoint, verify it
before making any edit:
```bash
git fetch origin main
SHA="$(git rev-parse HEAD)"
test "$(git rev-parse origin/main)" = "$SHA"
bash .claude/skills/entwurf-release/scripts/verify-exact-ci.sh "$SHA" verify
```
A missing landing run is not a prepare failure to work around. Stop with the
exact next command: `/entwurf-release land <version>`.
Prepare may commit only release-prep files such as `CHANGELOG.md`,
`package.json`, `pnpm-lock.yaml`, and an evidence handoff explicitly required by
the current release contract.
Forbidden in prepare mode:
- pushes
- tags
- GitHub releases
- release agenda stamps
- notifications
- npm publication
- final candidate creation for a contract that requires post-commit CI first
## P1. Audit changes since the last release
```bash
# The baseline is the newest CORE release tag. A Herdr plugin snapshot tag
# (tag-release CalVer, `vYYYY.M.D-herdr.<runtime>`) version-sorts above the
# current v0.x core tags but ships no core version, so exactly that shape is
# excluded here.
LAST_TAG=$(git tag --sort=-version:refname | grep -vE '^v[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}-herdr\.' | head -1)
printf 'baseline=%s\n' "$LAST_TAG"
git log "${LAST_TAG}..HEAD" --oneline
```
Compare the commit range and closed `NEXT.md` work with the existing
`CHANGELOG.md` `## Unreleased` section. Record only verified changes. Do not
rewrite historical release sections.
## P2. Promote the release section
Use the current KST date and transform the top of the changelog into this shape:
```text
## Unreleased
## <VERSION> - YYYY-MM-DD
```
Keep a fresh empty `## Unreleased` section above the promoted release body.
Preserve the repository's existing heading punctuation if it uses an em dash.
Release-gate paths and summaries may live in the release section or in an
explicit durable operator handoff, following the repository's current
convention. The paths and actual MUST/BEHAVIOR counts must not be lost.
## P3. Update package version and lockfile
```bash
npm version "$VERSION" --no-git-tag-version
pnpm install --lockfile-only
```
Inspect the resulting diff. Do not manufacture a lockfile change when the
resolver produced none.
## P4. Run the deterministic floor
```bash
pnpm run check:full
```
The FULL tier is the candidate floor; the everyday `pnpm check` core alone is
never release evidence. Do not summarize the aggregate as a fixed number of
gates. The current `package.json` check:* scripts are the SSOT. If any check
fails, stop at that axis, fix it, and rerun the complete aggregate.
The check chains carry only the qualification HEAD (`check-gate-manifests`) and
deliberately exclude the mutant-executing body `check-gate-qualification`. The
whole body is not a release axis (#144): no push runs it, M2 does not read it, and
no cut owes a local one; it runs only on the weekly schedule or a manual
`qualify=true` dispatch, as a drift diagnostic. The LIVE release gate (P5) runs no
local body (#132), and development runs none either (#133): a lane that changed a
gate/mutant/matrix runs its affected exact-argv groups as PARTIAL receipts
(`./run.sh check-gate-qualification --group '<argv JSON>' --receipt <abs path>`).
A receipt measures one group on its own candidate; it never stands for the release.
The release section therefore says, in its own line, `Kill-power re-measured at
this shipped SHA: none (all <N> committed claims not re-proved, #144).` Only a
measurement actually taken at this SHA (a manual dispatch run, or a PARTIAL on
this SHA's candidate) gets a separate `Measured at this SHA (manual): ...` line with
its run or receipt. An earlier-candidate PARTIAL is listed one receipt per line as
`Earlier-candidate PARTIAL (operator-attested, not this SHA): head <sha12> surface
<sha12> -- <group> <claims>/<group size> receipt <sha12>`, never summed into a k/N
for the release, and omitted when there is none. Never write "covered",
"qualified" or "N/N" for the release.
Pushing the release tag creates no run, so the exact-SHA oracle never reads a
tag run. It reads the newest push run at that commit.
## P5. Run the LIVE release gate from fresh scratch
Use the `tmux` skill because this command is long-running. Preserve the scratch
directory and complete log.
Three `ENTWURF_CODEX_*` values are the operator-supplied prerequisite of the
`smoke-codex-fresh-live` MUST step. The gate inherits them and never infers a
server or a model, so YOU state each one before the run. Do not derive the pid
from a process search: a host can hold more than one app-server, picking the
first match is the inference this step exists to refuse, and the one that counts
is the app-server you started in a DIFFERENT tmux session from this gate's.
Decide all three, record them beside the receipt, then export them:
```bash
CODEX_APP_SERVER_PID= # pid of the app-server YOU started (fill in, no process search)
CODEX_FRESH_MODEL= # Codex-side model tier this cut is accepted on
CODEX_FRESH_PI_MODEL= # pi-side model tier of its callback sibling
```
```bash
: "${CODEX_APP_SERVER_PID:?state the app-server pid you started; never search for it}"
: "${CODEX_FRESH_MODEL:?state the Codex-side model tier this cut is accepted on}"
: "${CODEX_FRESH_PI_MODEL:?state the pi-side model tier of the callback sibling}"
SCRATCH=$(mktemp -d "/tmp/entwurf-release-gate-${VERSION}.XXXXXX")
LOG="$SCRATCH/release-gate.log"
set -o pipefail
LIVE=1 \
ENTWURF_CODEX_APP_SERVER_PID="$CODEX_APP_SERVER_PID" \
ENTWURF_CODEX_FRESH_MODEL="$CODEX_FRESH_MODEL" \
ENTWURF_CODEX_FRESH_PI_MODEL="$CODEX_FRESH_PI_MODEL" \
./run.sh release-gate "$SCRATCH" --cut 2>&1 | tee "$LOG"
```
The three `:` guards refuse an unset or empty value before the gate starts, so a
forgotten export costs one line instead of a LIVE run. Omit one anyway and that
step reports a protocol SKIP naming what was missing, which `--cut` then reads as
red (`cut: BLOCKED (MUST SKIP)`). That is a prerequisite to supply, not a defect
to diagnose: run the gate again with the triple rather than explaining the skip in
the release record. A *present but wrong* value (a dead pid, a process that is not
a Codex app-server, an app-server sharing this gate's session) stays a FAIL,
because the operator answered and the answer is broken.
The release gate has two tiers:
- `MUST` is release-blocking and owns the exit code. `FAIL` must be zero, and
`--cut` enforces the other half: any MUST `SKIP` makes the run red, so a
release run can no longer hide required LIVE work behind a skip. Each step is
invoked and reports its own outcome (exit 0 PASS / 97 SKIP / else FAIL); a
`[entwurf:skip]` line names the prerequisite that was missing. Drop `--cut`
only for an unattended diagnostic pass, which is not acceptance.
- `BEHAVIOR` is advisory model-in-loop evidence. A failure does not block the
release, but its PASS/FAIL counts and artifact path must be recorded.
Do not expect a fixed PASS count. Record actual output. Do not waive a MUST
failure without diagnosing and explicitly classifying the failing axis. Do not
hide a BEHAVIOR failure.
**Freeze HEAD, index, and working tree while the gate is running.** The sequential P5 steps consume
the candidate checkout directly, so a working-tree or index edit can make one release-gate verdict
describe multiple candidates. Queue every edit and commit
request (including one from GLG) until the gate reports its verdict; any movement voids that P5 receipt.
**When the gate reports its verdict, run P9 before doing anything else.** This is the
heaviest resource event in the whole release (the full floor and every LIVE smoke on
one host), and it is where residue is BOTH largest and freshest,
so the prefix a leak carries still names the gate that produced it. Do it here, not only
at P8: by P8 the trail is cold. Report the P9 numbers together with the gate's own
verdict; a large residue at this point is a finding about a gate, not housekeeping.
## P6. Apply release-specific pre-commit acceptance
`NEXT.md` and `VERIFY.md` may require gates beyond `pnpm run check:full` and the LIVE
release gate. Apply every requirement that belongs before the release-prep
commit.
For #51-style repair releases, do not create the final candidate here. The exact
candidate must be created from the clean prepared HEAD only after that exact SHA
has been pushed and all FOUR required CI jobs are green. `make` owns that post-CI
acceptance. A checkout pack-once result is not release-artifact evidence.
Never claim an unrun gate as passed.
## P7. Create the release-prep commit
Stage only release-prep files. Never pull preceding implementation changes into
this commit.
```bash
git status --short
git diff --check
git diff --cached --check
git commit -m "chore(release): prepare v${VERSION}"
```
Do not bypass hooks. A commit request does not authorize a push.
## P8. Final preparation check
```bash
test "$(node -p "require('./package.json').version")" = "$VERSION"
grep -qE "^## ${VERSION}([[:space:]]|$)" CHANGELOG.md
git diff-index --quiet HEAD --
```
Report:
- prepared version and commit SHA
- `pnpm run check:full` result
- release-gate scratch, log, and artifact paths
- actual `MUST: PASS=n FAIL=0 SKIP=n` (no qualification step: the body is not a release axis, #144)
- actual `BEHAVIOR: PASS=n FAIL=n`
- release-specific work deliberately deferred to `make`
- clean-tree result
- host residue before and after P9, by prefix
## P9. Reclaim the host
Run this **twice**: once the moment P5's release gate reports its verdict (freshest
trail, largest residue), and again here at the end of `prepare`. `publish` calls it a
third time after U3. It is the same procedure each time.
The floor and the LIVE gates are the heaviest resource consumers this repo has, and
what they leave behind is invisible until a disk fills. Measure it on the host that
just ran them, and reclaim only what nothing is using.
A gate that ends RED ends by THROWING, so any teardown written as its last statement is
skipped. That is how `oracle` reached ~9,200 stale roots and 3.8G under `/tmp` before
2026-09-08. Gates now register their roots with `scripts/lib/reclaim-on-exit.ts`, so this
step should find LITTLE. A large number here is not routine housekeeping: it names a gate
that still reclaims on its last line, or a fixture child with no parent-death watchdog.
Report the prefix, do not just delete it.
**Census prefix-blind; delete by a prefix this repo can prove it owns.** The first
version of this step looked only at `entwurf-*` and reported a clean host while 5,329
roots sat under `psa-*`, `acp-*` and `omp-*`: a census that only counts what it already
suspects will always confirm the fix it was written for. So the report below counts every
directory this operator owns under `/tmp`, while the DELETE list is derived from the
gates' own `mkdtempSync` prefixes at run time. A hand-kept name list would rot on the
next gate; a prefix-blind `rm` would take `nix-shell`, editor and toolchain state that is
not ours.
Report (read-only, prefix-blind, and never counts a root some live process is using):
```bash
mapfile -t IN_USE < <({ ps -eo args --no-headers | grep -oE '/tmp/[A-Za-z0-9._-]+';
for l in /proc/[0-9]*/cwd; do readlink "$l" 2>/dev/null; done; } |
sed -E 's#(/tmp/[^/]+).*#\1#' | sort -u)
census() { find /tmp -maxdepth 1 -user "$(id -un)" -type d "$@" 2>/dev/null; }
census | sed -E 's#^(/tmp/[^/]*?)[.-][A-Za-z0-9]{6}$#\1#' | sort | uniq -c | sort -rn | head -20
ps -eo pid,ppid,args --no-headers | awk '$2==1' | grep -cF '/tmp/'
```
The last line counts REPARENTED processes holding a `/tmp` path. A non-zero count is a
finding, not debris: name the prefix to GLG before anything is killed.
Reclaim (only prefixes the checkout itself mints, only roots older than the run, only
roots nothing holds):
```bash
mapfile -t OWNED < <(grep -rhoE 'mkdtempSync\(\s*(path\.)?join\([^"]*tmpdir\(\)[^"]*"[^"]+"' scripts test |
grep -oE '"[^"]+"$' | tr -d '"' | sort -u)
RESIDUE=$(mktemp -t entwurf-residue.XXXXXX)
while IFS= read -r d; do
for u in "${IN_USE[@]:-}"; do [ "$d" = "$u" ] && continue 2; done
b=$(basename "$d")
for o in "${OWNED[@]}"; do case "$b" in "$o"*) printf '%s\n' "$d"; break ;; esac; done
done < <(census -mmin +60 | sort) > "$RESIDUE"
printf 'reclaimable roots: %d\n' "$(wc -l < "$RESIDUE")"
du -sch --files0-from=<(tr '\n' '\0' < "$RESIDUE") 2>/dev/null | tail -1
sed -E 's#^(/tmp/[^/]*?)[.-][A-Za-z0-9]{6}$#\1#' "$RESIDUE" | sort | uniq -c | sort -rn | head -20
xargs -r -a "$RESIDUE" rm -rf -- # read the list above FIRST; it is the whole delete set
rm -f "$RESIDUE"
```
Four fences make that safe and none is optional: `-user` keeps it to this operator,
`-mmin +60` keeps it off a concurrently running gate, the in-use filter (argv AND
`/proc/*/cwd`) keeps it off a root some process still holds, and `OWNED` keeps it to
prefixes this checkout provably mints. Never widen it to a bare `rm -rf /tmp/*` or to
the prefix-blind census list, and never kill reparented processes as a batch without
naming their prefix first.
End with both harness forms:
```text
Ready for /entwurf-release make <version>.
Ready for /skill:entwurf-release make <version>.
```
---
# MAKE
`make` operates only on an already prepared clean HEAD. It pushes that HEAD,
requires exact-SHA CI, creates and accepts the final candidate, then tags,
stamps, creates the GitHub release, and notifies. It does not edit release files
or run `npm publish`.
A `make <version>` invocation is explicit authorization for ordinary main and
tag pushes plus the GitHub release sequence. Read both the `commit` and
`tag-release` skills before proceeding so their push, safety, and stamp rules
remain active.
## M0. Preflight
Abort on the first failed check.
### Clean tree, version, changelog, gate evidence, and tag absence
```bash
git diff-index --quiet HEAD --
test "$(git branch --show-current)" = main
test -z "$(git tag -l "v${VERSION}")"
test -z "$(git ls-remote --tags origin "v${VERSION}")"
grep -qE "^## ${VERSION}([[:space:]]|$)" CHANGELOG.md
test "$(node -p "require('./package.json').version")" = "$VERSION"
pnpm run check:full
```
`pnpm run check:full` here is the full deterministic floor without
qualification; do not add a manual `check-gate-qualification` run in preflight.
The whole body is not a release axis (#144).
Confirm that a fresh release-gate scratch/log path and its actual MUST/BEHAVIOR
summary are present in the changelog or durable operator handoff, and that the
release section carries the kill-power line P4 describes (no "covered",
"qualified" or "N/N"). Do not proceed
when MUST has a failure, evidence is missing, or `NEXT.md` names an unresolved
pre-release blocker.
### GitHub identity and target
```bash
gh auth status
REMOTE=$(git remote get-url origin)
EXPECTED_REPO=$(printf '%s\n' "$REMOTE" | sed -E 's#^git@github(-[a-z]+)?\.com:##; s#^https://github.com/##; s#\.git$##')
GH_REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
GH_PERM=$(gh repo view --json viewerPermission --jq .viewerPermission)
test "$GH_REPO" = "$EXPECTED_REPO"
case "$GH_PERM" in
ADMIN|MAINTAIN|WRITE) ;;
*) echo "ABORT: GitHub permission is $GH_PERM; WRITE or higher is required"; exit 1 ;;
esac
```
### Non-divergence and push dry-runs
```bash
git fetch origin main
read -r BEHIND AHEAD < <(git rev-list --left-right --count origin/main...HEAD)
test "$BEHIND" = 0
git push --dry-run origin main
git push --dry-run origin "HEAD:refs/tags/v${VERSION}"
```
## M1. Push the prepared HEAD and stamp that push
```bash
SHA="$(git rev-parse HEAD)"
REMOTE_SHA="$(git rev-parse origin/main)"
PUSHED_MAIN=0
if [ "$REMOTE_SHA" != "$SHA" ]; then
test "$AHEAD" -gt 0
git push origin main
test "$(git ls-remote origin refs/heads/main | cut -f1)" = "$SHA"
PUSHED_MAIN=1
fi
printf 'prepared-sha=%s pushed-main=%s\n' "$SHA" "$PUSHED_MAIN"
```
If `PUSHED_MAIN=1`, immediately stamp the pushed commit according to the
`commit` skill before waiting on CI. If main already pointed at this SHA, do not
create a duplicate commit stamp. A stamp failure stops the mode even though the
push has already happened; report that half-complete state exactly.
## M2. Require exact-SHA CI
```bash
bash .claude/skills/entwurf-release/scripts/verify-exact-ci.sh "$SHA" wait
```
A CI failure stops make before candidate creation or tagging. This is the same
oracle L3 runs: the newest push run at the SHA and its four required jobs. It
reads no qualification body, and a dispatch run is never its witness (#144). Do
not create a candidate or a tag while that run is not green.
## M3. Create and accept one preserved exact candidate
Create the candidate only after exact-SHA CI is green. Keep the directory and
log; do not clean them automatically.
```bash
ARTIFACT_DIR=$(mktemp -d "/tmp/entwurf-release-candidate-${VERSION}.XXXXXX")
LOG="$ARTIFACT_DIR/acceptance.log"
bash scripts/with-dist-lock.sh npm pack --dry-run=false --pack-destination "$ARTIFACT_DIR"
CANDIDATE=$(realpath "$ARTIFACT_DIR/junghanacs-entwurf-${VERSION}.tgz")
test -f "$CANDIDATE"
SHA256_BEFORE=$(sha256sum "$CANDIDATE" | cut -d' ' -f1)
set -o pipefail
ENTWURF_REQUIRE_DOCKER=1 ENTWURF_CANDIDATE_TGZ="$CANDIDATE" \
./run.sh check-install-container 2>&1 | tee "$LOG"
SHA256_AFTER=$(sha256sum "$CANDIDATE" | cut -d' ' -f1)
test "$SHA256_AFTER" = "$SHA256_BEFORE"
grep -F "candidate mode: caller-preserved exact artifact (no repack)" "$LOG"
grep -F "candidate canonical-path=$CANDIDATE" "$LOG"
grep -F "artifact sha256=$SHA256_BEFORE" "$LOG"
grep -F "repoDigest=" "$LOG"
```
The handoff must preserve:
- candidate canonical path
- candidate SHA-256
- acceptance log path
- Node image ID
- repository digest
- exact prepared commit SHA
The accepted file is now the only file eligible for `publish`. Never repack it.
## M4. Recheck immutability, tag, and push the tag
```bash
test "$(git rev-parse HEAD)" = "$SHA"
git diff-index --quiet HEAD --
test "$(sha256sum "$CANDIDATE" | cut -d' ' -f1)" = "$SHA256_BEFORE"
git tag "v${VERSION}" "$SHA"
git push origin "v${VERSION}"
test "$(git ls-remote origin "refs/tags/v${VERSION}" | cut -f1)" = "$SHA"
```
Never force and never bypass verification. If tag push fails, stop before the
release stamp, GitHub release, or notification.
## M5. Stamp the release
```bash
REMOTE=$(git remote get-url origin)
REPO_URL=$(echo "$REMOTE" | sed -E 's|git@github(-[a-z]+)?\.com:|https://github.com/|;s|\.git$||')
REPO_NAME=$(basename "$REMOTE" .git)
REPO_TAG=$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[-.]//g')
STAMP="$HOME/.pi/agent/skills/pi-skills/agenda/scripts/agenda-stamp.sh"
[ -x "$STAMP" ] || STAMP="$HOME/.claude/skills/agenda/scripts/agenda-stamp.sh"
"$STAMP" \
"${REPO_NAME}: release v${VERSION} [[${REPO_URL}/releases/tag/v${VERSION}][v${VERSION}]]" \
"pi:release:${REPO_TAG}"
```
If the stamp fails, report the exact command and error and stop. Do not invent a
manual fallback on the agenda target.
## M6. Create and verify the GitHub release
`CHANGELOG.md` stays hard-wrapped: it is a source file, and GitHub's file view
already joins single newlines inside a paragraph. A release body does not. It
renders every newline as a line break, so a hard-wrapped section reads as ragged
short lines. The extraction therefore joins each paragraph and list item into
one line. Fenced code blocks stay byte-for-byte, and the script refuses to write
notes whose text differs from the section by anything other than whitespace.
```bash
NOTES_FILE="/tmp/release-notes-v${VERSION}.md"
VERSION="$VERSION" python3 - <<'PY'
import os
import re
from pathlib import Path
version = os.environ["VERSION"]
lines = Path("CHANGELOG.md").read_text().splitlines()
section = []
inside = False
for line in lines:
if line.startswith(f"## {version} ") or line == f"## {version}":
inside = True
continue
if inside and line.startswith("## "):
break
if inside:
section.append(line)
# Soft-wrap: join continuation lines; a heading, list item, quote or table row
# starts a new block; blank lines and fenced code are kept as they are.
block_start = re.compile(r"^\s*(#{1,6}\s|[-*+]\s|\d+\.\s|>|\|)")
out, cur, fence = [], None, False
for line in section:
if line.lstrip().startswith("```"):
if cur is not None:
out.append(cur)
cur = None
out.append(line)
fence = not fence
continue
if fence:
out.append(line)
continue
if not line.strip():
if cur is not None:
out.append(cur)
cur = None
out.append(line)
continue
if cur is None or block_start.match(line):
if cur is not None:
out.append(cur)
cur = line.rstrip()
continue
cur += " " + line.strip()
if cur is not None:
out.append(cur)
def squash(text):
return re.sub(r"\s+", " ", text).strip()
notes = "\n".join(out).strip() + "\n"
if squash(notes) != squash("\n".join(section)):
raise SystemExit("ABORT: soft-wrap changed release-note text beyond whitespace")
Path(f"/tmp/release-notes-v{version}.md").write_text(notes)
PY
test -s "$NOTES_FILE"
gh release create "v${VERSION}" --title "v${VERSION}" --notes-file "$NOTES_FILE"
gh release view "v${VERSION}" --json tagName,name,url
rm -f "$NOTES_FILE"
```
Do not notify until `gh release view` verifies the expected tag.
## M7. Notify
Run only after the GitHub release is verified.
```bash
source ~/.env.local
gog chat messages send "$GOG_CHAT_SPACE_ID" \
--account "$GOG_CHAT_ACCOUNT" \
--text "entwurf v${VERSION} released
${REPO_URL}/releases/tag/v${VERSION}"
```
A notification failure does not undo an existing release, but it must be
reported exactly.
Report the release URL plus the preserved candidate path, SHA-256, acceptance
log, image ID, and repository digest. End with the exact publish command but do
not execute it:
```text
/entwurf-release publish <version> <absolute-candidate.tgz> <dist-tag>
```
---
# PUBLISH
`publish` is a separate authority boundary. It never creates a candidate. It
publishes only the exact file accepted by `make`.
A publish invocation requires:
1. version
2. absolute candidate path
3. explicit dist-tag
For example:
```text
/entwurf-release publish 0.12.8-repair.1 /tmp/entwurf-release-candidate-0.12.8-repair.1.X/junghanacs-entwurf-0.12.8-repair.1.tgz repair
```
## U0. Verify release and candidate identity
```bash
CANDIDATE="<absolute candidate argument>"
DIST_TAG="<dist-tag argument>"
case "$CANDIDATE" in /*) ;; *) echo "ABORT: candidate path must be absolute"; exit 1 ;; esac
case "$DIST_TAG" in ""|*[!0-9A-Za-z._-]*) echo "ABORT: invalid or missing dist-tag"; exit 1 ;; esac
# Every publication must prove it did not move the lane it does not own. Capture
# both dist-tags before U1 and assert the untouched lane against its own captured
# value; a version literal kept by hand in this file rots at the next cut. An
# absent tag reads as the empty string, and preserving absence is a valid result.
PRE_PUBLISH_LATEST=$(npm view @junghanacs/entwurf dist-tags.latest)
PRE_PUBLISH_REPAIR=$(npm view @junghanacs/entwurf dist-tags.repair)
export PRE_PUBLISH_LATEST PRE_PUBLISH_REPAIR
CANDIDATE=$(realpath "$CANDIDATE")
test -f "$CANDIDATE"
test "$(node -p "require('./package.json').version")" = "$VERSION"
git diff-index --quiet HEAD --
test "$(git rev-parse "v${VERSION}")" = "$(git rev-parse HEAD)"
gh release view "v${VERSION}" --json tagName,name,url
```
Read package identity from the tarball and require an exact match:
```bash
META=$(tar -xOf "$CANDIDATE" package/package.json | node -e '
let s=""; process.stdin.setEncoding("utf8");
process.stdin.on("data", d => s += d);
process.stdin.on("end", () => {
const p=JSON.parse(s); process.stdout.write(`${p.name}\t${p.version}`);
});')
IFS=$'\t' read -r NAME CANDIDATE_VERSION <<< "$META"
test "$NAME" = "@junghanacs/entwurf"
test "$CANDIDATE_VERSION" = "$VERSION"
```
Require the sibling acceptance log produced by `make` and bind it to the same
canonical path and digest:
```bash
ACCEPTANCE_LOG="$(dirname "$CANDIDATE")/acceptance.log"
test -s "$ACCEPTANCE_LOG"
CANDIDATE_SHA256=$(sha256sum "$CANDIDATE" | cut -d' ' -f1)
grep -F "candidate mode: caller-preserved exact artifact (no repack)" "$ACCEPTANCE_LOG"
grep -F "candidate canonical-path=$CANDIDATE" "$ACCEPTANCE_LOG"
grep -F "artifact sha256=$CANDIDATE_SHA256" "$ACCEPTANCE_LOG"
grep -F "repoDigest=" "$ACCEPTANCE_LOG"
```
If any evidence is missing, stop. Do not repack or regenerate the candidate.
## U1. Publish the accepted bytes
```bash
npm publish "$CANDIDATE" --tag "$DIST_TAG"
test "$(sha256sum "$CANDIDATE" | cut -d' ' -f1)" = "$CANDIDATE_SHA256"
```
## U2. Verify dist-tags
Apply the lane-specific dist-tag contract. The published lane must equal this
version, and the lane this publication did not touch must still hold the exact
value captured in U0. Stable publication moves `latest` and preserves the
accepted repair line; repair publication moves `repair` and preserves whatever
stable version was current immediately before U1. Read both from the same
post-publish registry snapshot, and never assert a hand-kept literal.
```bash
DIST_TAGS=$(npm view @junghanacs/entwurf dist-tags --json)
case "$DIST_TAG" in
latest) PRESERVED_TAG=repair; PRESERVED="${PRE_PUBLISH_REPAIR?capture it in U0 before U1}" ;;
repair) PRESERVED_TAG=latest; PRESERVED="${PRE_PUBLISH_LATEST?capture it in U0 before U1}" ;;
*) echo "ABORT: release policy has no preservation contract for dist-tag $DIST_TAG" >&2; exit 1 ;;
esac
DIST_TAGS="$DIST_TAGS" VERSION="$VERSION" DIST_TAG="$DIST_TAG" \
PRESERVED_TAG="$PRESERVED_TAG" PRESERVED="$PRESERVED" node - <<'NODE'
const tags = JSON.parse(process.env.DIST_TAGS);
const version = process.env.VERSION;
const distTag = process.env.DIST_TAG;
const preservedTag = process.env.PRESERVED_TAG;
const preserved = process.env.PRESERVED;
const seen = (name) => tags[name] ?? "";
const shown = (name) => seen(name) || "(absent)";
if (seen(distTag) !== version) {
throw new Error(`dist-tag ${distTag}=${shown(distTag)}, expected ${version}`);
}
if (seen(preservedTag) !== preserved) {
throw new Error(`dist-tag ${preservedTag}=${shown(preservedTag)}, expected preserved ${preserved || "(absent)"}`);
}
console.log(`registry ${distTag} publication: ${distTag}=${version} ${preservedTag}=${shown(preservedTag)}`);
NODE
```
## U3. Prove the registry-installed package
Sandbox HOME and every writable XDG root. Prove the package came from the
registry, not the checkout or preserved tarball.
```bash
TMP_ROOT=$(mktemp -d -t entwurf-registry-smoke.XXXXXX)
mkdir -p "$TMP_ROOT/home" "$TMP_ROOT/data" "$TMP_ROOT/state" "$TMP_ROOT/cache" "$TMP_ROOT/agent"
HOME="$TMP_ROOT/home" \
XDG_DATA_HOME="$TMP_ROOT/data" \
XDG_STATE_HOME="$TMP_ROOT/state" \
XDG_CACHE_HOME="$TMP_ROOT/cache" \
PI_CODING_AGENT_DIR="$TMP_ROOT/agent" \
pi install "npm:@junghanacs/entwurf@${VERSION}"
printf '%s\n' "{ \"packages\": [\"npm:@junghanacs/entwurf@${VERSION}\"] }" > "$TMP_ROOT/agent/settings.json"
BRIDGE=$(HOME="$TMP_ROOT/home" XDG_DATA_HOME="$TMP_ROOT/data" XDG_STATE_HOME="$TMP_ROOT/state" XDG_CACHE_HOME="$TMP_ROOT/cache" PI_CODING_AGENT_DIR="$TMP_ROOT/agent" node --experimental-strip-types scripts/resolve-acp-bridge.ts)
test "$BRIDGE" = "$TMP_ROOT/agent/npm/node_modules/@junghanacs/entwurf"
HOME="$TMP_ROOT/home" \
XDG_DATA_HOME="$TMP_ROOT/data" \
XDG_STATE_HOME="$TMP_ROOT/state" \
XDG_CACHE_HOME="$TMP_ROOT/cache" \
PI_CODING_AGENT_DIR="$TMP_ROOT/agent" \
pi --no-extensions -e "$BRIDGE" --list-models entwurf
rm -rf "$TMP_ROOT"
```
The output must include `entwurf` and the curated Claude anchors, with no
`Unknown provider` or `No models matching` error. A failed registry smoke is a
stop-and-classify event; do not notify downstream consumers.
Then run **P9** again on this host: `publish` runs a registry install and a pi
session of its own, and the release is not finished while its leftovers are still
on the disk.
For the current #51 contract, the final Linux recovery proof remains:
1. Install the approved package on the maintainer and target host.
2. Run installed `entwurf install-meta-bridge`.
3. Restart every already-open Claude Code session.
4. Open a new session with its live MCP child.
5. Require installed `entwurf doctor-meta-bridge` to pass.
## Recovery table
| State | Recovery |
|---|---|
| Pre-version main push succeeded; CI failed | Fix in a new commit; rerun `land` for the new SHA. |
| Prepared main push succeeded; CI failed | Fix in a new commit; do not tag or create a candidate. |
| Exact candidate acceptance failed | Preserve candidate and log; diagnose; do not repack around the failure. |
| Local tag exists; tag push failed | Fix the cause and retry the tag push. |
| Tag is pushed; GitHub release is absent | Resume at M5/M6; do not move the tag. |
| GitHub release exists; notification failed | Resume at M7 only. |
| Wrong local tag; not pushed | Delete the local tag and rerun preflight. |
| Wrong pushed tag | Do not force; report to GLG. |
| npm publish succeeded; registry smoke failed | Stop and classify; do not notify downstream consumers. |
| P9 reports a large residue or any reparented `/tmp/entwurf-*` process | Name the prefix and its owning gate to GLG; reclaim the reported list only. A gate reclaiming on its last line is a defect to fix, not to sweep every release. |