mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* feat(ship): GSTA-167 turn levers on the restored reviewers: ship preflight/release bins, own-jam meter, dispatch-pair metering, ship/DRIVER.md
Ports the non-reviewer parts of #3633 (gsta-167-pr34) and #3637 (gsta-167-pr5 @2914400)
onto main ae3d58c (v1.91.331.0). Reviewer briefs, checklists, the fix-check prompt and
the depth rules are byte-identical to main and now pinned in
test/review-packets-removed.test.ts.
Left out: gstack-review-packet and its libs, parallel/batched fanout, --like, collect,
the audit prompt split, the docs-audit packet, review-army/adversarial/outside-voice
edits, the gate-scan summary-only block and the review-depth scope-error change.
Packet-bytes reader pricing is dropped from gstack-ship-budget; handoff READ ranges
send each section whole; the driver runs the audit, review, adversarial and
documentation sections as written.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(context-budget): raise the capy/skill/gstack eager ceiling to its captured value after the main merge (own-jam meter, driver and reviewer-batch prose, GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ship-driver): name ship/DRIVER.md.tmpl so the pregate maps the template to its test lane (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-driver): keep the skill generator off lib/ship-budget so a minimal setup checkout still renders (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(windows): keep test/ship-release.test.ts off the Windows subset; it launches the shebang bin and sh stubs directly (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ship): variant E orchestration: one program per review pass and Step 14.5 audit, the review child starts at Step 9, a metered dispatch:docs role, and the driver's record headings (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ship): variant G orchestration: one call per driver phase (9 for a clean pass, at most 12), gstack-ship-preflight --audit for single reads and deterministic audits, an observe-mode ledger with note and credits, 240 s wait pings, and small-model audit readers (GSTA-167)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-budget): a Capy handoff child needs no jam id: verify-handoff with no reading makes it parent-metered (METER: parent), it never delegates on an unknown charge, and reconcile charges the parent's counter delta (GSTA-167 replay 7e)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): a poll settles a reader only at done, failed or a final summary, never a fresh idle; return settles the child's open step reservation (GSTA-167 replay 7e)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-budget): return releases an open step reservation under the estimate meter instead of booking its projection; measured meters still settle it (GSTA-167 replay 7e)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review): a clean fix-check after below-floor fixes is done; Step 11 and fix-check below-floor findings are notes at pass 1
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(ship): verbatim reviewer briefs and loss-free collection (gstack-review-dispatch render/sent/collect/judge/verify)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
- verify requires each output's brief rendered and sent verbatim, reruns judge
against the recorded judged.jsonl hash, and flags outputs collected after judge
- judge drops self-folds, fold cycles and chains
- prose findings parse a leading label or a label heading, not any severity word
- releasePaths: manifests count as release metadata only for version-only edits,
lockfiles never; TODOS.md stays out of lane-evidence exemptions
- push stops when the sealed-output guard did not run
- ship-audit reads the test tree once instead of one git grep per file
- drop the uncalled --delegate measured mode; doc/help step-number fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (pass 2)
- collect keeps a finding wherever its label stands (headings, table rows,
mid-line, Informational and upper-case labels); only top-level list items
inherit a label heading, and any heading or bold-only line ends it
- judge treats a merged file missing any collected id as a drop; verify flags
merged_ids_missing and a sent reader never collected (NOT_COLLECTED)
- finish computes release paths after --build, so a build that rewrites
package.json is a build-input change
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review-dispatch): verify per render, voided superseded mismatches
- verify treats each render as one dispatch of its reader, so a two-pass
ledger compares pass-1 sends with pass-1 briefs (later renders print as
<reader>@2); outputs bind to the latest send of their own render
- a mismatched send re-sent byte-exact before anything was collected is void
and counted (voided=N on the verdict line); one that produced an output
stays MISMATCH
- an indented bold sub-line no longer ends a label section (fix-check)
- docs: a failed reader is collected from its failure text
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(review-dispatch): only the newest render of a path checks its file; absolute ledger paths
- a later render into the same briefs directory overwrites the earlier brief by
design, so verify checks a brief file only for the newest render of that path
- collect and judge record absolute output, merged and judged paths, so verify
reads them from any directory
- docs: a failed reader's failure text is parsed like any output
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: changelog and TODOS
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship-release): the changelog_fragments fragment is release metadata
Fix-check 4 (CRITICAL) after the main merge: releasePaths omitted
<changelog_fragments.dir>/<branch-slug>.md, so finish stopped on the
fragment Step 13 writes under gstack's policy, or routed it to re-review.
version now names the fragment in its Step 13 NEXT line instead of
[Unreleased], DRIVER.md Step 13 writes and lints where NEXT says, and the
preflight POLICY line says Steps 12-16.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: changelog and TODOS
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>
211 lines
15 KiB
TypeScript
211 lines
15 KiB
TypeScript
/**
|
||
* gstack-ship-release (GSTA-167 PR 4): /ship Step 12 (version), Steps 15-16 (finish) and
|
||
* Step 17 (push). These are the checks those steps used to spell out as shell blocks,
|
||
* moved into the bin; each STOP is a place the prose leaves the decision to the agent.
|
||
*/
|
||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||
|
||
setDefaultTimeout(60_000);
|
||
const fixtures: ShipFx[] = [];
|
||
const fx = (...a: Parameters<typeof shipFixture>) => { const f = shipFixture(...a); fixtures.push(f); return f; };
|
||
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
|
||
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
|
||
const read = (f: ShipFx, p: string) => fs.readFileSync(path.join(f.work, p), 'utf8');
|
||
const wtree = (f: ShipFx) => f.run(path.join(path.dirname(RELEASE), 'gstack-wtree'), []).stdout.trim();
|
||
const snapshot = (f: ShipFx, name = 'post') => {
|
||
const out = path.join(f.root, `${name}.json`);
|
||
expect(f.run(path.join(path.dirname(RELEASE), 'gstack-docs-candidate'), ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
|
||
return out;
|
||
};
|
||
|
||
describe('version (Step 12)', () => {
|
||
// Value: protects=FRESH never picks a level itself (unless the policy pins patch), and a chosen level writes VERSION, the manifest and the CHANGELOG heading through gstack-version-bump; fails_when=the bin guesses a level, or hand-writes versions;
|
||
// why_new=moved from Step 12 items 1-5; seam=gstack-version-bump, gstack-next-version; no_claim=the queue's live host read
|
||
test('FRESH stops for BUMP_LEVEL; --bump micro writes VERSION, package.json and moves [Unreleased]', () => {
|
||
const f = fx();
|
||
const ask = f.run(RELEASE, ['version', '--base', 'main']);
|
||
expect(ask.status).toBe(3);
|
||
expect(ask.stdout).toMatch(/^STOP: BUMP_LEVEL needed \(FRESH, /m);
|
||
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
|
||
const w = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--why', 'one line']);
|
||
expect(w.status).toBe(0);
|
||
expect(lineOf(w.stdout, 'VERSION')).toMatch(/^VERSION: 1\.0\.0\.0 -> 1\.0\.0\.1 level=micro state=FRESH /);
|
||
expect(read(f, 'VERSION')).toBe('1.0.0.1\n');
|
||
expect(JSON.parse(read(f, 'package.json')).version).toBe('1.0.0');
|
||
expect(read(f, 'CHANGELOG.md')).toContain('1.0.0.1');
|
||
expect(w.stdout).toContain('NEXT: Step 13');
|
||
});
|
||
|
||
// Value: protects=ALREADY_BUMPED keeps its version without a write, DRIFT_UNEXPECTED stops, stamp-at-merge skips, and an explicit version must be above base; fails_when=a re-run double-bumps, a manual manifest edit is overwritten, or a stamp-at-merge repo gets a version;
|
||
// why_new=moved from Step 12's state dispatch; seam=gstack-version-bump classify, gstack-ship-policy; no_claim=the rebump question's answer
|
||
test('ALREADY_BUMPED keeps, DRIFT_UNEXPECTED stops, stamp-at-merge skips, a version at or below base stops', () => {
|
||
const bumped = fx({}, { VERSION: '1.0.1.0\n', 'package.json': '{"name":"fx","version":"1.0.1"}\n' });
|
||
const kept = bumped.run(RELEASE, ['version', '--base', 'main']);
|
||
expect(lineOf(kept.stdout, 'VERSION')).toMatch(/^VERSION: 1\.0\.0\.0 -> 1\.0\.1\.0 level=patch state=ALREADY_BUMPED .*\(kept, no write\)$/);
|
||
expect(read(bumped, 'VERSION')).toBe('1.0.1.0\n');
|
||
const drift = fx({}, { 'package.json': '{"name":"fx","version":"9.0.0"}\n' });
|
||
const d = drift.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']);
|
||
expect(d.status).toBe(3);
|
||
expect(d.stdout).toContain('STOP: package.json disagrees with VERSION');
|
||
const stamp = fx({ '.gstack/ship-policy.json': '{"queue_mode":"stamp-at-merge"}\n' });
|
||
const s = stamp.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']);
|
||
expect(s.status).toBe(0);
|
||
expect(lineOf(s.stdout, 'VERSION')).toContain('skipped (policy: queue_mode stamp-at-merge');
|
||
expect(read(stamp, 'VERSION')).toBe('1.0.0.0\n');
|
||
const low = fx();
|
||
expect(low.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--version', '0.9.0.0']).stdout).toContain('STOP: 0.9.0.0 is not above the base version 1.0.0.0');
|
||
});
|
||
});
|
||
|
||
describe('finish (Steps 15-16)', () => {
|
||
// Value: protects=finish commits only release metadata with the trailer, then checks the reviewed-tree route, docs freshness, each lane receipt and the pregate; fails_when=it commits feature files, skips a stage, or pushes;
|
||
// why_new=moved from Steps 15-16; seam=gstack-wtree, gstack-docs-candidate compare, gstack-evidence, gstack-pregate; no_claim=the stage 5 report
|
||
test('commits release metadata with the trailer and clears stages 2-4 and the pregate; feature files stop it', () => {
|
||
const f = fx();
|
||
const reviewed = wtree(f);
|
||
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
|
||
const post = snapshot(f);
|
||
f.write({ 'src/c.ts': 'export const c = 3;\n' });
|
||
const feature = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
|
||
expect(feature.status).toBe(3);
|
||
expect(feature.stdout).toContain('STOP: uncommitted changes outside release metadata: src/c.ts');
|
||
fs.rmSync(path.join(f.work, 'src/c.ts'));
|
||
const ok = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE, '--trailer', 'Co-Authored-By: Fixture Bot <bot@example.com>']);
|
||
expect(ok.stdout).toMatch(/^COMMIT: [0-9a-f]{12} chore: bump version and changelog \(v1\.0\.0\.1\)/m);
|
||
expect(f.git('log', '-1', '--format=%B').stdout).toContain('Co-Authored-By: Fixture Bot <bot@example.com>');
|
||
expect(lineOf(ok.stdout, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=\d+/);
|
||
expect(lineOf(ok.stdout, 'DOCS')).toContain('fresh');
|
||
expect(ok.stdout).toMatch(/^(LANE: tests pass|EVIDENCE: FRESH label=tests)/m);
|
||
expect(lineOf(ok.stdout, 'PREGATE')).not.toBe('');
|
||
expect(ok.stdout).toContain('NEXT: Step 16 stage 5');
|
||
expect(ok.status).toBe(0);
|
||
expect(f.git('ls-remote', '--heads', 'origin', 'feat').stdout).toBe('');
|
||
});
|
||
|
||
// Value: protects=under changelog_fragments the branch's own fragment is release metadata: version writes nothing and names it in NEXT, finish commits it as the release commit and the route counts it as metadata, and another fragment is still a feature file; fails_when=finish stops on changelog.d/<slug>.md or routes it to re-review (/ship #3648 fix-check 4, CRITICAL);
|
||
// why_new=releasePaths predates changelog_fragments (#3654); seam=gstack-ship-policy show-effective, gstack-wtree; no_claim=the merge step's fold
|
||
test('under changelog_fragments the branch fragment is release metadata and Step 12 names it', () => {
|
||
const f = fx({ '.gstack/ship-policy.json': '{"changelog_fragments":{"dir":"changelog.d"},"min_gstack_version":"1.91.336.0"}\n', 'changelog.d/README.md': 'Fragments.\n' });
|
||
const reviewed = wtree(f);
|
||
const v = f.run(RELEASE, ['version', '--base', 'main']);
|
||
expect(v.status).toBe(0);
|
||
expect(lineOf(v.stdout, 'VERSION')).toContain('skipped (policy: changelog_fragments');
|
||
expect(v.stdout).toContain('NEXT: Step 13: write the entry as changelog.d/feat.md');
|
||
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
|
||
const post = snapshot(f);
|
||
const args = ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE];
|
||
f.write({ 'changelog.d/other.md': 'Not this branch.\n' });
|
||
expect(f.run(RELEASE, args).stdout).toContain('STOP: uncommitted changes outside release metadata: changelog.d/other.md');
|
||
fs.rmSync(path.join(f.work, 'changelog.d/other.md'));
|
||
f.write({ 'changelog.d/feat.md': 'One entry.\n' });
|
||
const ok = f.run(RELEASE, args);
|
||
expect(ok.stdout).toMatch(/^COMMIT: [0-9a-f]{12} chore: changelog and TODOS \(changelog\.d\/feat\.md\)/m);
|
||
expect(lineOf(ok.stdout, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
|
||
expect(ok.status).toBe(0);
|
||
});
|
||
|
||
// Value: protects=a behavior change after review inserts the re-review route, an authored doc change needs --docs-rechecked, stale docs and failing or absent lanes stop; fails_when=any of these continues to push;
|
||
// why_new=moved from Step 16 stages 2-4; seam=same; no_claim=the repair itself
|
||
test('behavior after review, unrechecked docs, stale docs, a failing lane and no lanes each stop', () => {
|
||
const f = fx();
|
||
const reviewed = wtree(f);
|
||
const post = snapshot(f);
|
||
f.write({ 'src/a.ts': 'export const a = 9;\n' }); f.commit('late fix');
|
||
const behavior = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
|
||
expect(behavior.stdout).toContain('STOP: behavior, tests or build inputs changed since review (src/a.ts): insert 5–11.5 → 12–14 → 16');
|
||
const g = fx();
|
||
const reviewed2 = wtree(g);
|
||
g.write({ 'docs/guide.md': '# guide\n' }); g.commit('docs');
|
||
const post2 = snapshot(g, 'post2');
|
||
const docs = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed2, '--docs-post', post2, '--lane', LANE]);
|
||
expect(docs.stdout).toContain('STOP: authored docs changed since review (docs/guide.md)');
|
||
g.write({ 'README.md': '# changed after the audit\n' }); g.commit('readme');
|
||
const stale = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed2, '--docs-post', post2, '--lane', LANE, '--docs-rechecked']);
|
||
expect(lineOf(stale.stdout, 'DOCS')).toBe('DOCS: stale (README.md)');
|
||
expect(stale.stdout).toContain('STOP: documentation inputs changed since the accepted audit');
|
||
const h = fx();
|
||
const reviewed3 = wtree(h);
|
||
const post3 = snapshot(h, 'post3');
|
||
const failing = h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3, '--docs-post', post3, '--lane', 'tests=exit 1']);
|
||
expect(failing.status).toBe(3);
|
||
expect(failing.stdout).toContain('STOP: lane tests failed or ran zero tests');
|
||
expect(h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3, '--docs-post', post3]).stdout).toContain("STOP: no test lanes: require Step 5's explicit untested-scope approval");
|
||
expect(h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3]).status).toBe(2);
|
||
});
|
||
|
||
// Value: protects=a dependency or script edit to package.json, and any lockfile edit, after review takes the re-review route; a version-only bump stays release metadata; fails_when=finish treats package.json or bun.lock as metadata whatever changed in them (/ship #3648 pass-1 security, CRITICAL);
|
||
// why_new=releasePaths exempted the manifests and lockfiles by name; seam=gstack-wtree, git; no_claim=the re-review itself
|
||
test('a script or dependency edit to package.json, or a bun.lock edit, after review stops finish', () => {
|
||
for (const [file, text] of [['package.json', '{"name":"fx","version":"1.0.0","scripts":{"test":"exit 0"}}\n'], ['bun.lock', '{"lockfileVersion":1}\n']]) {
|
||
const f = fx();
|
||
const reviewed = wtree(f);
|
||
const post = snapshot(f);
|
||
f.write({ [file]: text }); f.commit(`late ${file}`);
|
||
const r = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
|
||
expect([r.status, r.stdout.includes(`STOP: behavior, tests or build inputs changed since review (${file})`)]).toEqual([3, true]);
|
||
}
|
||
const g = fx();
|
||
const reviewed = wtree(g);
|
||
const built = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', snapshot(g), '--lane', LANE, '--build', `printf '{"name":"fx","version":"1.0.0","scripts":{"postinstall":"x"}}\\n' > package.json`]);
|
||
expect([built.status, built.stdout.includes('STOP: behavior, tests or build inputs changed since review (package.json)')]).toEqual([3, true]);
|
||
});
|
||
});
|
||
|
||
describe('push (Step 17)', () => {
|
||
// Value: protects=push refuses a branch other than Step 1's saved one, waits for the one-time guard offer, refuses open gate findings on a ready PR without approval, then pushes once and reports ALREADY_PUSHED after; fails_when=it pushes past an unanswered offer or a ready PR with open gate findings, or pushes twice;
|
||
// why_new=moved from Step 17's idempotency block; seam=gstack-config, gh, git ls-remote, gstack-pregate sealed; no_claim=the guard block's hook install
|
||
test('an unanswered guard offer stops; open gate findings on a ready PR stop; then it pushes once', () => {
|
||
const f = fx();
|
||
expect(f.run(RELEASE, ['push']).status).toBe(2);
|
||
const moved = f.run(RELEASE, ['push', '--branch', 'other']);
|
||
expect(moved.status).toBe(3);
|
||
expect(moved.stdout).toContain('STOP: HEAD is on feat, not the branch Step 1 saved (other)');
|
||
const unanswered = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||
expect(unanswered.status).toBe(3);
|
||
expect(unanswered.stdout).toContain("STOP: Step 17's one-time pre-push guard offer is unanswered");
|
||
fs.mkdirSync(f.state, { recursive: true });
|
||
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
|
||
f.gh('echo 12');
|
||
const gate = f.run(RELEASE, ['push', '--branch', 'feat', '--gate-open', '2']);
|
||
expect(gate.status).toBe(3);
|
||
expect(gate.stdout).toContain('STOP: 2 open gate finding(s) and ready PR #12');
|
||
const pushed = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||
expect(pushed.status).toBe(0);
|
||
expect(lineOf(pushed.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/feat$/);
|
||
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'PUSH')).toMatch(/^PUSH: ALREADY_PUSHED /);
|
||
});
|
||
|
||
// Value: protects=a rejected push stops with the merge-and-reverify route and never force-pushes; fails_when=the bin retries with force or reports success;
|
||
// why_new=moved from Step 17's failure rules; seam=git push; no_claim=the merge
|
||
test('a non-fast-forward push stops with the merge route', () => {
|
||
const f = fx();
|
||
fs.mkdirSync(f.state, { recursive: true });
|
||
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
|
||
expect(f.run(RELEASE, ['push', '--branch', 'feat']).status).toBe(0);
|
||
f.git('reset', '-q', '--hard', 'HEAD~1');
|
||
f.write({ 'src/z.ts': 'export const z = 1;\n' }); f.commit('diverge');
|
||
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
|
||
expect(r.status).toBe(3);
|
||
expect(r.stdout).toContain('STOP: non-fast-forward');
|
||
expect(r.stdout).toContain('never force-push');
|
||
});
|
||
});
|
||
|
||
describe('gstack-ship-release push: branch names', () => {
|
||
// Value: protects=push's open-PR lookup passes the branch to gh as one argv item; fails_when=a branch named with $(...) runs a command before the push guards;
|
||
// why_new=GSTA-167 /ship review of #3633 (security, CRITICAL); seam=gh; no_claim=gh's own handling of the name
|
||
test('a branch name with shell syntax is never executed by the gate lookup', () => {
|
||
const f = fx();
|
||
const name = 'x$(touch${IFS}PWNED)';
|
||
expect(f.git('checkout', '-qb', name).status).toBe(0);
|
||
fs.mkdirSync(f.state, { recursive: true });
|
||
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
|
||
f.gh('echo 12');
|
||
f.run(RELEASE, ['push', '--branch', name, '--gate-open', '1']);
|
||
expect(fs.existsSync(path.join(f.work, 'PWNED'))).toBe(false);
|
||
});
|
||
});
|