mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 19:44:48 +01:00
Merge remote-tracking branch 'origin/main' into garrytan/gsta137-pregate-removed-paths
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
3a953f4bb7
30 files changed
+555
-140
No files matched your search
@@ -11,7 +11,7 @@ name: Main backstop gate
|
||||
# The verdict is published on the commit as the main-backstop check run
|
||||
# (success when a green train covers the tree or the backstop gated it green,
|
||||
# neutral when the push was superseded or deferred, failure when it froze the
|
||||
# queue or could not finish); the weekly release reads it (docs/releases.md).
|
||||
# queue or could not finish); the stable release reads it (docs/releases.md).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
bun --no-env-file --config=/dev/null --no-install run scripts/train-backstop.ts run --repo "$GITHUB_REPOSITORY" --branch "$GITHUB_REF_NAME" --sha "$GITHUB_SHA" \
|
||||
--summary "$GITHUB_STEP_SUMMARY" --outputs "$GITHUB_OUTPUT"
|
||||
|
||||
# The per-commit verdict the weekly release reads (scripts/release-cut.ts).
|
||||
# The per-commit verdict the stable release reads (scripts/release-cut.ts).
|
||||
# Its own job with checks: write and no repository code (the same split as
|
||||
# evals.yml's train-check): only the action word the gate step recorded.
|
||||
backstop-check:
|
||||
|
||||
@@ -6,9 +6,12 @@ name: Stable release
|
||||
# weekly-release.yml name so its run history and every `gh workflow run`
|
||||
# reference stay intact). stable moves at most once per 72 hours: a cut inside
|
||||
# that floor is refused with REFUSED_FLOOR and NEXT_ALLOWED; a scheduled run
|
||||
# gets 3 hours of grace for cron start jitter, a hand-run cut none. The only
|
||||
# exception is a security cut: the security input with a ref, naming a GHSA,
|
||||
# CVE or #<n> the script ties to the released commits. Run it by hand with a
|
||||
# gets 3 hours of grace for cron start jitter, a hand-run cut none. Two
|
||||
# exceptions pass it, one per cut and each with a ref: a security cut (the
|
||||
# security input, naming a GHSA, CVE or #<n> the script ties to the released
|
||||
# commits) and a regression cut (the regression input, naming a
|
||||
# regression-labelled #<n> and the earlier stable release v<X> the behavior
|
||||
# worked in). Run it by hand with a
|
||||
# ref for a hotfix, or with dry_run to see the plan and change nothing. A pull request that touches
|
||||
# the release tooling runs the dry run against main with a read-only token,
|
||||
# which is the only pre-merge exercise of the real workflow. The candidate's
|
||||
@@ -35,6 +38,10 @@ on:
|
||||
description: 'Security cut (needs ref): the reason, naming the GHSA-…, CVE-… or #<n> it fixes; passes the 72-hour floor once verified and lands in the release notes'
|
||||
type: string
|
||||
default: ''
|
||||
regression:
|
||||
description: 'Regression cut (needs ref): the reason, naming the regression-labelled #<n> it fixes and the earlier stable release v<X> it worked in; passes the 72-hour floor once verified and lands in the release notes'
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/weekly-release.yml'
|
||||
@@ -59,7 +66,7 @@ jobs:
|
||||
contents: write
|
||||
# Reads the candidate's check runs.
|
||||
checks: read
|
||||
# A security cut reads the security label on the #<n> its reason names.
|
||||
# A security or regression cut reads the label on the #<n> its reason names.
|
||||
issues: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
@@ -88,6 +95,7 @@ jobs:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }}
|
||||
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
SECURITY_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.security || '' }}
|
||||
REGRESSION_REASON: ${{ github.event_name == 'workflow_dispatch' && inputs.regression || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$RELEASE_REF" ] && ! [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
@@ -98,10 +106,15 @@ jobs:
|
||||
echo "::error::a security cut needs ref: the full SHA of the fix commit on main"
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$REGRESSION_REASON" ] && [ -z "$RELEASE_REF" ]; then
|
||||
echo "::error::a regression cut needs ref: the full SHA of the fix commit on main"
|
||||
exit 1
|
||||
fi
|
||||
args=(--repo "$GITHUB_REPOSITORY" --summary "$GITHUB_STEP_SUMMARY")
|
||||
[ "$DRY_RUN" = true ] && args+=(--dry-run)
|
||||
[ -n "$RELEASE_REF" ] && args+=(--ref "$RELEASE_REF")
|
||||
[ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_REASON")
|
||||
[ -n "$REGRESSION_REASON" ] && args+=(--regression "$REGRESSION_REASON")
|
||||
echo "## Stable release ($([ "$DRY_RUN" = true ] && echo 'dry run' || echo 'cut'), $GITHUB_EVENT_NAME)" >> "$GITHUB_STEP_SUMMARY"
|
||||
bun --no-env-file --config=/dev/null --no-install run scripts/release-cut.ts "${args[@]}"
|
||||
|
||||
|
||||
@@ -14,6 +14,48 @@
|
||||
### Gate integrity
|
||||
- New `test/pregate-removed-paths.test.ts` (47 cases) covers the waived tests and the fail-closed negative controls; no existing test, threshold or allowlist changed.
|
||||
|
||||
## [1.91.294.0] - 2026-10-28
|
||||
|
||||
**`gstack-doctor` and setup's hook check start far fewer processes, so the doctor tests fit the Windows CI lane again.** On Windows Git Bash each process start costs 12 to 77 ms, and the doctor spent most of its time starting them: about 3.3 s per `--check` and 6 s per dashboard run. `windows-free-shard (1)` was red on main because `test/gstack-doctor.test.ts` took 553 to 647 s there. Every doctor row, fix line and JSON field prints the same as before.
|
||||
|
||||
### Changed
|
||||
- **`bin/gstack-doctor`**: `clamp` uses bash builtins under `LC_ALL=C`, bytewise like the `tr` and `cut` it replaces. The doctor reads `codex_reviews`, `artifacts_sync_mode`, `telemetry`, `update_check` and `test_backend` in-process through `gstack_config_select`, the reader `gstack-config get` uses, with the same defaults, instead of starting `gstack-config` six times per `--check`.
|
||||
- **`bin/gstack-hook-check`** scans each hook shim's TypeScript entries once, reads the shebang with `read` and dedupes its marker-scan list in bash.
|
||||
- **`bin/gstack-launch-probe.sh`** takes the first non-blank output line in one subshell instead of a five-process pipeline.
|
||||
- Measured on `windows-latest` (profile runs 40672710554 and 40676938495): the doctor test file went from 481 s to 185 s, its slowest case from 25.6 s to 9.9 s, and shard 1 from 682 s (red) to 402 s (green). Dispatched run 40678519767 was green on all six Windows shards.
|
||||
|
||||
### Tests
|
||||
- `test/gstack-doctor.test.ts`: the `--check` state-root row names `GSTACK_HOME` when `GSTACK_HOME` selected the root, in text and JSON.
|
||||
- `test/gstack-launch-probe.test.ts`: a broken binary's detail is its first non-blank line, with CRs dropped, cut to 200 bytes.
|
||||
|
||||
### Gate integrity
|
||||
- `test/owner-brief-contributor-banned.test.ts` no longer pins the source text `gstack-config" get test_backend` in `bin/gstack-doctor-check.sh`, which the in-process read removed. It runs `gstack-doctor --check --require cores` against a state root holding `test_backend: ubicloud` and expects `test_backend=ubicloud`, with an empty state root as the negative control (`test_backend=local`). The under-8-cores wording pin stays.
|
||||
|
||||
## [1.91.293.0] - 2026-10-28
|
||||
|
||||
**`gstack-harness` errors print again, and `register qm` says plainly that QM cannot import gstack yet.** Every refused `gstack-harness` command crashed with `TypeError: e.line is not a function` instead of printing its reason, because Bun gives each thrown error its own numeric `line` property. Harness Evals then found that QM registration never worked: QM imports a skill pack by cloning the whole repository and stops at 32 MiB and 5,000 files, and gstack's repository is about 73 MiB in 4,300 files.
|
||||
|
||||
### Fixed
|
||||
- **Error lines from `bin/gstack-harness`**: a refused command prints `gstack-harness: <reason>. Fix: <what to do> (<CODE>; docs/troubleshooting.md#<anchor>)` and exits 1, as documented. The printer is now `HarnessError.report()`.
|
||||
- **`gstack-harness register qm --yes`** measures the repository at the installed commit first and stops with `HARNESS_QM_PACK_TOO_LARGE` before sending anything to QM. A QM that refuses with its own cap message gets the same code with QM's words, and no blind retry.
|
||||
|
||||
### Docs
|
||||
- docs/hosts.md lists QM as not available yet, with the paste-in route (`gstack-harness register generic --print`) meanwhile; docs/troubleshooting.md has the `harness-qm-pack-too-large` section.
|
||||
|
||||
### Tests
|
||||
- `test/harness-register.test.ts`: the CLI error path prints the code and fix with no TypeError; `register qm` against the real checkout refuses with zero requests and no ledger; a QM that answers `pack exceeds max bytes` gets the cap code; the pack reuse and retry case now runs against a small committed repository.
|
||||
|
||||
## [1.91.292.0] - 2026-10-28
|
||||
|
||||
**A fix for a regression on stable can skip the 72-hour wait, like a security fix.** Garry, asked whether non-security breakage on stable may bypass the twice-weekly cadence and the floor, answered on 2026-10-28: "Regression". Until now a fix for something the last stable release did right, and the current one breaks, waited up to 72 hours on every team install. Now `--regression "#n: worked in v<X>: why"` with `--ref` passes the floor once the tool ties it to the release, and the claim is public in the tag and the notes.
|
||||
|
||||
### Added
|
||||
- **Regression cuts in `scripts/release-cut.ts`: `--regression "<reason>"` with `--ref <fix sha>`** (workflow input `regression`). The reason must name exactly one earlier stable release `v<X>` and at least one `#<n>`. The cut is refused with `REFUSED_REGRESSION`, red on every trigger, unless `v<X>` is an annotated release tag strictly behind the current stable with a published GitHub Release (the current stable itself never counts, nor a tag a half-done cut left behind), `#<n>` carries the `regression` label (a maintainer's declaration; a `security` label does not count, nor the reverse) and a released commit references `#<n>`. That the behavior really worked in `v<X>` stays attested. The record matches a security cut. The sanitized reason and evidence go into the tag (`Regression:` / `Verified:`), the notes (`(regression release)`, `**Regression release:** …`) and the plan (`REGRESSION:`, `RIDING_ALONG:`); a half-done cut finishes from its tag; an unmoved exception tag must verify again with the reason it recorded (this now holds for security cuts too).
|
||||
|
||||
### Changed
|
||||
- **One exception per cut.** `--security` and `--regression` together are `REFUSED_REGRESSION`. A regression cut passes a dated floor only; `FLOOR_UNKNOWN` (an undatable or unpublished stable) still yields to a security cut alone. The floor counts from any exception release, so the next scheduled cut may be held. `REFUSED_FLOOR` names both ways past it.
|
||||
- **"Weekly" wording follows the twice-weekly cadence** in README.md, `bin/gstack-update-channel.sh` (its comment and the no-stable-yet message, now "until the first stable release is cut"), the /gstack-upgrade skill's `AHEAD_OF_STABLE` line and the main-backstop workflow comments. docs/releases.md and docs/recommended-harness.md describe the regression exception.
|
||||
|
||||
## [1.91.291.0] - 2026-10-28
|
||||
|
||||
**/ship and /autoplan size their review to the diff.** A /ship review used to send the same specialist army, Red Team, adversarial pass and outside voice at every diff and repeat the whole army after any fix, up to three passes. Now `gstack-review-plan` classifies the diff (docs, tests, code or gate) and plans the next step through one transition table, so a README edit gets the core checklist and a gate change still gets everything. Measured on two captured diffs across five loop scenarios with measured reviewer output (`scripts/review-dispatch-compare.ts`), estimated review tokens fall to a median 0.26x of before on docs-only diffs and 0.85x on code diffs (0.52x over all ten runs, range 0.23–0.99). Every reviewer that can block a ship stays at full effort on the lane's model; a paid replay showed a medium-effort fix-check miss a fail-open that the full-effort check caught.
|
||||
|
||||
@@ -38,7 +38,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
|
||||
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
|
||||
```
|
||||
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the weekly tagged release (Monday mornings, plus off-cycle hotfixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md).
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the twice-weekly tagged release (Monday and Thursday, at most once every 72 hours, plus verified security and regression fixes), not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [docs/releases.md](docs/releases.md).
|
||||
|
||||
Swap `required` for `optional` if you'd rather nudge teammates than block them.
|
||||
|
||||
@@ -241,7 +241,7 @@ On Windows without Developer Mode (MSYS2 / Git Bash), `setup` falls back to file
|
||||
| Doc | What it covers |
|
||||
|-----|---------------|
|
||||
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
|
||||
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
|
||||
| [The recommended harness](docs/recommended-harness.md) | gstack + Capy from zero to the first merged train: install, the Captain and its Drive, trains, the daily flaky pass, the twice-weekly release, the factory scorecard, what is Capy-specific, known Capy limits |
|
||||
| [Why gstack](docs/why-gstack.md) | The story, the productivity numbers and their caveats, Karpathy's failure modes, parallel sprints |
|
||||
| [Install details](docs/install.md) | Setup timing, requirements in detail, team mode, the hooks setup registers, agent harness registration |
|
||||
| [Other agents](docs/hosts.md) | Agent harnesses (OpenClaw, Hermes, dots, QM, Muse Code, Grok, any other), OpenClaw and ClawHub, per-host notes, the Codex profile and model tiers, outside-review providers, the design binary's models |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.291.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.294.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+12
-5
@@ -93,7 +93,14 @@ row() {
|
||||
sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
|
||||
# clamp CHARSET MAX — stdin's first line reduced to CHARSET, at most MAX chars.
|
||||
clamp() { head -1 | tr -cd "$1" | cut -c1-"$2"; }
|
||||
# Builtins only, bytewise like tr and cut: every caller feeds it stdin, and on
|
||||
# Windows a head | tr | cut chain costs three process starts (~60 ms).
|
||||
clamp() { local LC_ALL=C line pat="[^$1]"; IFS= read -r line; line=${line//$pat/}; printf '%s' "${line:0:$2}"; }
|
||||
|
||||
# cfg KEY DEFAULT — sets _cfg to KEY as `gstack-config get` reads it, or DEFAULT
|
||||
# (gstack-config's own default) when unset. In-process through
|
||||
# gstack-state-root.sh, sourced below: a gstack-config start costs ~200 ms on Windows.
|
||||
cfg() { local sr_var=${_gstack_sr_var-}; gstack_config_select "$1" 2>/dev/null; _gstack_sr_var=$sr_var; _cfg="${_gstack_cfg_value:-$2}"; }
|
||||
|
||||
# epoch_of VALUE — epoch seconds for an epoch or an ISO-8601 UTC time; "" otherwise.
|
||||
epoch_of() {
|
||||
@@ -202,7 +209,7 @@ check_hooks() {
|
||||
check_codex() {
|
||||
local reviews probe="$BIN/gstack-codex-probe" sel rc model src
|
||||
CODEX_READY=0
|
||||
reviews=$("$BIN/gstack-config" get codex_reviews 2>/dev/null || echo enabled)
|
||||
cfg codex_reviews enabled; reviews=$_cfg
|
||||
if [ "$reviews" = disabled ]; then
|
||||
row "not configured" codex "codex_reviews disabled; $CODEX_FEATURES skip Codex" "$BIN/gstack-config set codex_reviews enabled"
|
||||
return
|
||||
@@ -269,7 +276,7 @@ check_codex_probe() {
|
||||
|
||||
check_sync() {
|
||||
local mode file json status held drainable drain push queue=0 now f detail case_names fix="$BIN/gstack-brain-sync --status"
|
||||
mode=$("$BIN/gstack-config" get artifacts_sync_mode 2>/dev/null || echo off)
|
||||
cfg artifacts_sync_mode off; mode=$_cfg
|
||||
if [ ! -d "$STATE_ROOT/.git" ]; then
|
||||
row "not configured" "artifacts sync" "off; cross-machine artifacts sync is unavailable" "$BIN/gstack-artifacts-init"
|
||||
return
|
||||
@@ -373,7 +380,7 @@ check_binaries() {
|
||||
check_cso() {
|
||||
local file="$SRC/bin/.gstack-cso-build-result" result stage reason installed diag usable=0
|
||||
[ -x "$SRC/bin/gstack-cso-launcher$EXE" ] && [ -x "$SRC/bin/gstack-cso-core$EXE" ] && usable=1
|
||||
cso_field() { sed -n "s/^$1=//p" "$file" 2>/dev/null | head -1 | clamp ' -~' 200; }
|
||||
cso_field() { sed -n "s/^$1=//p" "$file" 2>/dev/null | clamp ' -~' 200; }
|
||||
result=$(cso_field result) stage=$(cso_field stage) reason=$(cso_field reason) installed=$(cso_field installed) diag=$(cso_field diagnostic)
|
||||
case "$result" in
|
||||
ok) if [ "$usable" -eq 1 ]; then row ok cso "native helper $installed"
|
||||
@@ -603,7 +610,7 @@ if [ "$CHECK" -eq 1 ]; then
|
||||
exit $?
|
||||
fi
|
||||
|
||||
echo "gstack doctor $(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)"
|
||||
echo "gstack doctor $(clamp '0-9A-Za-z.+-' 40 2>/dev/null <"$ROOT/VERSION")"
|
||||
check_install
|
||||
check_rescue_refs
|
||||
check_state_root
|
||||
|
||||
+11
-11
@@ -2,7 +2,7 @@
|
||||
# gstack-doctor-check.sh — `gstack-doctor --check`: the one-line install verdict.
|
||||
#
|
||||
# Sourced by bin/gstack-doctor after its prelude (ROOT, ROOT_REAL, BIN, SRC,
|
||||
# DOCS, STATE_ROOT, EXE, clamp, the launch probe, the codex status helpers)
|
||||
# DOCS, STATE_ROOT, EXE, clamp, cfg, the launch probe, the codex status helpers)
|
||||
# and never executed on its own. Prints `required: <ids>`, one
|
||||
# `PASS | FAIL | SKIP <id>` row per component in bin/gstack-doctor-components.sh
|
||||
# (generated from lib/doctor-components.ts; the same ids feed --for, --require
|
||||
@@ -142,7 +142,7 @@ _dc_check_patch() {
|
||||
fi
|
||||
# A tier 2+ render keeps the patch in the shared sections/preamble.md its SKILL.md points to.
|
||||
for f in "$file" "${file%/SKILL.md}/sections/preamble.md"; do
|
||||
model=$(grep -F 'Model-Specific Behavioral Patch (' "$f" 2>/dev/null | head -1 | sed 's/.*Model-Specific Behavioral Patch (\([^)]*\)).*/\1/' | clamp 'A-Za-z0-9._:/-' 60)
|
||||
model=$(grep -F 'Model-Specific Behavioral Patch (' "$f" 2>/dev/null | sed 's/.*Model-Specific Behavioral Patch (\([^)]*\)).*/\1/' | clamp 'A-Za-z0-9._:/-' 60)
|
||||
[ -n "$model" ] && { file="$f"; break; }
|
||||
done
|
||||
if [ -n "$model" ]; then
|
||||
@@ -154,7 +154,7 @@ _dc_check_patch() {
|
||||
|
||||
_dc_check_codex_cli() {
|
||||
local probe="$BIN/gstack-codex-probe" reviews sel rc model out state optional_note="optional; a Claude subagent serves as the outside voice"
|
||||
reviews=$("$BIN/gstack-config" get codex_reviews 2>/dev/null || echo enabled)
|
||||
cfg codex_reviews enabled; reviews=$_cfg
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
_dc_fail_or_skip codex-cli "Codex CLI not installed" "npm install -g @openai/codex, then codex login" COMPONENT_MISSING "$optional_note; Codex CLI not installed"
|
||||
return
|
||||
@@ -228,7 +228,7 @@ _dc_check_browser() {
|
||||
if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -q '^RENDER_OK'; then
|
||||
_dc_row PASS browser "Chromium $(printf '%s\n' "$out" | sed -n 's/^RENDER_OK //p' | clamp '0-9.' 20) launched and rendered ($engine)"
|
||||
else
|
||||
_dc_row FAIL browser "Chromium did not launch and render via $engine (exit $rc: $(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | head -1 | clamp ' -~' 160))" "$fix" BROWSER_UNAVAILABLE
|
||||
_dc_row FAIL browser "Chromium did not launch and render via $engine (exit $rc: $(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | clamp ' -~' 160))" "$fix" BROWSER_UNAVAILABLE
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ _dc_check_cso() {
|
||||
_dc_row PASS cso "native helper built"
|
||||
return
|
||||
fi
|
||||
[ -f "$file" ] && reason=$(sed -n 's/^reason=//p' "$file" 2>/dev/null | head -1 | clamp ' -~' 100)
|
||||
[ -f "$file" ] && reason=$(sed -n 's/^reason=//p' "$file" 2>/dev/null | clamp ' -~' 100)
|
||||
_dc_fail_or_skip cso "native helper not built${reason:+ ($reason)}" "cd $SRC && ./setup (it names any missing build prerequisite)" COMPONENT_MISSING "optional; toolchain absent or helper not built${reason:+: $reason}"
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ _dc_check_pins() {
|
||||
return
|
||||
fi
|
||||
_dc_row FAIL pins "$failline$note" "$fix" PROJECT_PIN_MISMATCH ;;
|
||||
*) _dc_row SKIP pins "gstack-runtime-pins could not evaluate $project (exit $rc: $(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | head -1 | clamp ' -~' 120))" ;;
|
||||
*) _dc_row SKIP pins "gstack-runtime-pins could not evaluate $project (exit $rc: $(printf '%s\n' "$out" | grep -v '^[[:space:]]*$' | clamp ' -~' 120))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ _dc_check_state_root() {
|
||||
}
|
||||
|
||||
# _dc_cfg KEY DEFAULT — a clamped config value, never the file or a message.
|
||||
_dc_cfg() { local v; v=$("$BIN/gstack-config" get "$1" 2>/dev/null </dev/null | clamp 'a-z0-9_-' 24); printf '%s' "${v:-$2}"; }
|
||||
_dc_cfg() { local v; cfg "$1" "$2"; v=$(printf '%s' "$_cfg" | clamp 'a-z0-9_-' 24); printf '%s' "${v:-$2}"; }
|
||||
|
||||
_dc_check_privacy() {
|
||||
_dc_row PASS privacy "telemetry=$(_dc_cfg telemetry off) artifacts_sync=$(_dc_cfg artifacts_sync_mode off) codex_reviews=$(_dc_cfg codex_reviews enabled) update_check=$(_dc_cfg update_check true)"
|
||||
@@ -408,7 +408,7 @@ _dc_check_cores() {
|
||||
local n backend
|
||||
n=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 0)
|
||||
n=$(printf '%s' "$n" | clamp '0-9' 6)
|
||||
backend=$("$BIN/gstack-config" get test_backend 2>/dev/null </dev/null | clamp 'a-z' 8); backend=${backend:-local}
|
||||
cfg test_backend local; backend=$(printf '%s' "$_cfg" | clamp 'a-z' 8); backend=${backend:-local}
|
||||
case "$n" in ''|0) _dc_row PASS cores "unknown CPU count test_backend=$backend"; return ;; esac
|
||||
if [ "$n" -lt 8 ] && [ "$backend" = local ]; then
|
||||
_dc_row PASS cores "$n test_backend=local (under 8: run full unit and E2E tiers on a remote gate; set gstack-config set test_backend ubicloud, scripts/ubicloud/ubi-runner.sh)"
|
||||
@@ -454,7 +454,7 @@ _dc_check_min_revision() {
|
||||
|
||||
_dc_check_revision() {
|
||||
local installed head="" want="$CHECK_REVISION" target=""
|
||||
installed=$(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)
|
||||
installed=$(clamp '0-9A-Za-z.+-' 40 2>/dev/null <"$ROOT/VERSION")
|
||||
head=$(git -C "$ROOT_REAL" rev-parse --short HEAD 2>/dev/null | clamp '0-9a-f' 12)
|
||||
_dc_revision="${installed:-unknown}"
|
||||
_dc_check_min_revision "${installed:-0}" "$head" || return
|
||||
@@ -500,7 +500,7 @@ _dc_check_floor() {
|
||||
# first occurrence wins (the top-level field in a policy file), and only the
|
||||
# first 64 KiB are read: a real policy is a few KB.
|
||||
flat=$(head -c 65536 "$file" 2>/dev/null | tr '\n\r\t' ' ')
|
||||
floor=$(printf '%s' "$flat" | grep -o '"'"$GSTACK_PROJECT_FLOOR_FIELD"'" *: *"[0-9][0-9.]*"' | head -1 | sed 's/.*"\([0-9][0-9.]*\)"$/\1/' | clamp '0-9.' 32)
|
||||
floor=$(printf '%s' "$flat" | grep -o '"'"$GSTACK_PROJECT_FLOOR_FIELD"'" *: *"[0-9][0-9.]*"' | sed 's/.*"\([0-9][0-9.]*\)"$/\1/' | clamp '0-9.' 32)
|
||||
if [ -z "$floor" ]; then
|
||||
if printf '%s' "$flat" | grep -q '"'"$GSTACK_PROJECT_FLOOR_FIELD"'" *: *"'; then
|
||||
_dc_row SKIP floor "$file sets $GSTACK_PROJECT_FLOOR_FIELD to something other than a plain dotted version"
|
||||
@@ -509,7 +509,7 @@ _dc_check_floor() {
|
||||
fi
|
||||
return
|
||||
fi
|
||||
installed=$(head -1 "$ROOT/VERSION" 2>/dev/null | clamp '0-9A-Za-z.+-' 40)
|
||||
installed=$(clamp '0-9A-Za-z.+-' 40 2>/dev/null <"$ROOT/VERSION")
|
||||
if _dc_version_below "${installed:-0}" "$floor"; then
|
||||
if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
|
||||
fix="bash $GSTACK_CAPY_WRAPPER_PATH${CHECK_FOR:+ --for $CHECK_FOR} (fast-forwards a clean checkout to origin and reinstalls), or $SRC/bin/gstack-upgrade"
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* gstack-harness — register gstack's skills with the agent harness it is
|
||||
* installed into (OpenClaw, Hermes, dots, QM, any other), the harness's own
|
||||
* installed into (OpenClaw, Hermes, dots, any other; QM once its pack cap allows), the harness's own
|
||||
* way, and take them back out. lib/harness-register.ts holds the targets and
|
||||
* the ledger; docs/hosts.md has the per-harness install, verify and uninstall
|
||||
* blocks.
|
||||
@@ -122,10 +122,10 @@ async function main(): Promise<number> {
|
||||
const done = new Set<string>();
|
||||
for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) {
|
||||
try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
|
||||
}
|
||||
try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.line()); failed = 1; } else throw e; }
|
||||
catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; }
|
||||
for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) {
|
||||
console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n);
|
||||
}
|
||||
@@ -143,6 +143,6 @@ async function main(): Promise<number> {
|
||||
}
|
||||
|
||||
main().then(code => process.exit(code), (e) => {
|
||||
if (e instanceof HarnessError) { console.error(e.line()); process.exit(1); }
|
||||
if (e instanceof HarnessError) { console.error(e.report()); process.exit(1); }
|
||||
throw e;
|
||||
});
|
||||
+17
-4
@@ -155,11 +155,17 @@ ts_entries() {
|
||||
# One bun process bundles every hook's TypeScript entries (a bun start costs
|
||||
# seconds on Windows). Each line is "<entry>\tok\t<input>" per bundled input,
|
||||
# or "<entry>\tfail\t<file:line>\t<message>".
|
||||
# TS_OF[i] keeps hook i's entries for the check loop below, so each shim is
|
||||
# scanned once (four process starts, ~80 ms on Windows).
|
||||
ENTRIES=""
|
||||
i=0
|
||||
for hook in $HOOKS; do
|
||||
shim="$ROOT/$hook"
|
||||
TS_OF[i]=""
|
||||
[ -f "$shim" ] && TS_OF[i]=$(ts_entries "$shim")
|
||||
i=$((i + 1))
|
||||
[ -f "$shim" ] || continue
|
||||
for entry in $(ts_entries "$shim"); do
|
||||
for entry in ${TS_OF[i - 1]}; do
|
||||
[ -f "${shim%/*}/$entry" ] && ENTRIES="$ENTRIES${shim%/*}/$entry$NL"
|
||||
done
|
||||
done
|
||||
@@ -190,11 +196,14 @@ if [ -n "$ENTRIES" ]; then
|
||||
}' $ENTRIES) > "$BUILT" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
i=0
|
||||
for hook in $HOOKS; do
|
||||
shim="$ROOT/$hook"
|
||||
i=$((i + 1))
|
||||
if [ ! -f "$shim" ]; then echo "missing $hook"; continue; fi
|
||||
hook_ok=1
|
||||
shebang=$(head -1 "$shim")
|
||||
shebang=""
|
||||
IFS= read -r shebang < "$shim"
|
||||
case "$shebang" in
|
||||
'#!'*bash*) interp=bash ;;
|
||||
'#!'*/sh|'#!'*/sh' '*|'#!'*' sh'|'#!'*' sh '*) interp=sh ;;
|
||||
@@ -217,7 +226,7 @@ for hook in $HOOKS; do
|
||||
scan="$scan$NL$helper"
|
||||
done
|
||||
dir=${shim%/*}
|
||||
for entry in $(ts_entries "$shim"); do
|
||||
for entry in ${TS_OF[i - 1]}; do
|
||||
if [ ! -f "$dir/$entry" ]; then
|
||||
fail "$hook" "$dir/$entry:1" "missing TypeScript entry (the hook runs it)"
|
||||
hook_ok=0
|
||||
@@ -244,7 +253,11 @@ for hook in $HOOKS; do
|
||||
# and -H prefixes "<file>:<line>:" (the line is the last colon field before
|
||||
# the marker text, so paths containing colons still split right).
|
||||
# shellcheck disable=SC2046 # newline-split list (IFS is a newline)
|
||||
for found in $(grep -HnE -m1 '^(<<<<<<<|=======|>>>>>>>)( |$)' $(printf '%s\n' "$scan" | awk '!seen[$0]++') 2>/dev/null); do
|
||||
files=""
|
||||
for f in $scan; do
|
||||
case "$NL$files" in *"$NL$f$NL"*) ;; *) files="$files$f$NL" ;; esac
|
||||
done
|
||||
for found in $(grep -HnE -m1 '^(<<<<<<<|=======|>>>>>>>)( |$)' $files 2>/dev/null); do
|
||||
marker_file=${found%%:[0-9]*:[<=>]*}
|
||||
marker_line=${found#"$marker_file":}
|
||||
marker_line=${marker_line%%:*}
|
||||
|
||||
@@ -30,7 +30,10 @@ gstack_launch_probe() {
|
||||
out=$("$1" --version </dev/null 2>&1)
|
||||
fi
|
||||
rc=$?
|
||||
_glp_detail=$(printf '%s\n' "$out" | tr -d '\r' | grep -v '^[[:space:]]*$' | head -1 | cut -c1-200)
|
||||
# The first non-blank line, CRs dropped, at most 200 bytes: one subshell, not
|
||||
# a five-process pipeline (each process start is ~20 ms on Windows).
|
||||
_glp_detail=$(set -f; IFS='
|
||||
'; LC_ALL=C; for line in $out; do line=${line//$'\r'/}; case "$line" in *[![:space:]]*) printf '%s' "${line:0:200}"; break ;; esac; done)
|
||||
case "$out" in
|
||||
*[Aa]pplication\ [Cc]ontrol*|*"Smart App Control"*|*"blocked by group policy"*|*"has blocked this file"*)
|
||||
_glp_state=blocked; return 0 ;;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#
|
||||
# Channels (docs/releases.md):
|
||||
# main every merge to main (a version per merge, a dozen a day)
|
||||
# stable the weekly tagged release: refs/heads/stable on origin, moved only
|
||||
# stable the twice-weekly tagged release: refs/heads/stable on origin, moved only
|
||||
# forward along main's first-parent history by the release workflow
|
||||
#
|
||||
# The config key update_channel (bin/gstack-config) is auto | stable | main.
|
||||
@@ -77,7 +77,7 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
_uc_err=$(_receipted_git open gstack-upgrade "${_uc_host:-unknown}" gstack-self-update-pull "user ran /gstack-upgrade" git -C "$_uc_root" fetch -q origin "$(gstack_update_channel_refspec stable)" 2>&1 >/dev/null) \
|
||||
&& { printf 'origin/stable\n'; exit 0; }
|
||||
if gstack_update_channel_missing "$_uc_err"; then
|
||||
echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first weekly release is cut" >&2
|
||||
echo "gstack-update-channel: origin has no stable release yet; following origin/main until the first stable release is cut" >&2
|
||||
else
|
||||
echo "gstack-update-channel: fetching origin/stable failed ($(printf '%s' "$_uc_err" | head -1 | head -c 200)); staying on origin/stable as last fetched" >&2
|
||||
printf 'origin/stable\n'; exit 0
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ it has one (`visible to harness: yes`). Errors: [troubleshooting](troubleshootin
|
||||
| OpenClaw | one `gstack` router skill (one description line in every request; its body lists every skill and its path) in `${OPENCLAW_STATE_DIR:-~/.openclaw}/skills/gstack`; `--skills all` registers every skill as its own top-level folder | `./setup --host openclaw`, or `./setup` inside OpenClaw | `openclaw skills list --json` shows `gstack` (source `openclaw-managed`, eligible) | `gstack-harness unregister openclaw` |
|
||||
| Hermes | every skill, as `gstack-<skill>` in one `gstack` category, in `${HERMES_HOME:-~/.hermes}/skills/gstack` (Hermes opens only skills it has indexed) | `./setup --host hermes`, or `./setup` inside Hermes | `hermes skills list` shows the `gstack-*` skills in category `gstack` | `gstack-harness unregister hermes` |
|
||||
| dots (feder-cr) | the `gstack` router in `/home/dot/skills/gstack`, which the Dot lists every turn | `./setup` on the Dot's computer, or `gstack-harness register dots` | the Dot's Skills page, or `GET /skills` | `gstack-harness unregister dots` |
|
||||
| QM | an org skill pack: `github.com/garrytan/gstack` at the installed commit, every skill imported (instruction-only there: discovery, no gstack runtime) | an org admin, with `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` set: `gstack-harness register qm --yes` | QM's skill list for the org | `gstack-harness unregister qm --remote` archives the pack; without `--remote` only the local record goes |
|
||||
| QM | **not available yet.** QM imports a skill pack by cloning the whole repository and caps it at 32 MiB and 5,000 files; gstack's repository is about 73 MiB, so `gstack-harness register qm --yes` stops before sending anything ([why](troubleshooting.md#harness-qm-pack-too-large)) | meanwhile, paste `gstack-harness register generic --print` into the agent's instructions | ask it for a gstack skill | delete the pasted text |
|
||||
| Muse Code, Grok Build | nothing: both read `~/.claude/skills`, so the Claude install (`./setup`) is what they see, with Claude tool names | `./setup` | `gstack-harness status` runs `muse skills list` / `grok inspect` and reports *discovered (Claude-rendered, unverified)* | `gstack-uninstall` |
|
||||
| Grok Bot, any chat-only skill library | nothing on disk | `gstack-harness register generic --print`, then paste the router and block into the app | ask it for a gstack skill | delete the pasted text |
|
||||
| any other harness | the router in an Agent Skills folder you name, and/or a 600-byte routing block between `<!-- gstack:begin harness-routing -->` markers in a context file you name | `gstack-harness register generic --skills-dir <dir>` and/or `--context <file>`; `gstack-harness manifest generic` prints every skill as JSON | the harness's own skill list | `gstack-harness unregister generic` |
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ When qualified CSO runtime images are published, setup gives each automatic prel
|
||||
|
||||
From inside your repo, `(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required` switches you to team mode and bootstraps the repo so teammates get gstack automatically (the README has the full paste, which also commits the change).
|
||||
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md).
|
||||
No vendored files in your repo, no version drift, no manual upgrades. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent). Team mode follows the `stable` channel: the tagged release cut Monday and Thursday mornings, never more than once every 72 hours except for a security or regression fix, not every merge to main. `gstack-config set update_channel main` follows every merge instead; see [releases.md](releases.md).
|
||||
|
||||
Swap `required` for `optional` if you'd rather nudge teammates than block them.
|
||||
|
||||
@@ -53,7 +53,7 @@ Run inside OpenClaw, Hermes or a Dot, `./setup` also registers gstack's skills
|
||||
with that harness, in the harness's own skills folder, and prints every path
|
||||
it wrote. It never does this when `--host` names another host or with
|
||||
`--local`. Turn it off with `./setup --no-harness-auto` or
|
||||
`GSTACK_HARNESS_AUTO=0`. Per-harness details, QM and any other harness: [hosts.md](hosts.md#agent-harnesses).
|
||||
`GSTACK_HARNESS_AUTO=0`. Per-harness details, any other harness, and why QM is not available yet: [hosts.md](hosts.md#agent-harnesses).
|
||||
|
||||
## Uninstall
|
||||
|
||||
|
||||
@@ -225,8 +225,8 @@ one place, and run it once by hand before enabling it.
|
||||
|
||||
`.github/workflows/weekly-release.yml` ("Stable release") runs
|
||||
`scripts/release-cut.ts` every Monday and Thursday at 14:00 UTC, and never moves
|
||||
`stable` more than once in 72 hours except for a verified security fix
|
||||
(`--security` with `--ref`): it takes the newest green commit on main's first-parent history
|
||||
`stable` more than once in 72 hours except for a verified security or regression
|
||||
fix (`--security` or `--regression`, with `--ref`): it takes the newest green commit on main's first-parent history
|
||||
(its required check succeeded, the backstop check succeeded, every check run
|
||||
completed and none failed, no active freeze), tags it `v<VERSION>`, publishes a
|
||||
GitHub Release whose notes are one line per CHANGELOG version since the
|
||||
|
||||
+65
-16
@@ -20,7 +20,8 @@ commit on main and:
|
||||
|
||||
A tag and its release never change. `stable` is a moving pointer: always a
|
||||
commit on main's first-parent history, moved only by this workflow, only
|
||||
forward, and at most once every 72 hours except for a security fix (below).
|
||||
forward, and at most once every 72 hours except for a security or regression
|
||||
fix (below).
|
||||
|
||||
**Green** means, on that exact commit: the `free-tests` check run succeeded
|
||||
(the status main requires), the commit was gated (below), every check run on
|
||||
@@ -74,7 +75,7 @@ extra tag can neither date the move nor stall later cuts.
|
||||
A cut inside the floor changes nothing and prints, as its first two lines:
|
||||
|
||||
```
|
||||
REFUSED_FLOOR: stable moved to v1.91.300.0 at 2026-11-02T14:04:51Z, 47.2 h ago; a hand-run cut needs 72 h; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>
|
||||
REFUSED_FLOOR: stable moved to v1.91.300.0 at 2026-11-02T14:04:51Z, 47.2 h ago; a hand-run cut needs 72 h; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>, a regression fix with --regression "#n: worked in v<X>: why" --ref <fix sha>
|
||||
NEXT_ALLOWED: 2026-11-05T14:04:51Z for a hand-run cut; 2026-11-05T11:04:51Z for a scheduled run
|
||||
```
|
||||
|
||||
@@ -87,8 +88,8 @@ always needs the full 72. Thursday to Monday is 96 hours.
|
||||
|
||||
A refused hand cut exits 1 with an error annotation. A refused scheduled run
|
||||
exits 0 with a notice annotation and the same lines in the step summary: that
|
||||
is the policy working (a security cut on Wednesday holds Thursday, and
|
||||
Monday's cut carries everything), not a fault. A dry run prints the plan and
|
||||
is the policy working (a security or regression cut on Wednesday restarts the
|
||||
floor and holds Thursday, and Monday's cut carries everything), not a fault. A dry run prints the plan and
|
||||
the floor verdict (`FLOOR:` line) and exits 0.
|
||||
|
||||
Two cases are not held. The first cut, before `stable` exists, has nothing to
|
||||
@@ -98,7 +99,7 @@ it considers anything newer.
|
||||
|
||||
The floor fails closed when it cannot date the move. Three cases are
|
||||
`FLOOR_UNKNOWN`, red on every trigger, and only a verified security cut gets
|
||||
past them, as it gets past the floor. They are a `stable` that no annotated `v<VERSION>` tag dates
|
||||
past them (a regression cut passes a dated floor only). They are a `stable` that no annotated `v<VERSION>` tag dates
|
||||
(only this tool moves `stable` and it tags first, so that state means someone
|
||||
moved it by hand), a release that cannot be read (a GitHub API error is not
|
||||
"no release"), and a `ref` cut while `stable`'s own release is still
|
||||
@@ -107,14 +108,15 @@ future holds the floor from now, not from that date. The floor binds the
|
||||
release tool, not a writer pushing `stable` directly; that would take a branch
|
||||
ruleset limiting `stable` to the Actions bot.
|
||||
|
||||
The floor applies to non-security breakage too. If a cut breaks team
|
||||
installs, the fix waits for the floor; an affected install can follow main
|
||||
meanwhile (`gstack-config set update_channel main`) and the next scheduled
|
||||
cut brings it back.
|
||||
Breakage that is not a regression of an earlier stable release (a new
|
||||
feature that misbehaves, say) waits for the floor; an affected install can
|
||||
follow main meanwhile (`gstack-config set update_channel main`) and the next
|
||||
scheduled cut brings it back.
|
||||
|
||||
## Security releases
|
||||
|
||||
A security fix is the one exception to the floor. Run the workflow by hand
|
||||
A security fix is one of the two exceptions to the floor (the other is a
|
||||
regression fix, below); a cut carries one exception, never both. Run the workflow by hand
|
||||
with `ref` set to the fix commit on main and `security` set to a reason that
|
||||
names what it fixes:
|
||||
|
||||
@@ -157,14 +159,60 @@ A security cut that stopped half-way after moving `stable` is finished by
|
||||
the next run with the reason and evidence its tag recorded, without checking
|
||||
them again. A tag that records a security cut but never moved `stable` gets
|
||||
no such pass: the cut is refused (`REFUSED_SECURITY`) until it runs again
|
||||
with `security` and `ref` and verifies. The floor then counts from the security
|
||||
with `security`, `ref` and the reason its tag recorded, and verifies. The floor then counts from the security
|
||||
release, and the next regular cut's notes list only the versions after it.
|
||||
|
||||
## Regression releases
|
||||
|
||||
A regression fix is the other exception (Garry, 2026-10-28: "Regression"). A
|
||||
regression is a fix for behavior that worked in an earlier stable release and
|
||||
is broken on the current one. A maintainer declares it by putting the
|
||||
`regression` label on the issue or pull request (labelling needs triage
|
||||
access; create the label once if the repository lacks it), and the person
|
||||
running the cut names it in the `regression` input, with `ref` set to the
|
||||
fix commit on main:
|
||||
|
||||
```bash
|
||||
gh workflow run weekly-release.yml -f ref=<full sha of the fix> -f regression="#3601: worked in v1.91.300.0: the update check stopped running" -f dry_run=false
|
||||
```
|
||||
|
||||
The reason must name exactly one release `v<X>` and at least one `#<n>`.
|
||||
All of these must hold, or the cut is `REFUSED_REGRESSION` with what it
|
||||
checked:
|
||||
|
||||
1. `v<X>` is an annotated release tag strictly behind the current `stable`
|
||||
whose GitHub Release is published (not a draft): an earlier stable release
|
||||
team installs actually ran, which a tag left behind by a half-done cut is
|
||||
not. The current stable
|
||||
itself does not count, since a fix for something it still does right needs
|
||||
no exception.
|
||||
2. `#<n>` carries the `regression` label (a `security` label does not count,
|
||||
nor the reverse) and a commit in `stable`..`ref` references it (the squash
|
||||
subject `(#<n>)` does).
|
||||
|
||||
**Verified:** the release tag, its place behind `stable` and its published
|
||||
release, the label, and
|
||||
that the labelled issue or PR is in the released span. **Attested, not
|
||||
verified:** that the behavior really worked in `v<X>` and that this change
|
||||
restores it; the label and the public reason carry that claim.
|
||||
|
||||
Everything else matches a security cut. `ref` is required, `regression` and
|
||||
`security` together are refused, every version between `stable` and the fix
|
||||
rides along (`RIDING_ALONG:`), and the sanitized reason and evidence go into
|
||||
the tag message (`Regression:` / `Verified:`) and the notes
|
||||
(``**Regression release:** `<reason>` (verified: <evidence>)``). A half-done
|
||||
regression cut finishes from its tag's record, and a regression tag that never
|
||||
moved `stable` is refused until it runs again with `regression` and the reason
|
||||
its tag recorded, and verifies.
|
||||
Unlike a security cut, a regression cut does not pass `FLOOR_UNKNOWN`. The
|
||||
floor counts from the regression release, so the next scheduled cut may be
|
||||
held.
|
||||
|
||||
## Channels
|
||||
|
||||
| Channel | Follows | Who |
|
||||
|---|---|---|
|
||||
| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security cut off-cycle | team mode (`./setup --team`) by default |
|
||||
| `stable` | `refs/heads/stable`: the twice-weekly release (at most once per 72 hours), plus any security or regression cut off-cycle | team mode (`./setup --team`) by default |
|
||||
| `main` | every merge to main | plain `./setup`, contributors, Capy crew machines |
|
||||
|
||||
The config key is `update_channel` (`bin/gstack-config`):
|
||||
@@ -213,7 +261,7 @@ still holds and nothing about version stamping changed.
|
||||
A hotfix is an ordinary PR: it merges to main through a train and gets its
|
||||
version like any other change. To put it on stable before the next scheduled
|
||||
cut, run the workflow by hand with the merge commit's SHA, once the 72-hour
|
||||
floor allows it (a security fix uses the security input instead):
|
||||
floor allows it (a security or regression fix uses its input instead):
|
||||
|
||||
```bash
|
||||
gh workflow run weekly-release.yml -f ref=<full sha on main> -f dry_run=false
|
||||
@@ -263,7 +311,8 @@ is exercised against real check runs before it merges.
|
||||
| `REFUSED_FLOOR: stable moved to v<X> at <time>, <h> h ago; ...` | inside the 72-hour floor; nothing changed. Exit 1 by hand, 0 on the schedule or a dry run |
|
||||
| `NEXT_ALLOWED: <time> for a hand-run cut; <time> for a scheduled run` | follows every `REFUSED_FLOOR` (and `FLOOR_UNKNOWN`, where it says why there is no time) |
|
||||
| `FLOOR_UNKNOWN: ...` | stable's last move cannot be dated (no annotated `v<VERSION>` tag, an unreadable release, or an unpublished release under a `ref` cut); exit 1 on every trigger |
|
||||
| `REFUSED_SECURITY: ...` | `security` without `ref`, a reason the tool cannot tie to the released commits, or a security tag that never moved stable run without `security`; exit 1 on every trigger |
|
||||
| `FLOOR: clear|refused|unknown|none|not applied|bypassed by the security exception (...)` | the floor verdict, in every plan |
|
||||
| ``SECURITY: `<reason>` (verified: <evidence>)``, `RIDING_ALONG: <n> other versions ship with the fix` | a security cut's plan |
|
||||
| `REFUSED_SECURITY: ...` | `security` without `ref`, a reason the tool cannot tie to the released commits, a security tag that never moved stable run without `security`, or a rerun whose reason differs from the one its unmoved tag recorded; exit 1 on every trigger (the workflow stops `security` without `ref` before the script runs) |
|
||||
| `REFUSED_REGRESSION: ...` | `regression` without `ref` or beside `security`, a reason without one earlier stable `v<X>` and a regression-labelled `#<n>` in the span, a regression tag that never moved stable run without `regression`, or a rerun whose reason differs from the one its unmoved tag recorded; exit 1 on every trigger (the workflow stops `regression` without `ref` before the script runs) |
|
||||
| `FLOOR: clear|refused|unknown|none|not applied|bypassed by the security|regression exception (...)` | the floor verdict, in every plan |
|
||||
| ``SECURITY: `<reason>` (verified: <evidence>)`` or ``REGRESSION: `<reason>` (verified: <evidence>)``, `RIDING_ALONG: <n> other versions ship with the fix` | an exception cut's plan |
|
||||
| `TAG:`, `STABLE:`, `RELEASE:` | what the cut did; a re-run reports each step as already done and finishes a step an earlier run did not reach |
|
||||
@@ -5082,7 +5082,7 @@ gitignored entries (`.env`, build output) that go with the worktree.
|
||||
|
||||
## Agent harnesses (gstack-harness)
|
||||
|
||||
`bin/gstack-harness` registers gstack's skills with the agent harness gstack is installed into (OpenClaw, Hermes, a Dot, QM, any other) and records every file it writes in `$GSTACK_STATE_ROOT/harness/<id>.ledger`. Each error line ends with its code and a link here. Per-harness install, verify and uninstall steps: [docs/hosts.md](hosts.md#agent-harnesses).
|
||||
`bin/gstack-harness` registers gstack's skills with the agent harness gstack is installed into (OpenClaw, Hermes, a Dot, any other; QM is not available yet) and records every file it writes in `$GSTACK_STATE_ROOT/harness/<id>.ledger`. Each error line ends with its code and a link here. Per-harness install, verify and uninstall steps: [docs/hosts.md](hosts.md#agent-harnesses).
|
||||
|
||||
<a id="harness-unknown-target"></a>
|
||||
### `gstack-harness: unknown harness "<name>"` (HARNESS_UNKNOWN_TARGET)
|
||||
@@ -5129,10 +5129,15 @@ There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `g
|
||||
<a id="harness-qm-credentials"></a>
|
||||
### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS)
|
||||
|
||||
Registering gstack in QM publishes an org skill pack, so it needs an org admin's credential and an explicit `--yes`. Set `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` (or `GSTACK_QM_SIGNING_SECRET` with `GSTACK_QM_ADMIN_ACTOR`) in the environment and run `gstack-harness register qm --yes`. gstack never prompts for, prints or stores the credential; the message shows the exact request instead.
|
||||
Registering gstack in QM publishes an org skill pack, so it needs an org admin's credential and an explicit `--yes`. Set `GSTACK_QM_URL` and `GSTACK_QM_ADMIN_TOKEN` (or `GSTACK_QM_SIGNING_SECRET` with `GSTACK_QM_ADMIN_ACTOR`) in the environment and run `gstack-harness register qm --yes`. gstack never prompts for, prints or stores the credential; the message shows the exact request instead. With the credential set, registration still stops at the next check: QM cannot import gstack yet ([HARNESS_QM_PACK_TOO_LARGE](#harness-qm-pack-too-large)).
|
||||
|
||||
<a id="harness-qm-api"></a>
|
||||
### `gstack-harness: <METHOD> /v1/admin/skill-packs... returned HTTP <code>` / `... failed: timed out after 60 s` (HARNESS_QM_API)
|
||||
|
||||
QM's admin API refused or did not answer. gstack lists packs before it creates one and retries once, so rerunning never makes a second pack. Check the URL is QM core and the credential is an org admin; `gstack-harness status` shows whether a pack id was recorded.
|
||||
|
||||
<a id="harness-qm-pack-too-large"></a>
|
||||
### `gstack-harness: gstack at <sha> is <n> MiB in <n> files; nothing was sent to QM` / `QM refused the pack: pack exceeds max bytes (33554432)` (HARNESS_QM_PACK_TOO_LARGE)
|
||||
|
||||
QM registration does not work yet. QM's skill-pack import clones the whole repository and reads every file before it picks the skills, and by default it refuses a pack over 32 MiB or 5,000 files. The gstack repository is larger (about 73 MiB in 4,300 files), so gstack checks the size first and sends nothing. When QM itself refuses (a QM with other limits), the pack it created stays in the org without skills: `gstack-harness unregister qm --remote` archives it. Meanwhile, give a QM agent gstack by pasting the output of `gstack-harness register generic --print` into its instructions.
|
||||
|
||||
@@ -171,7 +171,7 @@ their conflicting local edits were saved there and relay the `Inspect`,
|
||||
checkout with `git apply -3`, never `stash apply` in the install;
|
||||
`unmerged=1` means the rescue needs manual resolution).
|
||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
||||
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
|
||||
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the latest stable release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
|
||||
`ff-busy` means another gstack update is running (retry in a minute);
|
||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||
release adds, or another git command); `index-unreadable` means the checkout's
|
||||
|
||||
@@ -177,7 +177,7 @@ their conflicting local edits were saved there and relay the `Inspect`,
|
||||
checkout with `git apply -3`, never `stash apply` in the install;
|
||||
`unmerged=1` means the rescue needs manual resolution).
|
||||
On `FF_STOPPED`, STOP and relay the reason: nothing was changed.
|
||||
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the weekly release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
|
||||
On `AHEAD_OF_STABLE`, STOP: the checkout is already past the latest stable release, so nothing changes until the next cut (`gstack-config set update_channel main` follows every merge). On a `fetching origin/stable failed` line above, STOP and relay it; nothing was changed.
|
||||
`ff-busy` means another gstack update is running (retry in a minute);
|
||||
`autostash-unrecoverable` names what is in the way (an untracked file the
|
||||
release adds, or another git command); `index-unreadable` means the checkout's
|
||||
|
||||
+25
-1
@@ -21,6 +21,24 @@ type Env = Record<string, string | undefined>;
|
||||
export const GSTACK_REPO_URL = 'https://github.com/garrytan/gstack';
|
||||
/** Only the top-level skills: the committed renders at the repository root, not fixtures or the openclaw/ folder. */
|
||||
export const QM_PACK_CONFIG = { skillGlobs: ['*/SKILL.md'] };
|
||||
/** QM's git fetcher reads the whole tree before it applies skillGlobs, and refuses past these defaults (QM src/skills/pack-fetcher.ts). */
|
||||
export const QM_PACK_LIMITS = { files: 5000, bytes: 32 * 1024 * 1024 };
|
||||
|
||||
const mib = (n: number) => (n / 1024 / 1024).toFixed(1) + ' MiB';
|
||||
const tooLarge = (what: string) => new HarnessError('HARNESS_QM_PACK_TOO_LARGE', what + '. QM imports the whole repository and caps a pack at ' + mib(QM_PACK_LIMITS.bytes) + ' and ' + QM_PACK_LIMITS.files + ' files by default, so it cannot import gstack yet');
|
||||
|
||||
/** Files and bytes QM would read from the repository at ref. */
|
||||
export function packSize(checkout: string, ref: string): { files: number; bytes: number } {
|
||||
const r = spawnSync('git', ['-C', checkout, 'ls-tree', '-r', '-l', ref], { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (r.status !== 0 || !ref) throw new HarnessError('HARNESS_QM_API', 'could not read the gstack checkout at ' + (ref || 'HEAD') + ' to size the pack (QM imports a git commit, so register qm needs a git checkout of gstack)');
|
||||
let files = 0; let bytes = 0;
|
||||
for (const line of (r.stdout ?? '').split('\n')) {
|
||||
const [mode, type, , size] = line.split(/\s+/);
|
||||
if (type !== 'blob' || mode === '120000' || !Number.isFinite(Number(size))) continue;
|
||||
files++; bytes += Number(size);
|
||||
}
|
||||
return { files, bytes };
|
||||
}
|
||||
|
||||
export interface QmAuth { url: string; headers: (method: string, pathname: string, body: string) => Record<string, string> }
|
||||
|
||||
@@ -81,6 +99,8 @@ export async function registerQm(opts: { checkout: string; stateRoot: string; en
|
||||
if (!auth || !opts.yes) {
|
||||
throw new HarnessError('HARNESS_QM_CREDENTIALS', (auth ? 'registering a QM pack publishes skills to the whole organization, so it needs --yes' : 'no QM admin credential in the environment') + '. The request is:\n' + qmRequest(ref));
|
||||
}
|
||||
const size = packSize(opts.checkout, ref);
|
||||
if (size.files > QM_PACK_LIMITS.files || size.bytes > QM_PACK_LIMITS.bytes) throw tooLarge('gstack at ' + ref.slice(0, 12) + ' is ' + mib(size.bytes) + ' in ' + size.files + ' files; nothing was sent to QM');
|
||||
const id = registrationId('qm', auth.url);
|
||||
const lfile = ledgerPath(opts.stateRoot, id);
|
||||
const prev = readLedger(lfile);
|
||||
@@ -90,7 +110,11 @@ export async function registerQm(opts: { checkout: string; stateRoot: string; en
|
||||
const pack = existing ?? (await call(auth, 'POST', '/v1/admin/skill-packs', { url: GSTACK_REPO_URL, ref, config: QM_PACK_CONFIG, trustTier: 'third-party' })).pack;
|
||||
if (!pack?.id) throw new HarnessError('HARNESS_QM_API', 'QM did not return a pack id');
|
||||
writeLedger(lfile, { meta: { id, target: 'qm', dest: auth.url, mode: 'all', checkout: opts.checkout, version: opts.version, qm_pack: pack.id, qm_ref: ref }, files: [], dirs: [], blocks: [] });
|
||||
const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' });
|
||||
const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' }).catch(async (e) => {
|
||||
const recorded = ((await call(auth, 'GET', '/v1/admin/skill-packs').catch(() => ({}))).packs ?? []).find((p: any) => p.id === pack.id)?.lastImport;
|
||||
const cap = /pack exceeds max (bytes|files) \(\d+\)/.exec(String(recorded?.error ?? ''));
|
||||
throw cap ? tooLarge('QM refused the pack: ' + cap[0]) : e;
|
||||
});
|
||||
return { pack, existing: !!existing, imported };
|
||||
};
|
||||
let out;
|
||||
|
||||
@@ -117,12 +117,14 @@ export const HARNESS_CODES = {
|
||||
HARNESS_NOT_REGISTERED: { anchor: 'harness-not-registered', fix: 'run gstack-harness status to see the registrations on this machine' },
|
||||
HARNESS_QM_CREDENTIALS: { anchor: 'harness-qm-credentials', fix: 'set GSTACK_QM_URL and either GSTACK_QM_ADMIN_TOKEN, or GSTACK_QM_SIGNING_SECRET with GSTACK_QM_ADMIN_ACTOR, then rerun with --yes' },
|
||||
HARNESS_QM_API: { anchor: 'harness-qm-api', fix: 'check that the QM core URL is reachable and the credential is an org admin; gstack-harness status shows whether a pack was left half-registered' },
|
||||
HARNESS_QM_PACK_TOO_LARGE: { anchor: 'harness-qm-pack-too-large', fix: 'QM registration is not available yet; give a QM agent gstack with gstack-harness register generic --print pasted into its instructions' },
|
||||
} as const;
|
||||
export type HarnessCode = keyof typeof HARNESS_CODES;
|
||||
|
||||
export class HarnessError extends Error {
|
||||
constructor(readonly code: HarnessCode, message: string) { super(message); }
|
||||
line(): string {
|
||||
/** Not named line(): Bun gives every thrown Error an own numeric line property, which would shadow it. */
|
||||
report(): string {
|
||||
const c = HARNESS_CODES[this.code];
|
||||
return 'gstack-harness: ' + this.message + '. Fix: ' + c.fix + ' (' + this.code + '; docs/troubleshooting.md#' + c.anchor + ')';
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.291",
|
||||
"version": "1.91.294",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+114
-55
@@ -7,7 +7,7 @@
|
||||
* Thursday at 14:00 UTC and by hand (--ref <sha on main> for a hotfix). Dry
|
||||
* runs print the plan and change nothing.
|
||||
*
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason>] [--dry-run]
|
||||
* bun scripts/release-cut.ts --repo owner/name [--ref <sha>] [--security <reason> | --regression <reason>] [--dry-run]
|
||||
* [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]
|
||||
*
|
||||
* The checkout needs origin's full history (fetch-depth 0). Green, for a
|
||||
@@ -42,10 +42,14 @@
|
||||
* with NEXT_ALLOWED, and a stable without that tag is FLOOR_UNKNOWN. A
|
||||
* scheduled run (GITHUB_EVENT_NAME=schedule) gets SCHEDULED_GRACE_HOURS (3) of
|
||||
* slack for cron start jitter; nothing else does. Finishing a half-done cut is
|
||||
* not a new move. The one exception is a security cut: --security <reason>
|
||||
* with --ref, where the reason names a GHSA, CVE or #<n> that verifySecurity
|
||||
* ties to the commits being released; the reason and its evidence go into the
|
||||
* tag message and the notes, and a retry recovers them from the tag.
|
||||
* not a new move. Two exceptions pass the floor, each with --ref and one per
|
||||
* cut: a security cut (--security <reason>, where the reason names a GHSA, CVE
|
||||
* or #<n> that verifySecurity ties to the commits being released) and a
|
||||
* regression cut (--regression <reason>, where the reason names a
|
||||
* regression-labelled #<n> a released commit references and the earlier stable
|
||||
* release v<X> it worked in; see verifyRegression). Only a security cut passes
|
||||
* FLOOR_UNKNOWN. The reason and its evidence go into the tag message and the
|
||||
* notes, and a retry recovers them from the tag.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
@@ -183,13 +187,15 @@ export function changelogEntries(text: string): ChangelogEntry[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; security?: Security | null }
|
||||
export interface NotesInput { repo: string; version: string; date: string; prevVersion: string | null; prevTag: string | null; entries: ChangelogEntry[]; hotfix: boolean; first?: boolean; exception?: Exception | null }
|
||||
|
||||
/** A verified security cut: the sanitized reason and what tied it to the released commits. */
|
||||
export interface Security { reason: string; evidence: string }
|
||||
/** A verified floor exception (a security or a regression cut): the sanitized reason and what tied it to the released commits. */
|
||||
export interface Exception { kind: 'security' | 'regression'; reason: string; evidence: string }
|
||||
|
||||
export function releaseKind(hotfix: boolean, security: Security | null | undefined): 'security' | 'hotfix' | 'regular' {
|
||||
return security ? 'security' : hotfix ? 'hotfix' : 'regular';
|
||||
const KIND_LABEL = { security: 'Security', regression: 'Regression' } as const;
|
||||
|
||||
export function releaseKind(hotfix: boolean, exception: Exception | null | undefined): 'security' | 'regression' | 'hotfix' | 'regular' {
|
||||
return exception ? exception.kind : hotfix ? 'hotfix' : 'regular';
|
||||
}
|
||||
|
||||
/** Release notes: one headline per version in (prev, version], capped at NOTES_CAP. */
|
||||
@@ -200,9 +206,9 @@ export function releaseNotes(n: NotesInput): string {
|
||||
const changelogUrl = 'https://github.com/' + n.repo + '/blob/v' + n.version + '/CHANGELOG.md';
|
||||
const compare = n.prevTag ? '[Compare](https://github.com/' + n.repo + '/compare/' + n.prevTag + '...v' + n.version + ') or read ' : 'Read ';
|
||||
const head = [
|
||||
'## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.security) + ' release)',
|
||||
'## gstack v' + n.version + ' (' + releaseKind(n.hotfix, n.exception) + ' release)',
|
||||
'',
|
||||
...(n.security ? ['**Security release:** `' + n.security.reason + '` (verified: ' + n.security.evidence + '). Every version since the previous release ships with it.', ''] : []),
|
||||
...(n.exception ? ['**' + KIND_LABEL[n.exception.kind] + ' release:** `' + n.exception.reason + '` (verified: ' + n.exception.evidence + '). Every version since the previous release ships with it.', ''] : []),
|
||||
n.first || !n.prevVersion
|
||||
? 'First tagged release: the ' + span.length + ' version' + plural + (n.prevVersion ? ' since v' + n.prevVersion + ' (the week before it)' : '') + ', one line each; the [full changelog](' + changelogUrl + ') has every earlier one.'
|
||||
: span.length + ' version' + plural + ' since v' + n.prevVersion + ', one line each. ' + compare + 'the [full changelog](' + changelogUrl + ').',
|
||||
@@ -248,17 +254,18 @@ export function stableMovedAt(git: Git, gh: Gh, repo: string, stable: string): {
|
||||
}
|
||||
|
||||
/** The floor verdict for moving stable from prevStable to sha. A refusal carries the message and the NEXT_ALLOWED text. */
|
||||
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
|
||||
export interface Floor { verdict: 'first' | 'finishing' | 'clear' | 'security' | 'regression' | 'refused' | 'unknown'; line: string; message?: string; next?: string }
|
||||
|
||||
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; security: boolean; scheduled: boolean; now: number }): Floor {
|
||||
/** A regression cut passes only a dated floor; an undatable or unpublished stable stays security-only. */
|
||||
export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: string | null; sha: string; exception: Exception['kind'] | null; scheduled: boolean; now: number }): Floor {
|
||||
if (!o.prevStable) return { verdict: 'first', line: 'FLOOR: none (no stable yet; this cut creates it)' };
|
||||
if (o.prevStable === o.sha) return { verdict: 'finishing', line: 'FLOOR: not applied (finishing the cut that already moved stable)' };
|
||||
const moved = stableMovedAt(o.git, o.gh, o.repo, o.prevStable);
|
||||
if (moved.at === null) {
|
||||
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
|
||||
if (o.exception === 'security') return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + moved.why + ')' };
|
||||
return { verdict: 'unknown', line: 'FLOOR: unknown', message: moved.why + '; only a --security cut can move it until one does', next: 'none until an annotated v<VERSION> tag dates the release stable points at' };
|
||||
}
|
||||
if (!moved.published && !o.security) {
|
||||
if (!moved.published && o.exception !== 'security') {
|
||||
return { verdict: 'unknown', line: 'FLOOR: unknown', message: 'stable moved to ' + moved.tag + ' but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)', next: 'once the release for ' + moved.tag + ' is published' };
|
||||
}
|
||||
// A tag dated in the future holds the floor from now, not from that date.
|
||||
@@ -266,10 +273,10 @@ export function floorStatus(o: { git: Git; gh: Gh; repo: string; prevStable: str
|
||||
const need = FLOOR_HOURS - (o.scheduled ? SCHEDULED_GRACE_HOURS : 0);
|
||||
const since = 'stable moved to ' + moved.tag + ' at ' + iso(moved.at) + ', ' + ((o.now - moved.at) / HOUR).toFixed(1) + ' h ago';
|
||||
const next = iso(moved.at + FLOOR_HOURS * HOUR) + ' for a hand-run cut; ' + iso(moved.at + (FLOOR_HOURS - SCHEDULED_GRACE_HOURS) * HOUR) + ' for a scheduled run';
|
||||
if (o.security) return { verdict: 'security', line: 'FLOOR: bypassed by the security exception (' + since + ')' };
|
||||
if (o.exception) return { verdict: o.exception, line: 'FLOOR: bypassed by the ' + o.exception + ' exception (' + since + ')' };
|
||||
const kind = o.scheduled ? 'a scheduled run needs ' + need + ' h (' + FLOOR_HOURS + ' h less ' + SCHEDULED_GRACE_HOURS + ' h cron grace)' : 'a hand-run cut needs ' + need + ' h';
|
||||
if (o.now - moved.at >= need * HOUR) return { verdict: 'clear', line: 'FLOOR: clear (' + since + '; ' + kind + ')' };
|
||||
return { verdict: 'refused', line: 'FLOOR: refused', message: since + '; ' + kind + '; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>', next };
|
||||
return { verdict: 'refused', line: 'FLOOR: refused', message: since + '; ' + kind + '; a security fix passes with --security "<GHSA-…|CVE-…|#n>: why" --ref <fix sha>, a regression fix with --regression "#n: worked in v<X>: why" --ref <fix sha>', next };
|
||||
}
|
||||
|
||||
/** One line, no control characters or backticks, at most 300 characters: the reason is public in the notes and tag. */
|
||||
@@ -288,11 +295,9 @@ export function sanitizeReason(reason: string): string {
|
||||
export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string }): { ok: true; evidence: string } | { ok: false; why: string } {
|
||||
const advisories = [...new Set([...o.reason.matchAll(/\bGHSA(?:-[2-9cfghjmpqrvwx]{4}){3}\b/gi)].map(m => 'GHSA' + m[0].slice(4).toLowerCase())
|
||||
.concat([...o.reason.matchAll(/\bCVE-\d{4}-\d{4,}\b/gi)].map(m => m[0].toUpperCase())))];
|
||||
const issues = [...new Set([...o.reason.matchAll(/(?<![\w&])#(\d+)\b/g)].map(m => m[1]!))];
|
||||
const issues = issueNumbers(o.reason);
|
||||
if (advisories.length === 0 && issues.length === 0) return { ok: false, why: 'the reason names no GHSA-…, CVE-… or #<n> to verify' };
|
||||
const range = o.from ? [o.from + '..' + o.to] : ['--max-count=' + WINDOW_COMMITS, o.to];
|
||||
const log = o.git(['log', '--format=%H%x1f%B%x1e', ...range]);
|
||||
const commits = (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
|
||||
const commits = spanCommits(o.git, o.from, o.to);
|
||||
// Package names whose own entry changed: a package.json dependency line or a bun.lock package key, never a name listed inside another package's entry.
|
||||
const changed = new Set((o.from ? o.git(['diff', '--unified=0', o.from, o.to, '--', 'package.json', 'bun.lock']).out.split('\n') : [])
|
||||
.flatMap(l => { const m = /^[+-](?![+-])\s*"([^"]+)":\s*(?:"[^"]*"|\[\s*"([^"]+)@)/.exec(l); return m && (!m[2] || m[2] === m[1]) ? [m[1]!] : []; }));
|
||||
@@ -307,31 +312,76 @@ export function verifySecurity(o: { reason: string; git: Git; gh: Gh; repo: stri
|
||||
if (bumped) return { ok: true, evidence: 'advisory ' + id + ' lists ' + bumped + ', which changes in package.json/bun.lock' };
|
||||
why.push(id + ': no commit names it' + (read ? ' and none of its ' + packages.length + ' npm advisory packages changes in package.json/bun.lock' : ' and the advisory database has no entry for it'));
|
||||
}
|
||||
for (const n of issues) {
|
||||
const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
|
||||
const issue = parseJson<{ labels?: Array<{ name?: string }> }>(o.gh(['api', 'repos/' + o.repo + '/issues/' + n]));
|
||||
const labelled = Boolean(issue?.labels?.some(l => l.name?.toLowerCase() === 'security'));
|
||||
if (ref && labelled) return { ok: true, evidence: '#' + n + ' carries the security label and commit ' + ref.sha + ' references it' };
|
||||
why.push('#' + n + ': ' + [!issue ? 'cannot read it' : !labelled ? 'no security label' : '', !ref ? 'no commit in the span references it' : ''].filter(Boolean).join(', '));
|
||||
}
|
||||
return { ok: false, why: 'nothing in ' + (o.from ? o.from.slice(0, 12) + '..' : 'up to ') + o.to.slice(0, 12) + ' ties the reason to the release: ' + why.join('; ') };
|
||||
const labelled = labelledReference(o.gh, o.repo, commits, issues, 'security');
|
||||
if (labelled.ok) return labelled;
|
||||
return { ok: false, why: 'nothing in ' + spanName(o.from, o.to) + ' ties the reason to the release: ' + [...why, ...labelled.why].join('; ') };
|
||||
}
|
||||
|
||||
/** The security reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
|
||||
export function securityFromTag(git: Git, tag: string): Security | null {
|
||||
/**
|
||||
* Ties a regression reason to the commits in (from, to]. A regression is a fix
|
||||
* for behavior that worked in an earlier stable release. The reason names
|
||||
* exactly one release v<X> and at least one #<n>; it is verified when v<X> is
|
||||
* an annotated release tag strictly behind the current stable with a published
|
||||
* GitHub Release (so team installs ran it and have since moved past it; a tag a
|
||||
* half-done cut left behind does not count), and issue or PR #<n> carries the
|
||||
* regression label (a maintainer's declaration: labelling needs triage rights)
|
||||
* and a commit in the span references #<n>. That the behavior really worked in
|
||||
* v<X> stays attested, in public, by the label and the reason.
|
||||
*/
|
||||
export function verifyRegression(o: { reason: string; git: Git; gh: Gh; repo: string; from: string | null; to: string; stable: string | null }): { ok: true; evidence: string } | { ok: false; why: string } {
|
||||
const releases = [...new Set([...o.reason.matchAll(/(?<![\w.])v(\d+(?:\.\d+)+)(?!\w|\.\d)/g)].map(m => 'v' + m[1]!))];
|
||||
const issues = issueNumbers(o.reason);
|
||||
if (releases.length !== 1 || issues.length === 0) return { ok: false, why: 'the reason must name exactly one earlier stable release v<X> the behavior worked in and the regression-labelled #<n> it fixes (named ' + releases.length + ' release' + (releases.length === 1 ? '' : 's') + ' and ' + issues.length + ' #<n>)' };
|
||||
if (!o.stable) return { ok: false, why: 'there is no stable release yet, so nothing earlier can have worked' };
|
||||
const tag = releases[0]!;
|
||||
const type = o.git(['for-each-ref', '--format=%(objecttype)', 'refs/tags/' + tag]);
|
||||
const worked = o.git(['rev-parse', '--verify', '-q', 'refs/tags/' + tag + '^{commit}']);
|
||||
if (!type.ok || type.out !== 'tag' || !worked.ok) return { ok: false, why: tag + ' is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in' };
|
||||
if (worked.out === o.stable || !o.git(['merge-base', '--is-ancestor', worked.out, o.stable]).ok) return { ok: false, why: tag + ' is not an earlier stable release: it must sit strictly behind the current stable ' + o.stable.slice(0, 12) + ', since a fix for something the current stable still does right needs no exception' };
|
||||
const release = o.gh(['release', 'view', tag, '--repo', o.repo, '--json', 'isDraft']);
|
||||
const published = release.status === 0 ? parseJson<{ isDraft?: boolean }>(release) : null;
|
||||
if (!published || published.isDraft === true) return { ok: false, why: tag + ' has no published release, so team installs never ran it as stable' };
|
||||
const labelled = labelledReference(o.gh, o.repo, spanCommits(o.git, o.from, o.to), issues, 'regression');
|
||||
if (!labelled.ok) return { ok: false, why: 'nothing in ' + spanName(o.from, o.to) + ' ties the reason to the release: ' + labelled.why.join('; ') };
|
||||
return { ok: true, evidence: labelled.evidence + '; ' + tag + ' (' + worked.out.slice(0, 12) + ') is an earlier stable release' };
|
||||
}
|
||||
|
||||
const issueNumbers = (reason: string) => [...new Set([...reason.matchAll(/(?<![\w&])#(\d+)\b/g)].map(m => m[1]!))];
|
||||
const spanName = (from: string | null, to: string) => (from ? from.slice(0, 12) + '..' : 'up to ') + to.slice(0, 12);
|
||||
|
||||
function spanCommits(git: Git, from: string | null, to: string): Array<{ sha: string; body: string }> {
|
||||
const log = git(['log', '--format=%H%x1f%B%x1e', ...(from ? [from + '..' + to] : ['--max-count=' + WINDOW_COMMITS, to])]);
|
||||
return (log.ok ? log.out.split('\x1e') : []).map(c => c.trim().split('\x1f')).filter(c => c[0]).map(c => ({ sha: c[0]!.slice(0, 12), body: c[1] ?? '' }));
|
||||
}
|
||||
|
||||
/** The first #<n> carrying the label that a span commit references; each miss says why. */
|
||||
function labelledReference(gh: Gh, repo: string, commits: Array<{ sha: string; body: string }>, issues: string[], label: string): { ok: true; evidence: string } | { ok: false; why: string[] } {
|
||||
const why: string[] = [];
|
||||
for (const n of issues) {
|
||||
const ref = commits.find(c => new RegExp('(?<![\\w&])#' + n + '\\b').test(c.body));
|
||||
const issue = parseJson<{ labels?: Array<{ name?: string }> }>(gh(['api', 'repos/' + repo + '/issues/' + n]));
|
||||
const labelled = Boolean(issue?.labels?.some(l => l.name?.toLowerCase() === label));
|
||||
if (ref && labelled) return { ok: true, evidence: '#' + n + ' carries the ' + label + ' label and commit ' + ref.sha + ' references it' };
|
||||
why.push('#' + n + ': ' + [!issue ? 'cannot read it' : !labelled ? 'no ' + label + ' label' : '', !ref ? 'no commit in the span references it' : ''].filter(Boolean).join(', '));
|
||||
}
|
||||
return { ok: false, why };
|
||||
}
|
||||
|
||||
/** The exception reason and evidence an earlier run wrote into this release's annotated tag, so a retry publishes the same notes. */
|
||||
export function exceptionFromTag(git: Git, tag: string): Exception | null {
|
||||
const body = git(['for-each-ref', '--format=%(contents)', 'refs/tags/' + tag]);
|
||||
const reason = body.ok ? /^Security: (.+)$/m.exec(body.out) : null;
|
||||
const reason = body.ok ? /^(Security|Regression): (.+)$/m.exec(body.out) : null;
|
||||
if (!reason) return null;
|
||||
return { reason: reason[1]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
|
||||
return { kind: reason[1] === 'Security' ? 'security' : 'regression', reason: reason[2]!.trim(), evidence: /^Verified: (.+)$/m.exec(body.out)?.[1]!.trim() ?? 'recorded in tag ' + tag };
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
sha: string; version: string; tag: string; date: string; hotfix: boolean; via: string;
|
||||
prevStable: string | null; prevVersion: string | null; prevTag: string | null;
|
||||
tagExists: 'no' | 'same' | 'other'; stableAt: boolean; notes: string; skipped: string[];
|
||||
security: Security | null; floor: Floor; ridingAlong: number;
|
||||
exception: Exception | null; floor: Floor; ridingAlong: number;
|
||||
}
|
||||
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY';
|
||||
export type RefusalCode = 'NOTHING_NEW' | 'NO_GREEN_COMMIT' | 'REFUSED' | 'REFUSED_FLOOR' | 'FLOOR_UNKNOWN' | 'REFUSED_SECURITY' | 'REFUSED_REGRESSION';
|
||||
export type PlanResult = { ok: true; plan: Plan } | { ok: false; code: RefusalCode; message: string; skipped?: string[]; next?: string; plan?: Plan };
|
||||
|
||||
export interface PlanOptions {
|
||||
@@ -341,13 +391,19 @@ export interface PlanOptions {
|
||||
scheduled?: boolean;
|
||||
/** --security <reason>: bypass the floor for a verified security fix; needs ref. */
|
||||
security?: string;
|
||||
/** --regression <reason>: bypass a dated floor for a verified regression fix; needs ref. */
|
||||
regression?: string;
|
||||
}
|
||||
|
||||
const refusalFor = (kind: Exception['kind']): RefusalCode => (kind === 'security' ? 'REFUSED_SECURITY' : 'REFUSED_REGRESSION');
|
||||
|
||||
export function planRelease(o: PlanOptions): PlanResult {
|
||||
const { git, gh, repo } = o;
|
||||
const windowDays = o.windowDays ?? WINDOW_DAYS;
|
||||
const skipped: string[] = [];
|
||||
if (o.security !== undefined && !o.ref) return { ok: false, code: 'REFUSED_SECURITY', message: '--security needs --ref <the fix commit>: a security cut releases up to the fix, never whatever is newest' };
|
||||
if (o.security !== undefined && o.regression !== undefined) return { ok: false, code: 'REFUSED_REGRESSION', message: '--security and --regression together: a cut carries one exception, so its notes make one claim' };
|
||||
const asked: Exception['kind'] | null = o.security !== undefined ? 'security' : o.regression !== undefined ? 'regression' : null;
|
||||
if (asked && !o.ref) return { ok: false, code: refusalFor(asked), message: '--' + asked + ' needs --ref <the fix commit>: a ' + asked + ' cut releases up to the fix, never whatever is newest' };
|
||||
const mainHead = git(['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}']);
|
||||
if (!mainHead.ok) return { ok: false, code: 'REFUSED', message: 'origin/main is not fetched: ' + mainHead.out };
|
||||
const stable = git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/stable^{commit}']);
|
||||
@@ -424,23 +480,24 @@ export function planRelease(o: PlanOptions): PlanResult {
|
||||
const older = git(['rev-list', '--first-parent', '-1', '--before=' + before, sha]);
|
||||
if (older.ok && older.out) prevVersion = git(['show', older.out + ':VERSION']).out.trim() || null;
|
||||
}
|
||||
// Finishing a security cut keeps the reason and evidence its tag recorded; a tag
|
||||
// that recorded one but never moved stable needs --security verified again.
|
||||
const recorded = tagExists === 'same' ? securityFromTag(git, tag) : null;
|
||||
let security: Security | null = recorded && prevStable === sha ? recorded : null;
|
||||
if (recorded && !security && o.security === undefined) return { ok: false, code: 'REFUSED_SECURITY', message: 'tag ' + tag + ' records a security cut that has not moved stable; run it again with --security and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
|
||||
if (!security && o.security !== undefined) {
|
||||
const reason = sanitizeReason(o.security);
|
||||
const verified = verifySecurity({ reason, git, gh, repo, from: before, to: sha });
|
||||
if (!verified.ok) return { ok: false, code: 'REFUSED_SECURITY', message: verified.why };
|
||||
security = { reason, evidence: verified.evidence };
|
||||
// Finishing an exception cut keeps the reason and evidence its tag recorded; a
|
||||
// tag that recorded one but never moved stable needs that same kind verified again.
|
||||
const recorded = tagExists === 'same' ? exceptionFromTag(git, tag) : null;
|
||||
let exception: Exception | null = recorded && prevStable === sha ? recorded : null;
|
||||
if (recorded && !exception && asked !== recorded.kind) return { ok: false, code: refusalFor(recorded.kind), message: 'tag ' + tag + ' records a ' + recorded.kind + ' cut that has not moved stable; run it again with --' + recorded.kind + ' and --ref ' + sha.slice(0, 12) + ' so the reason is verified' };
|
||||
if (!exception && asked) {
|
||||
const reason = sanitizeReason((asked === 'security' ? o.security : o.regression)!);
|
||||
if (recorded && recorded.reason !== reason) return { ok: false, code: refusalFor(asked), message: 'tag ' + tag + ' records the ' + asked + ' reason "' + recorded.reason + '"; run it again with that same reason so the tag and the notes agree' };
|
||||
const verified = asked === 'security' ? verifySecurity({ reason, git, gh, repo, from: before, to: sha }) : verifyRegression({ reason, git, gh, repo, from: before, to: sha, stable: prevStable });
|
||||
if (!verified.ok) return { ok: false, code: refusalFor(asked), message: verified.why };
|
||||
exception = { kind: asked, reason, evidence: verified.evidence };
|
||||
}
|
||||
const changelog = git(['show', sha + ':CHANGELOG.md']);
|
||||
const date = git(['show', '-s', '--format=%cs', sha]).out;
|
||||
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, security });
|
||||
const floor = floorStatus({ git, gh, repo, prevStable, sha, security: Boolean(security), scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
|
||||
const notes = releaseNotes({ repo, version, date, prevVersion, prevTag, entries: changelog.ok ? changelogEntries(changelog.out) : [], hotfix: Boolean(o.ref), first: !before, exception });
|
||||
const floor = floorStatus({ git, gh, repo, prevStable, sha, exception: exception?.kind ?? null, scheduled: Boolean(o.scheduled), now: o.now?.() ?? Date.now() });
|
||||
const ridingAlong = Math.max(0, notes.split('\n').filter(l => l.startsWith('- **')).length - 1);
|
||||
const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, security, floor, ridingAlong };
|
||||
const plan: Plan = { sha, version, tag, date, hotfix: Boolean(o.ref), via, prevStable, prevVersion, prevTag, tagExists, stableAt: prevStable === sha, notes, skipped, exception, floor, ridingAlong };
|
||||
if (floor.verdict === 'refused' || floor.verdict === 'unknown') return { ok: false, code: floor.verdict === 'refused' ? 'REFUSED_FLOOR' : 'FLOOR_UNKNOWN', message: floor.message!, next: floor.next, plan };
|
||||
return { ok: true, plan };
|
||||
}
|
||||
@@ -454,7 +511,7 @@ export function cutRelease(p: Plan, o: { repo: string; git: Git; gh: Gh; notesDi
|
||||
const existing = git(['rev-parse', '--verify', '-q', 'refs/tags/' + p.tag + '^{commit}']);
|
||||
if (existing.ok && existing.out !== p.sha) return { ok: false, lines: ['TAG: ' + p.tag + ' already names ' + existing.out.slice(0, 12) + ', not ' + p.sha.slice(0, 12)] };
|
||||
if (!existing.ok) {
|
||||
const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.security) + ' release)' + (p.security ? '\n\nSecurity: ' + p.security.reason + '\nVerified: ' + p.security.evidence : '');
|
||||
const message = 'gstack ' + p.tag + ' (' + releaseKind(p.hotfix, p.exception) + ' release)' + (p.exception ? '\n\n' + KIND_LABEL[p.exception.kind] + ': ' + p.exception.reason + '\nVerified: ' + p.exception.evidence : '');
|
||||
const t = git(['tag', '-a', p.tag, p.sha, '-m', message]);
|
||||
if (!t.ok) return { ok: false, lines: [...lines, 'TAG: failed: ' + t.out] };
|
||||
const push = git(['push', 'origin', 'refs/tags/' + p.tag]);
|
||||
@@ -494,7 +551,7 @@ export function describePlan(p: Plan, dryRun: boolean): string[] {
|
||||
'NOTES: ' + p.notes.split('\n').filter(l => l.startsWith('- ')).length + ' version lines, ' + p.notes.length + ' chars',
|
||||
p.floor.line,
|
||||
];
|
||||
if (p.security) lines.push('SECURITY: `' + p.security.reason + '` (verified: ' + p.security.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
|
||||
if (p.exception) lines.push(p.exception.kind.toUpperCase() + ': `' + p.exception.reason + '` (verified: ' + p.exception.evidence + ')', 'RIDING_ALONG: ' + p.ridingAlong + ' other version' + (p.ridingAlong === 1 ? '' : 's') + ' ship with the fix');
|
||||
for (const s of p.skipped) lines.push('SKIPPED: ' + s);
|
||||
if (dryRun) {
|
||||
lines.push('DRY_RUN: nothing pushed, nothing created. The cut would run:',
|
||||
@@ -515,8 +572,10 @@ if (import.meta.main) {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const summary = arg('--summary');
|
||||
const security = arg('--security');
|
||||
if (!repo || (process.argv.includes('--security') && (!security || security.startsWith('--')))) {
|
||||
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
|
||||
const regression = arg('--regression');
|
||||
const empty = (flag: string, value: string | undefined) => process.argv.includes(flag) && (!value || value.startsWith('--'));
|
||||
if (!repo || empty('--security', security) || empty('--regression', regression)) {
|
||||
console.error('usage: release-cut.ts --repo owner/name [--ref <sha>] [--security <reason naming GHSA-…|CVE-…|#n> | --regression <reason naming #n and the v<X> it worked in>] [--dry-run] [--summary <file>] [--required-check <name>] [--backstop-check <name>] [--notes-ref <ref>]');
|
||||
process.exit(2);
|
||||
}
|
||||
const scheduled = process.env.GITHUB_EVENT_NAME === 'schedule';
|
||||
@@ -525,7 +584,7 @@ if (import.meta.main) {
|
||||
for (const l of lines) console.log(l);
|
||||
if (summary) fs.appendFileSync(summary, lines.map(l => '- ' + l).join('\n') + '\n');
|
||||
};
|
||||
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security });
|
||||
const planned = planRelease({ repo, ref: arg('--ref'), git, gh: runGh, requiredCheck: arg('--required-check'), backstopCheck: arg('--backstop-check'), notesRef: arg('--notes-ref'), scheduled, security, regression });
|
||||
if (!planned.ok) {
|
||||
emit([planned.code + ': ' + planned.message, ...(planned.next ? ['NEXT_ALLOWED: ' + planned.next] : []), ...(planned.skipped ?? []).map(s => 'SKIPPED: ' + s), ...(planned.plan ? describePlan(planned.plan, false) : [])]);
|
||||
// A dry run that finds nothing green has done its job (the skips say why),
|
||||
|
||||
@@ -507,6 +507,20 @@ describe('gstack-doctor --check', () => {
|
||||
expect(probeCalls(f)).not.toContain('probe-model');
|
||||
});
|
||||
|
||||
// Value: protects=the --check state-root row names the variable that selected the root, after earlier rows read config;
|
||||
// fails_when=an in-process config read reselects the root from the doctor's own GSTACK_STATE_ROOT;
|
||||
// why_new=every other fixture sets GSTACK_STATE_ROOT itself, so a wrong "selected by" still reads GSTACK_STATE_ROOT; seam=none;
|
||||
// no_claim=CLAUDE_PLUGIN_DATA and default selection (GSTACK_HOME stands in for every selector other than GSTACK_STATE_ROOT)
|
||||
test('the state-root row names GSTACK_HOME when GSTACK_HOME selected the root, in text and JSON', () => {
|
||||
const f = makeFixture();
|
||||
installCodex(f);
|
||||
const byHome = { GSTACK_STATE_ROOT: '', GSTACK_HOME: f.state };
|
||||
const r = row(f, [], byHome);
|
||||
expect(r.rows['state-root'].rest, r.out).toContain('(selected by GSTACK_HOME;');
|
||||
const json = JSON.parse(doctor(f, ['--json'], byHome).out);
|
||||
expect(json.rows.find((x: { id: string }) => x.id === 'state-root').detail).toContain('(selected by GSTACK_HOME;');
|
||||
});
|
||||
|
||||
test('--for autoplan: healthy Capy machine with Claude subagents as the outside voice and no Codex CLI passes', () => {
|
||||
const f = makeFixture();
|
||||
installCodex(f);
|
||||
|
||||
@@ -51,6 +51,14 @@ describe.skipIf(process.platform === 'win32')('gstack-launch-probe.sh classifica
|
||||
expect(probe(fakeBinary('echo "Unknown argument: --version" >&2; exit 3'))).toEqual({ state: 'broken', detail: 'exit 3: Unknown argument: --version' });
|
||||
});
|
||||
|
||||
// Value: protects=the detail is the first non-blank line with CRs dropped, cut to 200 bytes (Windows CRLF output);
|
||||
// fails_when=the builtin line picker keeps a CR, returns a blank line or cuts at another length;
|
||||
// why_new=every other case prints one clean LF line; seam=none; no_claim=real Git Bash output (a sh fake prints the bytes)
|
||||
test('a broken binary\'s detail is its first non-blank line, CRs dropped, at most 200 bytes', () => {
|
||||
const long = 'y'.repeat(250);
|
||||
expect(probe(fakeBinary(`printf '\\r\\n \\n\\t\\r\\n${long}\\r\\nnext\\n'; exit 3`))).toEqual({ state: 'broken', detail: `exit 3: ${'y'.repeat(200)}` });
|
||||
});
|
||||
|
||||
test('an absent binary is missing', () => {
|
||||
expect(probe(fakeBinary(null)).state).toBe('missing');
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
BLOCK_BEGIN, BLOCK_END, CONTEXT_BLOCK_MAX, DOT_SKILLS_DIR, HARNESS_CODES, HarnessError, ROUTER_DESCRIPTION, ROUTER_DESCRIPTION_MAX, TARGETS,
|
||||
contextBlock, detect, listLedgers, readLedger, register, unregister, type RegisterOptions,
|
||||
} from '../lib/harness-register';
|
||||
import { qmRequest, registerQm, removeQmPack } from '../lib/harness-qm';
|
||||
import { QM_PACK_LIMITS, qmRequest, registerQm, removeQmPack } from '../lib/harness-qm';
|
||||
import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup, tree } from './helpers/install-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -216,6 +216,18 @@ describe('gstack-harness register and unregister (skill folders)', () => {
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('bin/gstack-harness CLI error path', () => {
|
||||
test('a refused command prints its reason code and fix, never a TypeError from the error printer', () => {
|
||||
const d = tmp();
|
||||
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-harness'), 'unregister', 'nothing-here'],
|
||||
{ env: { ...process.env, GSTACK_STATE_ROOT: path.join(d, 'state'), HOME: path.join(d, 'home') }, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(r.status, r.stderr).toBe(1);
|
||||
expect(r.stderr).toContain('gstack-harness: no registration named "nothing-here"');
|
||||
expect(r.stderr).toContain('(HARNESS_NOT_REGISTERED; docs/troubleshooting.md#harness-not-registered)');
|
||||
expect(r.stderr).not.toContain('TypeError');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('bin/gstack-harness-ledger.sh (the bun-free reader gstack-uninstall sources)', () => {
|
||||
const ledgerSh = path.join(ROOT, 'bin', 'gstack-harness-ledger.sh');
|
||||
test('removes every registration with only bash and coreutils on PATH, and refuses without a state root', () => {
|
||||
@@ -287,19 +299,66 @@ describe('gstack-harness QM (org skill pack, against a fake admin API)', () => {
|
||||
expect(qmRequest('abc')).toContain('"ref":"abc"');
|
||||
});
|
||||
|
||||
/** A small committed repository standing in for a pack under QM's cap. */
|
||||
function smallCheckout(): string {
|
||||
const dir = tmp();
|
||||
const git = (...args: string[]) => spawnSync('git', ['-C', dir, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8', timeout: 30_000 });
|
||||
git('init', '-q');
|
||||
fs.mkdirSync(path.join(dir, 'review'));
|
||||
fs.writeFileSync(path.join(dir, 'review', 'SKILL.md'), '---\nname: review\ndescription: x\n---\nx\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', 'seed');
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('refuses before sending anything when the repository is over QM\'s pack cap, and names the cap QM recorded when it refuses, without a retry', async () => {
|
||||
const q = fakeQm();
|
||||
try {
|
||||
const d = tmp();
|
||||
const err = await registerQm({ checkout: ROOT, stateRoot: path.join(d, 'state'), env: q.env, version: VERSION, yes: true }).catch(e => e);
|
||||
expect(err.code).toBe('HARNESS_QM_PACK_TOO_LARGE');
|
||||
expect(err.message).toContain('nothing was sent to QM');
|
||||
expect(err.message).toContain(String(QM_PACK_LIMITS.files));
|
||||
expect(q.calls).toEqual([]);
|
||||
expect(fs.existsSync(path.join(d, 'state', 'harness'))).toBe(false);
|
||||
} finally { q.server.stop(true); }
|
||||
const recorded: Array<Record<string, unknown>> = [];
|
||||
const imports: string[] = [];
|
||||
const capped = Bun.serve({ port: 0, fetch: async (req) => {
|
||||
const u = new URL(req.url);
|
||||
if (req.method === 'GET') return Response.json({ packs: recorded });
|
||||
if (u.pathname.endsWith('/import')) {
|
||||
imports.push(u.pathname);
|
||||
recorded[0].lastImport = { status: 'error', error: 'pack exceeds max bytes (33554432)' };
|
||||
return Response.json({ error: 'internal', message: 'internal server error' }, { status: 500 });
|
||||
}
|
||||
const b = await req.json() as Record<string, unknown>;
|
||||
recorded.push({ id: 'pk1', url: b.url, ref: b.ref });
|
||||
return Response.json({ pack: recorded[0] });
|
||||
} });
|
||||
try {
|
||||
const d = tmp();
|
||||
const err = await registerQm({ checkout: smallCheckout(), stateRoot: path.join(d, 'state'), env: { GSTACK_QM_URL: 'http://127.0.0.1:' + capped.port, GSTACK_QM_ADMIN_TOKEN: 't0k' }, version: VERSION, yes: true }).catch(e => e);
|
||||
expect(err.code).toBe('HARNESS_QM_PACK_TOO_LARGE');
|
||||
expect(err.message).toContain('pack exceeds max bytes (33554432)');
|
||||
expect(imports).toHaveLength(1);
|
||||
} finally { capped.stop(true); }
|
||||
});
|
||||
|
||||
test('registers once, a retry after a failed import reuses the pack, two machines share it, --remote archives it', async () => {
|
||||
const q = fakeQm(1);
|
||||
const co = smallCheckout();
|
||||
try {
|
||||
const a = tmp(); const b = tmp();
|
||||
const r1 = await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true });
|
||||
const r1 = await registerQm({ checkout: co, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true });
|
||||
expect(q.packs.length).toBe(1);
|
||||
expect(q.calls.filter(c => c === 'POST /v1/admin/skill-packs').length).toBe(1);
|
||||
expect(q.calls.filter(c => c.endsWith('/import')).length).toBe(2);
|
||||
const ledger = fs.readFileSync(path.join(a, 'state', 'harness', r1.id + '.ledger'), 'utf8');
|
||||
expect(ledger).toContain('qm_pack\tpk1');
|
||||
expect(ledger).not.toContain('t0k');
|
||||
expect((await registerQm({ checkout: ROOT, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true })).status).toBe('unchanged');
|
||||
await registerQm({ checkout: ROOT, stateRoot: path.join(b, 'state'), env: q.env, version: VERSION, yes: true });
|
||||
expect((await registerQm({ checkout: co, stateRoot: path.join(a, 'state'), env: q.env, version: VERSION, yes: true })).status).toBe('unchanged');
|
||||
await registerQm({ checkout: co, stateRoot: path.join(b, 'state'), env: q.env, version: VERSION, yes: true });
|
||||
expect(q.packs.length).toBe(1);
|
||||
expect(await removeQmPack(path.join(a, 'state'), q.env, r1.id)).toBe('pk1');
|
||||
expect(q.packs.length).toBe(0);
|
||||
|
||||
@@ -222,8 +222,13 @@ describe('test_backend config', () => {
|
||||
expect(cfg('set', 'test_backend', 'mars').code).not.toBe(0);
|
||||
expect(cfg('get', 'test_backend').out).toBe('ubicloud');
|
||||
expect(cfg('defaults').out).toContain('test_backend');
|
||||
const coresRow = (root: string) => {
|
||||
const r = spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-doctor'), '--check', '--require', 'cores'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: root, GSTACK_HOME: root } });
|
||||
return (r.stdout ?? '').split('\n').find(line => line.startsWith('PASS cores ')) ?? `no cores row: ${r.stdout}${r.stderr}`;
|
||||
};
|
||||
expect(coresRow(state)).toContain('test_backend=ubicloud');
|
||||
expect(coresRow(tmp('gstack-test-backend-unset-'))).toContain('test_backend=local');
|
||||
const doctor = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-doctor-check.sh'), 'utf8');
|
||||
expect(doctor).toContain('gstack-config" get test_backend');
|
||||
expect(doctor).toContain('set gstack-config set test_backend ubicloud, scripts/ubicloud/ubi-runner.sh');
|
||||
});
|
||||
});
|
||||
+118
-11
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* release-cut (free): the weekly release picks the newest green commit on
|
||||
* release-cut (free): the stable release picks the newest green commit on
|
||||
* main's first-parent history, tags it v<VERSION>, fast-forwards stable and
|
||||
* publishes notes from the CHANGELOG headlines; a hotfix --ref promotes a
|
||||
* named main commit; stable never moves backwards; a frozen queue, a pending
|
||||
@@ -14,7 +14,7 @@ import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import {
|
||||
BACKSTOP_CHECK, FLOOR_HOURS, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, SCHEDULED_GRACE_HOURS, changelogEntries, compareVersions, cutRelease, describePlan, floorStatus,
|
||||
gateVerdict, gitIn, planRelease, readTrainNotes, releaseNotes, sanitizeReason, stableMovedAt, verifySecurity, type Gh, type Git,
|
||||
gateVerdict, gitIn, planRelease, readTrainNotes, releaseNotes, sanitizeReason, stableMovedAt, verifyRegression, verifySecurity, type Gh, type Git,
|
||||
} from '../scripts/release-cut';
|
||||
|
||||
const bases: string[] = [];
|
||||
@@ -624,7 +624,7 @@ describe('release-cut: the 72-hour floor', () => {
|
||||
// Value: protects=FLOOR_UNKNOWN's promise that a verified --security cut still moves an undatable stable; fails_when=the unknown branch returns before
|
||||
// the security check, stranding a security fix until someone hand-tags stable; why_new=the security test always starts from a dated stable; seam=none;
|
||||
// no_claim=verifySecurity (floorStatus takes the verified flag)
|
||||
expect(floorStatus({ git: f.git, gh: f.gh, repo: f.repo, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.3']!, security: true, scheduled: false, now: pastFloor() }))
|
||||
expect(floorStatus({ git: f.git, gh: f.gh, repo: f.repo, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.3']!, exception: 'security', scheduled: false, now: pastFloor() }))
|
||||
.toEqual({ verdict: 'security', line: 'FLOOR: bypassed by the security exception (no annotated v1.0.2 tag points at stable ' + f.shas['1.0.2']!.slice(0, 12) + ', so its last move cannot be dated)' });
|
||||
|
||||
git(f.seed, 'push', '-q', '--delete', 'origin', 'refs/tags/v1.0.2');
|
||||
@@ -640,7 +640,7 @@ describe('release-cut: the 72-hour floor', () => {
|
||||
expect(held.ok).toBe(false);
|
||||
if (!held.ok) expect(held.next).toStartWith('2026-10-24T09:30:00Z for a hand-run cut');
|
||||
|
||||
const base = { git: f.git, gh: f.gh, repo: f.repo, security: false, scheduled: false, now: Date.now() };
|
||||
const base = { git: f.git, gh: f.gh, repo: f.repo, exception: null, scheduled: false, now: Date.now() };
|
||||
expect(floorStatus({ ...base, prevStable: null, sha: f.shas['1.0.3']! }).verdict).toBe('first');
|
||||
expect(floorStatus({ ...base, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.2']! }).verdict).toBe('finishing');
|
||||
|
||||
@@ -715,7 +715,7 @@ describe('release-cut: the 72-hour floor', () => {
|
||||
expect(past.ok).toBe(false);
|
||||
if (!past.ok) { expect(past.code).toBe('FLOOR_UNKNOWN'); expect(past.message).toBe('stable moved to v1.0.4 but its release is not published, so the tag cannot date the move; finish that cut first (a run without --ref finishes it)'); }
|
||||
|
||||
const future = floorStatus({ git: f.git, gh: (args) => args[0] === 'release' ? { status: 0, stdout: JSON.stringify({ publishedAt: '2099-01-01T00:00:00Z' }), stderr: '' } : f.gh(args), repo: f.repo, prevStable: hot, sha: newer, security: false, scheduled: false, now: at });
|
||||
const future = floorStatus({ git: f.git, gh: (args) => args[0] === 'release' ? { status: 0, stdout: JSON.stringify({ publishedAt: '2099-01-01T00:00:00Z' }), stderr: '' } : f.gh(args), repo: f.repo, prevStable: hot, sha: newer, exception: null, scheduled: false, now: at });
|
||||
expect(future.verdict).toBe('refused');
|
||||
expect(future.next).toStartWith(isoZ(at + 72 * H) + ' for a hand-run cut');
|
||||
});
|
||||
@@ -788,20 +788,20 @@ describe('release-cut: the security exception', () => {
|
||||
f.state.api['repos/o/r/issues/77'] = { labels: [{ name: 'Security' }] };
|
||||
const byLabel = sec(labelled, '#77: summary injection');
|
||||
expect(byLabel.ok, JSON.stringify(byLabel)).toBe(true);
|
||||
if (byLabel.ok) { expect(byLabel.plan.security!.evidence).toBe('#77 carries the security label and commit ' + labelled.slice(0, 12) + ' references it'); expect(byLabel.plan.ridingAlong).toBe(1); }
|
||||
if (byLabel.ok) { expect(byLabel.plan.exception!.evidence).toBe('#77 carries the security label and commit ' + labelled.slice(0, 12) + ' references it'); expect(byLabel.plan.ridingAlong).toBe(1); }
|
||||
const labelOnly = sec(labelled, '#79');
|
||||
if (!labelOnly.ok) expect(labelOnly.message).toEndWith('#79: no commit in the span references it');
|
||||
for (const id of ['GHSA-9999-xxxx-wwww', 'CVE-2026-55555']) {
|
||||
const byAdvisory = sec(bump, id);
|
||||
expect(byAdvisory.ok, JSON.stringify(byAdvisory)).toBe(true);
|
||||
if (byAdvisory.ok) expect(byAdvisory.plan.security!.evidence).toBe('advisory ' + id + ' lists left-pad, which changes in package.json/bun.lock');
|
||||
if (byAdvisory.ok) expect(byAdvisory.plan.exception!.evidence).toBe('advisory ' + id + ' lists left-pad, which changes in package.json/bun.lock');
|
||||
}
|
||||
|
||||
const verified = sec(fix, 'ghsa-2345-CFGH-jmpq: tar\npath traversal \`x\`');
|
||||
expect(verified.ok, JSON.stringify(verified)).toBe(true);
|
||||
if (!verified.ok) return;
|
||||
const reason = 'ghsa-2345-CFGH-jmpq: tar path traversal ' + "'x'";
|
||||
expect(verified.plan.security).toEqual({ reason, evidence: 'commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq' });
|
||||
expect(verified.plan.exception).toEqual({ kind: 'security', reason, evidence: 'commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq' });
|
||||
expect(verified.plan.floor.verdict).toBe('security');
|
||||
expect(verified.plan.ridingAlong).toBe(0);
|
||||
expect(verified.plan.notes).toContain('## gstack v1.0.4 (security release)\n\n**Security release:** \`' + reason + '\` (verified: commit ' + fix.slice(0, 12) + ' names GHSA-2345-cfgh-jmpq).');
|
||||
@@ -817,7 +817,7 @@ describe('release-cut: the security exception', () => {
|
||||
expect(retry.ok, JSON.stringify(retry)).toBe(true);
|
||||
if (!retry.ok) return;
|
||||
expect(retry.plan.stableAt).toBe(true);
|
||||
expect(retry.plan.security!.reason).toBe(reason);
|
||||
expect(retry.plan.exception!.reason).toBe(reason);
|
||||
expect(retry.plan.notes).toBe(verified.plan.notes);
|
||||
expect(cutRelease(retry.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true);
|
||||
f.refresh();
|
||||
@@ -832,7 +832,7 @@ describe('release-cut: the security exception', () => {
|
||||
const next = planRelease({ repo: f.repo, git: f.git, gh: f.gh, scheduled: true, now: () => movedAt + 69 * H });
|
||||
expect(next.ok, JSON.stringify(next)).toBe(true);
|
||||
if (!next.ok) return;
|
||||
expect(next.plan.security).toBeNull();
|
||||
expect(next.plan.exception).toBeNull();
|
||||
expect(next.plan.prevTag).toBe('v1.0.4');
|
||||
expect(next.plan.notes).toContain('## gstack v1.0.6 (regular release)');
|
||||
expect(next.plan.notes).toContain('2 versions since v1.0.4');
|
||||
@@ -860,7 +860,7 @@ describe('release-cut: the security exception', () => {
|
||||
f.state.api = {};
|
||||
const finish = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => at + 2 * H, scheduled: true });
|
||||
expect(finish.ok, JSON.stringify(finish)).toBe(true);
|
||||
if (finish.ok) expect(finish.plan.security).toEqual(sec.plan.security);
|
||||
if (finish.ok) expect(finish.plan.exception).toEqual(sec.plan.exception);
|
||||
|
||||
// A security-recorded tag at a commit stable never reached.
|
||||
const later = f.release('1.0.5', 'Later.', { message: 'v1.0.5 (#78)' });
|
||||
@@ -889,6 +889,108 @@ describe('release-cut: the security exception', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('release-cut: the regression exception', () => {
|
||||
// Value: protects=Garry's "Regression" (2026-10-28): a fix for behavior that worked in an earlier stable release passes the floor only with --ref, a
|
||||
// regression-labelled #n a released commit references, and exactly one annotated v<X> strictly behind the current stable; it is recorded in the tag and
|
||||
// notes like a security cut, a retry recovers it, labels do not cross kinds, and the floor restarts from it; fails_when=a free-text claim, a main-only
|
||||
// break (v<X> == stable), an unknown or lightweight tag, a security label or an unreferenced #n bypasses the floor, or the record is lost; why_new=no
|
||||
// regression exception existed; seam=gh issues are faked; no_claim=that the behavior really worked in v<X> (attested by the label and the reason)
|
||||
test('verified regression reasons bypass a dated floor; every unverifiable claim is REFUSED_REGRESSION; the record survives a retry and restarts the floor', () => {
|
||||
const f = fixture();
|
||||
const first = cutFirst(f);
|
||||
f.release('1.0.4', 'Regular work.');
|
||||
f.refresh();
|
||||
const second = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => first + 73 * H });
|
||||
expect(second.ok, JSON.stringify(second)).toBe(true);
|
||||
if (!second.ok) return;
|
||||
expect(cutRelease(second.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true);
|
||||
f.refresh();
|
||||
const moved = stableMovedAt(f.git, f.gh, f.repo, f.shas['1.0.4']!);
|
||||
if (moved.at === null) throw new Error(moved.why);
|
||||
const fix = f.release('1.0.5', 'Restores the update check.', { message: 'v1.0.5: restore the update check (#91)' });
|
||||
git(f.seed, 'tag', 'v1.0.1', f.shas['1.0.1']!);
|
||||
execFileSync('git', ['tag', '-a', 'v1.0.2', f.shas['1.0.2']!, '-m', 'gstack v1.0.2 (regular release)'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
|
||||
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.1', 'refs/tags/v1.0.2');
|
||||
f.refresh();
|
||||
const now = () => moved.at + H;
|
||||
const reg = (ref: string | undefined, reason: string, extra: { security?: string } = {}) => planRelease({ repo: f.repo, ref, git: f.git, gh: f.gh, now, regression: reason, ...extra });
|
||||
const reason = '#91: the update check stopped running; it worked in v1.0.3.';
|
||||
const refused = (r: ReturnType<typeof reg>) => { expect(r.ok).toBe(false); return r.ok ? '' : (expect(r.code).toBe('REFUSED_REGRESSION'), r.message); };
|
||||
|
||||
expect(refused(reg(undefined, reason))).toBe('--regression needs --ref <the fix commit>: a regression cut releases up to the fix, never whatever is newest');
|
||||
expect(refused(reg(fix, reason, { security: 'CVE-2026-1: x' }))).toBe('--security and --regression together: a cut carries one exception, so its notes make one claim');
|
||||
expect(refused(reg(fix, '#91: the update check broke'))).toBe('the reason must name exactly one earlier stable release v<X> the behavior worked in and the regression-labelled #<n> it fixes (named 0 releases and 1 #<n>)');
|
||||
expect(refused(reg(fix, '#91: worked in v1.0.3 and v1.0.2'))).toContain('(named 2 releases and 1 #<n>)');
|
||||
expect(refused(reg(fix, 'worked in v1.0.3'))).toContain('(named 1 release and 0 #<n>)');
|
||||
expect(refused(reg(fix, '#91: worked in v1.0.4'))).toBe('v1.0.4 is not an earlier stable release: it must sit strictly behind the current stable ' + f.shas['1.0.4']!.slice(0, 12) + ', since a fix for something the current stable still does right needs no exception');
|
||||
expect(refused(reg(fix, '#91: worked in v9.9.9'))).toBe('v9.9.9 is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in');
|
||||
expect(refused(reg(fix, '#91: worked in v1.0.1'))).toBe('v1.0.1 is not an annotated release tag here, so it cannot be the earlier stable release the behavior worked in');
|
||||
// An annotated tag a half-done cut left behind, with no published release, never ran as stable.
|
||||
expect(refused(reg(fix, '#91: worked in v1.0.2'))).toBe('v1.0.2 has no published release, so team installs never ran it as stable');
|
||||
f.state.api = { 'repos/o/r/issues/91': { labels: [{ name: 'security' }] }, 'repos/o/r/issues/92': { labels: [{ name: 'regression' }] } };
|
||||
expect(refused(reg(fix, reason))).toEndWith('#91: no regression label');
|
||||
expect(refused(reg(fix, '#92: worked in v1.0.3'))).toEndWith('#92: no commit in the span references it');
|
||||
f.state.api['repos/o/r/issues/91'] = { labels: [{ name: 'Regression' }] };
|
||||
const crossed = planRelease({ repo: f.repo, ref: fix, git: f.git, gh: f.gh, now, security: '#91: x' });
|
||||
expect(crossed.ok).toBe(false);
|
||||
if (!crossed.ok) { expect(crossed.code).toBe('REFUSED_SECURITY'); expect(crossed.message).toEndWith('#91: no security label'); }
|
||||
|
||||
const verified = reg(fix, reason);
|
||||
expect(verified.ok, JSON.stringify(verified)).toBe(true);
|
||||
if (!verified.ok) return;
|
||||
const evidence = '#91 carries the regression label and commit ' + fix.slice(0, 12) + ' references it; v1.0.3 (' + f.shas['1.0.3']!.slice(0, 12) + ') is an earlier stable release';
|
||||
expect(verified.plan.exception).toEqual({ kind: 'regression', reason, evidence });
|
||||
expect(verified.plan.floor).toEqual({ verdict: 'regression', line: 'FLOOR: bypassed by the regression exception (stable moved to v1.0.4 at ' + isoZ(moved.at) + ', 1.0 h ago)' });
|
||||
expect(verified.plan.notes).toContain('## gstack v1.0.5 (regression release)\n\n**Regression release:** \`' + reason + '\` (verified: ' + evidence + ').');
|
||||
expect(describePlan(verified.plan, false)).toContain('REGRESSION: \`' + reason + '\` (verified: ' + evidence + ')');
|
||||
|
||||
// The release step fails; a scheduled retry with no flag finishes it with the record read back from the tag.
|
||||
f.state.createFails = 'v1.0.5';
|
||||
expect(cutRelease(verified.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(false);
|
||||
delete f.state.createFails;
|
||||
expect(git(f.origin, 'cat-file', '-p', 'v1.0.5')).toContain('gstack v1.0.5 (regression release)\n\nRegression: ' + reason + '\nVerified: ' + evidence);
|
||||
f.refresh();
|
||||
f.state.api = {};
|
||||
const retry = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => moved.at + 2 * H, scheduled: true });
|
||||
expect(retry.ok, JSON.stringify(retry)).toBe(true);
|
||||
if (!retry.ok) return;
|
||||
expect(retry.plan.exception).toEqual(verified.plan.exception);
|
||||
expect(cutRelease(retry.plan, { repo: f.repo, git: f.git, gh: f.gh, notesDir: f.base }).ok).toBe(true);
|
||||
f.refresh();
|
||||
|
||||
// The floor restarts from the regression release: the next scheduled cut 30 h later is held.
|
||||
const later = f.release('1.0.6', 'Later.', { message: 'v1.0.6 (#93)' });
|
||||
f.refresh();
|
||||
const held = planRelease({ repo: f.repo, git: f.git, gh: f.gh, now: () => moved.at + 32 * H, scheduled: true });
|
||||
expect(held.ok).toBe(false);
|
||||
if (!held.ok) { expect(held.code).toBe('REFUSED_FLOOR'); expect(held.message).toStartWith('stable moved to v1.0.5 at '); }
|
||||
|
||||
// A regression-recorded tag stable never reached cannot pass with the other flag.
|
||||
execFileSync('git', ['tag', '-a', 'v1.0.6', later, '-m', 'gstack v1.0.6 (regression release)\n\nRegression: #93: forged\nVerified: nothing'], { cwd: f.seed, timeout: 30_000, env: { ...process.env, GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
|
||||
git(f.seed, 'push', '-q', 'origin', 'refs/tags/v1.0.6');
|
||||
f.refresh();
|
||||
const forged = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => moved.at + 3 * H, security: 'CVE-2026-2: x' });
|
||||
expect(forged.ok).toBe(false);
|
||||
if (!forged.ok) expect(forged).toMatchObject({ code: 'REFUSED_REGRESSION', message: 'tag v1.0.6 records a regression cut that has not moved stable; run it again with --regression and --ref ' + later.slice(0, 12) + ' so the reason is verified' });
|
||||
// A different reason than the tag recorded would publish notes the tag (and a later retry) contradicts.
|
||||
const other = planRelease({ repo: f.repo, ref: later, git: f.git, gh: f.gh, now: () => moved.at + 3 * H, regression: '#93: worked in v1.0.4: other' });
|
||||
expect(other.ok).toBe(false);
|
||||
if (!other.ok) expect(other).toMatchObject({ code: 'REFUSED_REGRESSION', message: 'tag v1.0.6 records the regression reason "#93: forged"; run it again with that same reason so the tag and the notes agree' });
|
||||
});
|
||||
|
||||
// Value: protects=an undatable stable and the very first cut stay closed to a regression claim (FLOOR_UNKNOWN is security-only; nothing earlier worked
|
||||
// before stable existed); fails_when=a regression cut moves a stable whose last move cannot be dated, or anchors to no release; why_new=the security
|
||||
// exception alone passed FLOOR_UNKNOWN; seam=none; no_claim=none beyond protects
|
||||
test('a regression cut does not pass FLOOR_UNKNOWN, and with no stable yet nothing earlier can have worked', () => {
|
||||
const f = fixture();
|
||||
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.2'] + ':refs/heads/stable');
|
||||
f.refresh();
|
||||
expect(floorStatus({ git: f.git, gh: f.gh, repo: f.repo, prevStable: f.shas['1.0.2']!, sha: f.shas['1.0.3']!, exception: 'regression', scheduled: false, now: pastFloor() }).verdict).toBe('unknown');
|
||||
expect(verifyRegression({ reason: '#1: worked in v1.0.0', git: f.git, gh: f.gh, repo: f.repo, from: null, to: f.shas['1.0.3']!, stable: null }))
|
||||
.toEqual({ ok: false, why: 'there is no stable release yet, so nothing earlier can have worked' });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('release-cut: refusals at the workflow entry point', () => {
|
||||
// Value: protects=the CLI contract for refusals: REFUSED_FLOOR then NEXT_ALLOWED on the first two lines, the plan after them, nothing pushed, exit 1 for a
|
||||
// hand cut and an ::error:: annotation, exit 0 with a ::notice:: on the schedule or a dry run, REFUSED_SECURITY and FLOOR_UNKNOWN red on every trigger,
|
||||
@@ -937,6 +1039,11 @@ describe.skipIf(process.platform === 'win32')('release-cut: refusals at the work
|
||||
expect(escaped.stdout.split('\n').find(l => l.startsWith('::error'))).toStartWith('::error title=release-cut REFUSED::REFUSED: --ref no%25such%0D%0Aref is not a commit here: ');
|
||||
expect(run({}, '--security').status).toBe(2);
|
||||
expect(run({}, '--security', '--dry-run').status).toBe(2);
|
||||
expect(run({}, '--regression').status).toBe(2);
|
||||
const regNoRef = run({ GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'schedule' }, '--regression', '#1: worked in v1.0.2');
|
||||
expect(regNoRef.status).toBe(1);
|
||||
expect(regNoRef.stdout.split('\n')[0]).toBe('REFUSED_REGRESSION: --regression needs --ref <the fix commit>: a regression cut releases up to the fix, never whatever is newest');
|
||||
expect(regNoRef.stdout).toContain('::error title=release-cut REFUSED_REGRESSION::');
|
||||
|
||||
git(f.seed, 'push', '-q', 'origin', f.shas['1.0.4'] + ':refs/heads/stable');
|
||||
f.release('1.0.5', 'Even later.');
|
||||
|
||||
@@ -224,7 +224,7 @@ describe.skipIf(process.platform === 'win32')('gstack-update-channel.sh as the /
|
||||
const fallback = cli(early, '--fetch-ref');
|
||||
expect(fallback.status).toBe(0);
|
||||
expect(fallback.stdout.trim()).toBe('origin/main');
|
||||
expect(fallback.stderr).toContain('origin has no stable release yet; following origin/main until the first weekly release is cut');
|
||||
expect(fallback.stderr).toContain('origin has no stable release yet; following origin/main until the first stable release is cut');
|
||||
expect(git(early.install, 'rev-parse', 'refs/remotes/origin/main')).toBe(early.shas.C);
|
||||
expect(early.head()).toBe(early.shas.A);
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* scoped to the tag, the stable branch and the release; a pull request that
|
||||
* touches the release tooling runs the dry run against main read-only; a
|
||||
* dispatched hotfix ref is a full SHA and the dry_run input, true by default,
|
||||
* never reaches the scheduled run; the security input reaches the script only
|
||||
* through an env var, beside a ref. The release-cut CLI the jobs call is
|
||||
* never reaches the scheduled run; the security and regression inputs reach the
|
||||
* script only through env vars, beside a ref. The release-cut CLI the jobs call is
|
||||
* covered by test/release-cut.test.ts.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
@@ -24,13 +24,14 @@ describe('weekly-release.yml', () => {
|
||||
// Value: protects=Garry's "twice a week" (2026-10-27): Monday and Thursday at 14:00 UTC, 72 h then 96 h apart, so a scheduled Thursday cut clears the
|
||||
// floor once the grace absorbs a late Monday start; fails_when=the cron drifts off Mon/Thu 14:00 or the gap and floor stop matching; why_new=the cadence was
|
||||
// weekly; seam=none; no_claim=GitHub's cron start times
|
||||
test('Monday and Thursday schedule, a dispatch with dry_run (default true), a full-SHA ref and a security reason, and a pull request only for the release tooling', () => {
|
||||
test('Monday and Thursday schedule, a dispatch with dry_run (default true), a full-SHA ref, a security or regression reason, and a pull request only for the release tooling', () => {
|
||||
expect((wf as { name?: string }).name).toBe('Stable release');
|
||||
expect(Object.keys(wf.on).sort()).toEqual(['pull_request', 'schedule', 'workflow_dispatch']);
|
||||
expect(wf.on.schedule).toEqual([{ cron: '0 14 * * 1,4' }]);
|
||||
expect((4 - 1) * 24).toBe(FLOOR_HOURS);
|
||||
expect(SCHEDULED_GRACE_HOURS).toBeLessThan(FLOOR_HOURS / 12);
|
||||
expect(wf.on.workflow_dispatch.inputs.security).toMatchObject({ type: 'string', default: '' });
|
||||
expect(wf.on.workflow_dispatch.inputs.regression).toMatchObject({ type: 'string', default: '' });
|
||||
expect(wf.on.workflow_dispatch.inputs.dry_run).toMatchObject({ type: 'boolean', default: true });
|
||||
expect(wf.on.workflow_dispatch.inputs.ref).toMatchObject({ type: 'string', default: '' });
|
||||
expect(wf.on.pull_request).toEqual({ paths: ['.github/workflows/weekly-release.yml', 'scripts/release-cut.ts'] });
|
||||
@@ -56,6 +57,10 @@ describe('weekly-release.yml', () => {
|
||||
expect(plan.run).toContain('if [ -n "$SECURITY_REASON" ] && [ -z "$RELEASE_REF" ]; then');
|
||||
expect(plan.run).toContain('[ -n "$SECURITY_REASON" ] && args+=(--security "$SECURITY_REASON")');
|
||||
expect(plan.run).not.toContain('inputs.security');
|
||||
expect(plan.env!.REGRESSION_REASON).toBe("${{ github.event_name == 'workflow_dispatch' && inputs.regression || '' }}");
|
||||
expect(plan.run).toContain('if [ -n "$REGRESSION_REASON" ] && [ -z "$RELEASE_REF" ]; then');
|
||||
expect(plan.run).toContain('[ -n "$REGRESSION_REASON" ] && args+=(--regression "$REGRESSION_REASON")');
|
||||
expect(plan.run).not.toContain('inputs.regression');
|
||||
expect(plan.run).toContain(BUN_RUN + ' "${args[@]}"');
|
||||
expect(cut.steps.find(s => s.name === 'Fetch stable and the tags')!.run).toContain("git fetch --quiet origin '+refs/heads/stable:refs/remotes/origin/stable'");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user