mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
v1.93.20.0 docs: version flow under changelog fragments; scorecard pass cap of 2 (GST-1003) (#3882)
* docs: version flow under changelog fragments; scorecard pass cap matches the two-round review cap (GST-1003) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG_STYLE.md describes the fragment flow; pin the train land link to #train-land (GST-1003, #3885) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-1003 fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
cb72cc13b1
commit
16e4d2f4f0
11 files changed
+91
-65
No files matched your search
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [1.93.20.0] - 2026-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
- `gstack-factory-scorecard` now counts a PR as "at the cap" once it reaches 2 review passes, the two-round cap the review policy sets, instead of 3. The default `--pass-cap` is 2, and the "Review passes per PR" benchmark reads "median 1 pass, few at the cap". Pass `--pass-cap 3` to get the old count. Rows from earlier weeks in `trend.csv` were counted at the old cap, and each row names the cap it used.
|
||||
|
||||
### Docs
|
||||
|
||||
- CLAUDE.md now describes how gstack versions its own PRs today: a branch writes its entry as `changelog.d/<branch-slug>.md` and never edits VERSION, package.json or CHANGELOG.md, and the merge train's stamp assigns the version (`bump: minor` or `bump: major` front matter asks for more than a patch). The old text said to claim a version slot with `bin/gstack-next-version`.
|
||||
- docs/CHANGELOG_STYLE.md, which CLAUDE.md says to read before writing an entry, now describes the same fragment flow: what goes in the fragment, when to add `bump:` front matter, and what to check after merging main. It no longer tells a branch to bump VERSION or claim a slot with `bin/gstack-next-version`.
|
||||
- docs/ship-policy.md marks the queue-side procedure as being for repos on `stamp-at-merge` without changelog fragments, and points gstack's own PRs to the fragment flow and `train land`. The old link to that section still works.
|
||||
|
||||
## [1.93.19.0] - 2026-11-02
|
||||
|
||||
**`gstack-ship-receipt read --check-prose` no longer reads the Documentation exception line or code fences as counter claims (GST-983, GST-984).** `write --docs` with an owner exception requires the PR body to carry the whole `Documentation exception: …` line word for word, but the prose check read the owner's reason in it as prose, so a reason like "sections 1 and 2 skip the audit" was a `2 skip` claim and no body could pass both checks. That is the deadlock GST-977 fixed for the Review-loop exception line. Separately, on gstack-evals #142, test output pasted in a fenced block ("113 passed, 0 failed") and a quoted code comment ("the first 35 fail") were read as receipt counts and refused.
|
||||
|
||||
@@ -374,18 +374,22 @@ is not a reason to choose MINOR. Choose **MINOR (X.Y+1.0.0)** or **MAJOR
|
||||
("ridiculous"), such as an incompatible public-interface change or a genuinely
|
||||
new product-scale release. Make that judgment without another approval question.
|
||||
|
||||
Use `bin/gstack-next-version` to check the live release queue before publishing.
|
||||
If a slot is claimed, advance to the next available version at the chosen bump
|
||||
level and use `bin/gstack-version-bump` to synchronize release metadata. A higher
|
||||
base version does not itself require a MINOR bump. Keep the PR ready for Garry to
|
||||
merge; autonomous version decisions do not authorize merging, deploying or
|
||||
skipping required validation.
|
||||
gstack's policy sets `changelog_fragments` (#3654), so a branch never writes a
|
||||
version and never checks `bin/gstack-next-version` for a slot. The merge
|
||||
train's stamp (`train land`) assigns base + PATCH at merge. To ask for more,
|
||||
open the branch's fragment with `bump: minor` (or `major`) front matter. A
|
||||
higher base version does not itself require a MINOR bump. Keep the PR READY for
|
||||
the merge train; autonomous version decisions do not authorize merging,
|
||||
deploying or skipping required validation.
|
||||
|
||||
**VERSION and CHANGELOG are branch-scoped.** Each shipping branch gets its own
|
||||
bump and one entry, written at `/ship` time, that describes what THIS branch
|
||||
adds versus main, for users, with no branch-internal versions or development
|
||||
narrative. VERSION stays 4-digit; package.json carries the 3-digit npm
|
||||
translation owned by `bin/gstack-version-bump`, so never hand-edit it. Read
|
||||
**The CHANGELOG entry is a branch fragment.** Each shipping branch writes one
|
||||
entry at `/ship` time as `changelog.d/<branch-slug>.md` (entry text only, no
|
||||
`## [x]` heading) that describes what THIS branch adds versus main, for users,
|
||||
with no branch-internal versions or development narrative. A branch never edits
|
||||
VERSION, package.json or a released CHANGELOG.md section: the merge stamp folds
|
||||
the fragment into CHANGELOG.md under its version, keeps VERSION 4-digit and
|
||||
writes package.json's 3-digit npm translation through `bin/gstack-version-bump`
|
||||
([changelog fragments](docs/ship-policy.md#changelog-fragments)). Read
|
||||
[docs/CHANGELOG_STYLE.md](docs/CHANGELOG_STYLE.md) BEFORE writing an entry: it
|
||||
holds the versioning invariant, the merge-main checks, what stays out and the
|
||||
entry format. Always credit community contributions with
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.93.19.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.93.20.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,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* gstack-factory-scorecard [--repo owner/name] [--base main] [--since-days 7]
|
||||
* [--crew login,login] [--gate-workflow evals.yml] [--gate-event workflow_dispatch|any]
|
||||
* [--free-workflow free-tests.yml] [--train-prefix train/] [--ready-marker <regex>]
|
||||
* [--pass-marker <regex>] [--pass-cap 3] [--max-artifact-runs 60]
|
||||
* [--pass-marker <regex>] [--pass-cap 2] [--max-artifact-runs 60]
|
||||
* [--out-dir <dir>] [--csv <file>] [--json] [--cache-dir <dir>]
|
||||
* [--fixtures <recorded gh responses.json>] [--now <iso>]
|
||||
*
|
||||
|
||||
+46
-42
@@ -1,7 +1,9 @@
|
||||
# CHANGELOG + VERSION style
|
||||
|
||||
Moved verbatim from CLAUDE.md (token-load reduction). Read this BEFORE
|
||||
writing any `## [X.Y.Z]` CHANGELOG entry.
|
||||
writing a CHANGELOG entry. In gstack the entry is a fragment,
|
||||
`changelog.d/<branch-slug>.md`, and the merge step gives it its `## [X.Y.Z.W]`
|
||||
heading ([changelog fragments](ship-policy.md#changelog-fragments)).
|
||||
|
||||
## Versioning and branch-scoped entries
|
||||
|
||||
@@ -9,15 +11,15 @@ Moved verbatim from CLAUDE.md (#2096 size limit). The autonomous version
|
||||
policy (choose versions without asking; default to PATCH) stays in CLAUDE.md
|
||||
as the single source.
|
||||
|
||||
**Versioning invariant (workspace-aware ship).** VERSION is a monotonic ordered
|
||||
release identifier, not a strict semver commitment. The bump level
|
||||
(major/minor/patch/micro) expresses intent at ship time. Queue-advancing past a
|
||||
claimed version within the same bump level is explicitly permitted — if branch A
|
||||
claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0
|
||||
(still a MINOR relative to main). Downstream consumers must NOT rely on
|
||||
"MINOR = feature-only, PATCH = fix-only" as a strict contract. This is why
|
||||
`bin/gstack-next-version` advances within the chosen bump level rather than
|
||||
repicking the level when collisions happen.
|
||||
**Versioning invariant.** VERSION is a monotonic ordered release identifier,
|
||||
not a strict semver commitment. The bump level (major/minor/patch/micro)
|
||||
expresses intent. Versions are assigned at merge, in merge order: each stamp
|
||||
takes the version before it plus the effective bump, which is the policy's
|
||||
PATCH raised by any folded fragment's `bump:` front matter. If PR A and PR B
|
||||
both ask for MINOR and land back to back on v1.6.0.0, A lands at v1.7.0.0 and
|
||||
B at v1.8.0.0 (still a MINOR relative to the version before it). Downstream
|
||||
consumers must NOT rely on "MINOR = feature-only, PATCH = fix-only" as a
|
||||
strict contract.
|
||||
|
||||
**package.json carries the npm-valid translation, not VERSION verbatim.**
|
||||
VERSION stays the 4-digit source of truth (e.g. `1.67.0.0`); package.json and
|
||||
@@ -26,18 +28,25 @@ translation (`1.67.0`), and lockfile `version` fields sync only when the
|
||||
lockfile already exists. `bin/gstack-version-bump` (via `lib/version-source.ts`)
|
||||
owns the translation and judges drift on translated forms — do NOT "fix" the
|
||||
apparent mismatch by hand, and do not write a 4-digit version into
|
||||
package.json (npm rejects it). Rationale and translation rules live in the
|
||||
package.json (npm rejects it). A branch never writes either one: the merge
|
||||
stamp does, through `bin/gstack-version-bump`. Rationale and translation rules live in the
|
||||
`lib/version-source.ts` header; `test/gstack-version-bump.test.ts` pins the
|
||||
contract.
|
||||
|
||||
**VERSION and CHANGELOG are branch-scoped.** Every feature branch that ships gets its
|
||||
own version bump and CHANGELOG entry. The entry describes what THIS branch adds —
|
||||
not what was already on main.
|
||||
**The CHANGELOG entry is branch-scoped; the version is not.** Every feature
|
||||
branch that ships writes one entry as `changelog.d/<branch-slug>.md`: the entry
|
||||
text only, with no `## [x]` heading, and `bump: minor` or `bump: major` front
|
||||
matter only when the change warrants more than a PATCH. A branch never edits
|
||||
VERSION, package.json's version or a released CHANGELOG.md section. At merge,
|
||||
`train land` (or `gstack-ship-queue land`) assigns the version, folds the
|
||||
fragment into one `## [<version>] - <date>` section at the top of CHANGELOG.md
|
||||
and deletes the file. The entry describes what THIS branch adds — not what was
|
||||
already on main.
|
||||
|
||||
**The CHANGELOG entry is the diff between main and the shipping branch — what users
|
||||
get when they upgrade. NOT how the branch got there.** A reader landing on the entry
|
||||
should learn what they can do now that they couldn't before; they should not learn
|
||||
about the branch's internal version bumps, the bugs we caught and fixed mid-branch,
|
||||
about versions the branch carried during development, the bugs we caught and fixed mid-branch,
|
||||
the plan reviews we ran, or the commits we squashed. That is branch development
|
||||
narrative. It belongs in PR descriptions and commit messages, not CHANGELOG.
|
||||
|
||||
@@ -62,29 +71,28 @@ the user gets; the path to that system is invisible to them.
|
||||
**When to write the CHANGELOG entry:**
|
||||
- At `/ship` time (Step 13), not during development or mid-branch.
|
||||
- The entry covers ALL commits on this branch vs the base branch.
|
||||
- `/ship` lints the lines the branch added with `bin/gstack-prose-lint CHANGELOG.md --base origin/<base> --gate`,
|
||||
and the Quality gate workflow gates the `## [Unreleased]` block the same way. Rewrite each `PROSE:` line
|
||||
it names ([docs/troubleshooting.md](troubleshooting.md#prose-lint-found)).
|
||||
- Never fold new work into an existing CHANGELOG entry from a prior version that
|
||||
already landed on main. If main has v0.10.0.0 and your branch adds features,
|
||||
bump to v0.10.1.0 with a new entry — don't edit the v0.10.0.0 entry.
|
||||
- `/ship` lints the fragment with `bin/gstack-prose-lint changelog.d/<branch-slug>.md --gate`.
|
||||
Rewrite each `PROSE:` line it names ([docs/troubleshooting.md](troubleshooting.md#prose-lint-found)).
|
||||
- Never fold new work into a CHANGELOG.md section that already landed on main.
|
||||
New work goes in this branch's fragment, and the merge stamp gives it its own
|
||||
section at the next version.
|
||||
|
||||
**Key questions before writing:**
|
||||
1. What branch am I on? What did THIS branch change?
|
||||
2. Is the base branch version already released? (If yes, bump and create new entry.)
|
||||
3. Does an existing entry on this branch already cover earlier work? (If yes, replace
|
||||
it with one unified entry for the final version.)
|
||||
2. Does this change warrant more than a PATCH? (If yes, open the fragment with
|
||||
`bump: minor` or `bump: major` front matter; otherwise add none.)
|
||||
3. Does this branch's fragment already cover earlier work? (If yes, rewrite it as
|
||||
one unified entry.)
|
||||
|
||||
**Merging main does NOT mean adopting main's version.** When you merge origin/main into
|
||||
a feature branch, main may bring new CHANGELOG entries and a higher VERSION. Your branch
|
||||
still needs its OWN version bump on top. If main is at v0.13.8.0 and your branch adds
|
||||
features, bump to v0.13.9.0 with a new entry. Never jam your changes into an entry that
|
||||
already landed on main. Your entry goes on top because your branch lands next.
|
||||
**Merging main never gives your branch a version.** When you merge origin/main into
|
||||
a feature branch, main may bring new CHANGELOG sections and a higher VERSION. Take them
|
||||
exactly as main has them. Your branch still carries only its fragment, and never jams its
|
||||
changes into a section that already landed on main; the merge stamp puts its section on top.
|
||||
|
||||
**After merging main, always check:**
|
||||
- Does CHANGELOG have your branch's own entry separate from main's entries?
|
||||
- Is VERSION higher than main's VERSION?
|
||||
- Is your entry the topmost entry in CHANGELOG (above main's latest)?
|
||||
- Is your fragment still in `changelog.d/`, with its own text, separate from main's sections?
|
||||
- Are VERSION, package.json's version and every released CHANGELOG.md section exactly
|
||||
main's? (The Version Gate check requires this of a head the merge step did not stamp.)
|
||||
If any answer is no, fix it before continuing.
|
||||
|
||||
**After any CHANGELOG edit that moves, adds, or removes entries,** immediately run
|
||||
@@ -93,13 +101,9 @@ order. Gaps between version numbers are fine. A branch that ships at v1.6.4.0 wi
|
||||
a prior v1.5.2.0 or v1.5.3.0 entry on main is correct — those were branch-internal
|
||||
version numbers that never landed. Do not back-fill gaps with placeholder entries.
|
||||
|
||||
**Never orphan branch-internal versions.** If your branch bumped VERSION several times
|
||||
during development (v1.5.1.0 → v1.5.2.0 → v1.6.4.0, say) and those earlier entries were
|
||||
never released to main, the final ship consolidates ALL of them into a single entry at
|
||||
the final version (v1.6.4.0). Collapse them — delete the old entries and move their
|
||||
content into the final entry, re-version table columns accordingly. Readers see one
|
||||
release, not a branch diary. Gaps are fine (v1.6.3.0 → v1.6.4.0 with no v1.5.x
|
||||
in between on main is correct).
|
||||
**Keep one entry per branch.** When the branch's work grows after its fragment was
|
||||
written, rewrite that fragment as one unified entry instead of adding a second file
|
||||
beside it. Readers see one release, not a branch diary.
|
||||
|
||||
CHANGELOG.md is **for users**, not contributors. Write it like product release notes:
|
||||
|
||||
@@ -122,9 +126,9 @@ we got here. Keep out of the CHANGELOG, always:
|
||||
documents what DID ship, not what MIGHT ship.
|
||||
- Version-bump housekeeping when no user-facing work actually landed.
|
||||
|
||||
If the diff between the base branch version and this version has no user-facing change
|
||||
(only merges, only CHANGELOG edits, only placeholder work), the honest entry is one
|
||||
sentence: "Version bump for branch-ahead discipline. No user-facing changes yet." Stop
|
||||
If the diff between the base branch and this branch has no user-facing change
|
||||
(only merges, only CHANGELOG edits, only placeholder work), the honest fragment is one
|
||||
sentence: "No user-facing changes yet." Stop
|
||||
there. Do not pad. Do not explain the plan that will ship eventually. Do not narrate
|
||||
the branch's history. When real work lands, the entry will replace this at /ship time.
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ line whose input could not be read says `unavailable (<reason>)` rather than 0.
|
||||
gstack-factory-scorecard --repo owner/name --since-days 7 \
|
||||
[--crew login,login] [--gate-workflow evals.yml] [--free-workflow free-tests.yml] \
|
||||
[--train-prefix train/] [--ready-marker 'READY #\d+|SHIP_RECEIPT:'] [--pass-marker '"passes": (\d+)|passes=(\d+)'] \
|
||||
[--pass-cap 3] [--out-dir .context/factory-scorecard] [--now <iso>]
|
||||
[--pass-cap 2] [--out-dir .context/factory-scorecard] [--now <iso>]
|
||||
```
|
||||
|
||||
`--repo` defaults to the origin remote's owner/name on github.com (or
|
||||
@@ -280,7 +280,7 @@ measured later does not count anything that happened after that week.
|
||||
| Merged PRs | `merged_at` in the window, base = default branch; crew by `--crew` logins, else author association OWNER/MEMBER/COLLABORATOR | several merges a day, every day; outside contributions land in the same week |
|
||||
| Lead time, created → merged | PR `created_at` to `merged_at`, median and p90 | median under 24h, p90 under 3 days (DORA "elite" change lead time is under a day) |
|
||||
| Lead time, READY → merged | first PR comment matching `--ready-marker` at or before `merged_at`; PRs with no such comment are left out and counted | under 2h; every merged PR carries a READY comment |
|
||||
| Review passes per PR | the largest count `--pass-marker` captures in the PR body (the ship receipt's `"passes": N` once /ship writes it) or a PR comment (`passes=N`, `REVIEW_PASSES: N`), median and p90, plus how many PRs sat at `--pass-cap` (3: pass 1 reviews the whole diff, later passes only the delta, a pass that finds nothing new is the exit) | median 2 passes; none at the cap |
|
||||
| Review passes per PR | the largest count `--pass-marker` captures in the PR body (the ship receipt's `"passes": N` once /ship writes it) or a PR comment (`passes=N`, `REVIEW_PASSES: N`), median and p90, plus how many PRs reached `--pass-cap` (2, the [review policy](review-policy.md)'s cap of two rounds, a fix-check included: pass 1 reviews the whole diff, pass 2 only the range since, and a pass that finds nothing blocking is the exit) | median 1 pass; few at the cap |
|
||||
| Gate runs green / red / cancelled | completed runs of the gate workflow (`workflow_dispatch` by default; `--gate-event any`); cost from the run's `report-verdict-a*` artifact (`collector-outcomes.json` `.verdict.costUsd`), else the `**$N** rule/judge cost` or `cost=$N` line of the PR's verdict comment | green on 95%+ of runs, cancelled near 0, cost known for every run |
|
||||
| Known gate cost per merged PR | known cost / merged PRs; unknown-cost runs make it a floor | known, stable or falling week over week |
|
||||
| Red-train rate | runs whose head branch starts with `--train-prefix`, cancelled excluded; red = conclusion other than success; red backstops and screens shown beside it | under 5% (each red train is a bisect plus a rebuild, about two gates) |
|
||||
|
||||
+9
-3
@@ -112,10 +112,16 @@ refuses the file by name (`POLICY_GSTACK_TOO_OLD`).
|
||||
`.gstack/ship-policy.example.json` shows every key set the way a serial-merge
|
||||
repo with a release script, docs mentions and a mirror would set them.
|
||||
|
||||
### Queue-side procedure for gstack
|
||||
<a id="queue-side-procedure-for-gstack"></a>
|
||||
### Queue-side procedure under `stamp-at-merge` without fragments
|
||||
|
||||
Who does what under `stamp-at-merge`, for the PR author and for the thread
|
||||
running the merge queue:
|
||||
gstack itself no longer follows these steps. Since #3654 (v1.91.342.0) its
|
||||
policy sets [`changelog_fragments`](#changelog-fragments), so a gstack PR adds
|
||||
a fragment and never a version, and trains merge through
|
||||
[`train land`](#train-land). The steps
|
||||
below are for a repo on `stamp-at-merge` whose policy has no
|
||||
`changelog_fragments`. Who does what there, for the PR author and for the
|
||||
thread running the merge queue:
|
||||
|
||||
1. **Open.** The branch carries a tentative stamp of the base's version plus
|
||||
PATCH (`gstack-version-bump write --version <base+patch> --regen-digest`)
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface ScorecardOptions {
|
||||
export const DEFAULTS = {
|
||||
base: 'main', sinceDays: 7, gateWorkflow: 'evals.yml', gateEvent: 'workflow_dispatch', freeWorkflow: 'free-tests.yml',
|
||||
trainPrefix: 'train/', readyMarker: 'READY #\\d+|SHIP_RECEIPT:|gstack-ship-receipt',
|
||||
passMarker: '"passes"\\s*:\\s*(\\d+)|\\bpasses=(\\d+)|\\bREVIEW_PASS(?:ES)?[:= ]\\s*(\\d+)', passCap: 3, maxArtifactRuns: 60,
|
||||
passMarker: '"passes"\\s*:\\s*(\\d+)|\\bpasses=(\\d+)|\\bREVIEW_PASS(?:ES)?[:= ]\\s*(\\d+)', passCap: 2, maxArtifactRuns: 60,
|
||||
} as const;
|
||||
|
||||
export const CREW_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
@@ -399,7 +399,7 @@ export const BENCHMARKS: Record<string, string> = {
|
||||
merged: 'several merges a day, every day; outside contributions land in the same week',
|
||||
lead_time: 'median under 24h, p90 under 3 days (DORA elite: change lead time under a day)',
|
||||
ready_to_merge: 'under 2h from READY to merge; every merged PR carries a READY comment',
|
||||
review_passes: 'median 2 passes, none at the cap (a pass that finds nothing new is the exit; what is left at the cap is named in the PR body)',
|
||||
review_passes: 'median 1 pass, few at the cap (a pass that finds nothing blocking is the exit; a PR at the cap ships with what is left named in the PR body)',
|
||||
gates: 'green on 95%+ of runs, cancelled near 0, cost known for every run',
|
||||
cost_per_merge: 'known, stable or falling week over week',
|
||||
red_train: 'under 5% of trains red (each red train costs a bisect and a rebuild)',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.93.19",
|
||||
"version": "1.93.20",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('factory-scorecard: recorded window', () => {
|
||||
expect(values.merged).toBe('3 (crew 2, outside 1)');
|
||||
expect(values.lead_time).toBe('median 8.0h, p90 5.0d');
|
||||
expect(values.ready_to_merge).toBe('median 1.0h, p90 2.0h (2 of 3 PRs with a READY comment)');
|
||||
expect(values.review_passes).toBe('median 2, p90 3 (2 of 3 PRs report a pass count; 1 at the cap of 3)');
|
||||
expect(values.review_passes).toBe('median 2, p90 3 (2 of 3 PRs report a pass count; 2 at the cap of 2)');
|
||||
expect(values.gates).toBe('2 / 1 / 1; known cost $43.00 on 2 of 4 runs');
|
||||
expect(values.cost_per_merge).toBe('$14.33 (2 runs without a known cost)');
|
||||
expect(values.red_train).toBe('50% (1 red of 2 trains; 0 red backstop, 0 red screens)');
|
||||
|
||||
Reference in new issue
Block a user