mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
Merge origin/main (703b0049, v1.91.280.0) into flaky-2026-10-27-paid
Rule 19 grant from GStack Minder for #3516 after train 26 free run 40556676971 failed test/parity-suite.test.ts on main+#3516. CHANGELOG.md resolved with mergeChangelogs (additive); no other conflicts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
889c66a352
66 files changed
+1193
-271
No files matched your search
@@ -5,7 +5,8 @@
|
||||
* head's paid slices start only after (1) the PR has been quiet for the
|
||||
* window (no newer push since this run was created; `vars.EVALS_QUIET_MINUTES`,
|
||||
* default 10; the `evals-no-debounce` label sets it to 0) and (2) Free Tests
|
||||
* (free-tests.yml) has concluded success on the same head SHA. Selection,
|
||||
* (free-tests.yml) has concluded success on the same head SHA, as GitHub's
|
||||
* pull_request run or as a workflow_dispatch run on the branch. Selection,
|
||||
* trials and verdict rules are unchanged: only the start time moves.
|
||||
*
|
||||
* A newer push cancels the run through the workflow's cancel-in-progress
|
||||
@@ -40,9 +41,25 @@ export type ReadinessDecision =
|
||||
| { kind: 'superseded'; by: string }
|
||||
| { kind: 'blocked'; reason: string };
|
||||
|
||||
/** The newest Free Tests pull_request run on this exact head decides; a rerun updates that run in place. */
|
||||
/**
|
||||
* The Free Tests run events that count as this head's CI, in order of
|
||||
* precedence: GitHub's own pull_request run (it builds the merge ref, the tree
|
||||
* a merge would test), then a workflow_dispatch run on the branch (the
|
||||
* dispatch route in docs/ship-policy.md for a head whose only conflict with
|
||||
* the base is the CHANGELOG, which GitHub builds no merge ref for and so never
|
||||
* runs pull_request CI on; #3508). A dispatched run builds the head alone, so
|
||||
* it decides only for a head with no pull_request run: it never overrides a red
|
||||
* pull_request run (rerun that run), and a lane's extra dispatch never holds a
|
||||
* head its pull_request CI cleared. A push run is a branch build, not this
|
||||
* head's gate.
|
||||
*/
|
||||
export const FREE_TESTS_EVENTS: readonly string[] = ['pull_request', 'workflow_dispatch'];
|
||||
|
||||
/** The newest Free Tests run on this exact head under the first event present decides; a rerun updates that run in place. */
|
||||
export function freeTestsState(runs: FreeTestsRun[], headSha: string): FreeTestsState {
|
||||
const newest = runs.filter(run => run.head_sha === headSha && run.event === 'pull_request').sort((a, b) => b.id - a.id)[0];
|
||||
const atHead = runs.filter(run => run.head_sha === headSha);
|
||||
const event = FREE_TESTS_EVENTS.find(e => atHead.some(run => run.event === e));
|
||||
const newest = atHead.filter(run => run.event === event).sort((a, b) => b.id - a.id)[0];
|
||||
if (!newest) return { state: 'missing' };
|
||||
if (newest.status !== 'completed') return { state: 'running', runId: newest.id };
|
||||
if (newest.conclusion === 'success') return { state: 'success', runId: newest.id };
|
||||
@@ -111,8 +128,9 @@ export function waitForReadiness(o: WaitOptions): { decision: ReadinessDecision;
|
||||
if (quietUntil > now()) sleep(quietUntil - now());
|
||||
let lastError = '';
|
||||
for (;;) {
|
||||
// Every event at the head; freeTestsState keeps the pull_request and workflow_dispatch runs.
|
||||
const listed = ghJson<{ workflow_runs?: FreeTestsRun[] }>(gh, ['api', '-X', 'GET', `repos/${o.repo}/actions/workflows/free-tests.yml/runs`,
|
||||
'-f', `head_sha=${o.headSha}`, '-f', 'event=pull_request', '-f', 'per_page=20']);
|
||||
'-f', `head_sha=${o.headSha}`, '-f', 'per_page=20']);
|
||||
const pr = ghJson<{ head?: { sha?: string } }>(gh, ['api', `repos/${o.repo}/pulls/${o.pr}`]);
|
||||
lastError = !listed.ok ? listed.error : !pr.ok ? pr.error : '';
|
||||
if (!lastError && listed.ok && pr.ok) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
|
||||
"scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"},
|
||||
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"scripts/test-pr-profile.ts": {"blob":"a80de69f1923afed67127640d68c99e1a8528c5b","since":6,"nonBehavioral":"classify the new weekly-release.yml workflow and scripts/release-cut.ts as free-lane only PR files: the release cut runs on a cron and manual dispatch after merge and inside no case, so no case's selection on a PR, input or verdict changes"},
|
||||
"scripts/test-pr-profile.ts": {"blob":"188710401a08156f585aebcb699151c60e3d6273","since":6,"nonBehavioral":"#3499: RELEASE_METADATA_FILES exempts NOTICE.md, LICENSE and licenses/** and captain/templates/** joins FREE_ONLY_PR_FILES; selection only, no case runs or is judged differently"},
|
||||
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
|
||||
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6},
|
||||
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
|
||||
@@ -49,7 +49,7 @@
|
||||
"test/helpers/session-ledger.ts": {"blob":"a10bf28bf100f135b93c73818670dd147253cdd3","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
|
||||
"test/helpers/session-runner.ts": {"blob":"cc65a66adafa9c053e3da3c6f17b5ac86c0eade7","since":6,"nonBehavioral":"Claude-host verdicts are unchanged: the Codex host (EVALS_HOST=codex, codex-host-runner) runs only when selected and records its own per-model series; the Codex runner refactor (execCodex extraction, onSpawn hook) keeps its events and verdicts and adds token cost accounting; host fields on the plan/report are absent for claude"},
|
||||
"test/helpers/skill-fixture.ts": {"blob":"8a654dae6b0a396d839b02179119ae3e3bb13f69","since":6,"nonBehavioral":"extractSkillBody and extractSkillSections follow the shared-preamble hoist (pointer plus sections/preamble.md); every fixture they build is byte-identical to the pre-hoist output (compared for 8 body and 4 section extractions), so no case's input or verdict changes; the /ship review then reworded only comments and two error messages, so no fixture byte changes"},
|
||||
"test/helpers/skill-startup.ts": {"blob":"f2096462850a33e0913cad4cca2cf3c75eedb0e3","since":6,"nonBehavioral":"new in the shared-preamble hoist: skill-fixture.ts's extraction splices sections/preamble.md through readSharedSections and readSkillEffective, and every fixture it builds is byte-identical to the pre-hoist output (skill-fixture.ts's entry), so pinning it changes no case's input or verdict"},
|
||||
"test/helpers/skill-startup.ts": {"blob":"a8d21e3817c3f35a1d06af325c92fde7d420e377","since":6,"nonBehavioral":"readSkillEffective also appends a variant's added ### subsections (office-hours' Open-ended questions); readSkillStartup, readSharedSections and readSkillEffective return bytes identical to the pinned helper for every tier 2+ render without added subsections (735 across all hosts) and for office-hours' SKILL.md before this PR, so office-hours' fixture input changes only because its prompt changed in this PR, not because the helper did"},
|
||||
"test/helpers/test-selection.ts": {"blob":"51655f89f66be7a5ec487e2abfe4744300ebd5f6","since":6},
|
||||
"test/helpers/touchfiles.ts": {"blob":"15f369056d41b9dc42dcd86f81f60c878074704c","since":6}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
{ file: 'test/pregate-dart.test.ts', reason: 'its dart, flutter and fvm stand-ins are bash scripts on a colon-joined PATH, which Bun.which and CreateProcess cannot use on Windows, and its containment case needs a POSIX symlink; the Dart lane code it drives is POSIX-run like the other pregate fixtures' },
|
||||
{ file: 'test/pregate-non-js.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin and expects the Linux-host lanes verdict, like test/pregate-docs-lane-review.test.ts and test/pregate-selection.test.ts' },
|
||||
{ file: 'test/pregate-remote-gbrain.test.ts', reason: 'spawns bin/gstack-pregate through test/helpers/pregate-fixture.ts runBin (shebang via CreateProcess) and runs the gbrain-shaped ci:ubicloud package script through the POSIX lane runner, like test/pregate-remote.test.ts' },
|
||||
{ file: 'test/pregate-remote-stamp-only.test.ts', reason: 'expects the Linux-host lanes verdict like test/pregate-remote.test.ts (on Windows the fixture\'s Linux lane is a requires-remote obligation) and skips itself on win32 (describe.skipIf), so the curated lane would run an empty file (#3485, #3486)' },
|
||||
// C-pregate: these drive bin/gstack-issue-links and bin/gstack-ship-receipt through
|
||||
// test/helpers/pregate-fixture.ts runBin.
|
||||
{ file: 'test/stamp-templates.test.ts', reason: 'spawns bin/gstack-tree-receipt through test/helpers/restamp-fixture.ts runBin (shebang via CreateProcess)' },
|
||||
|
||||
@@ -64,17 +64,22 @@ const headingOf = (section: string) => section.split('\n', 1)[0]!;
|
||||
|
||||
/**
|
||||
* A skill-specific variant of a shared section, cut down to the `### `
|
||||
* subsections that differ, when the two share their heading, intro and
|
||||
* subsection headings. Otherwise the whole section stays inline.
|
||||
* subsections that differ plus any it adds after the shared ones, when it
|
||||
* keeps the shared heading, intro and subsection headings in order.
|
||||
* Otherwise the whole section stays inline.
|
||||
*/
|
||||
function variantSubsections(own: string, shared: string): string | null {
|
||||
const ownParts = splitSections(own, '### ');
|
||||
const sharedParts = splitSections(shared, '### ');
|
||||
if (ownParts.length < 2 || ownParts.length !== sharedParts.length || ownParts[0] !== sharedParts[0]) return null;
|
||||
if (ownParts.some((part, i) => headingOf(part) !== headingOf(sharedParts[i]!))) return null;
|
||||
const changed = ownParts.slice(1).filter((part, i) => part !== sharedParts[i + 1]);
|
||||
if (changed.length === 0) return null;
|
||||
return [`${headingOf(own)}\n\nOnly these subsections replace the shared ones; the rest of this section is shared.`, ...changed].join('\n\n');
|
||||
if (ownParts.length < 2 || ownParts.length < sharedParts.length || ownParts[0] !== sharedParts[0]) return null;
|
||||
if (sharedParts.some((part, i) => headingOf(part) !== headingOf(ownParts[i]!))) return null;
|
||||
const changed = ownParts.slice(1, sharedParts.length).filter((part, i) => part !== sharedParts[i + 1]);
|
||||
const added = ownParts.slice(sharedParts.length);
|
||||
if (changed.length === 0 && added.length === 0) return null;
|
||||
const note = added.length
|
||||
? 'Only these subsections replace the shared ones or, under a new heading, add to its end; the rest of this section is shared.'
|
||||
: 'Only these subsections replace the shared ones; the rest of this section is shared.';
|
||||
return [`${headingOf(own)}\n\n${note}`, ...changed, ...added].join('\n\n');
|
||||
}
|
||||
|
||||
/** Where the installed SKILL.md finds its copy of the shared file. */
|
||||
|
||||
@@ -36,7 +36,7 @@ Tell these apart:
|
||||
2. **Completeness scores per choice** — explicit on EACH choice, per the Completeness rule in the Format section below; never drop the score.
|
||||
3. **The recommendation and why** — the \`Recommendation: <choice> because <reason>\` line plus the \`(recommended)\` marker on that choice.
|
||||
|
||||
Layout: a \`D<N>\` title; an explicit reply line listing the offered selectors; the issue ELI10; the Recommendation line; ONE paragraph per choice with its \`(recommended)\` marker, \`Completeness: X/10\`, and 2-4 sentences of reasoning (never a bare bullet list); a closing \`Net:\` line. With \`QUESTION_TUNING: true\`, append the checked \`<gstack-qid:{question_id}>\` to the explicit reply line. Split chains / 5+ options: one prose block per per-option call, in order. Before an interactive prose question, finish the tool calls that do not depend on its answer; then send the complete brief as the turn's final message and STOP and wait for the typed answer.${openQuestions ? ' An open-ended question with no options list uses the `Q<N>` form below instead of a `D<N>` brief; a free-text reply answers the most recent unanswered `Q<N>`.' : ''} Do not publish an earlier copy during tool work or follow it with tools or a waiting message. In plan mode this satisfies end-of-turn like a tool call.
|
||||
Layout: a \`D<N>\` title; an explicit reply line listing the offered selectors; the issue ELI10; the Recommendation line; ONE paragraph per choice with its \`(recommended)\` marker, \`Completeness: X/10\`, and 2-4 sentences of reasoning (never a bare bullet list); a closing \`Net:\` line. With \`QUESTION_TUNING: true\`, append the checked \`<gstack-qid:{question_id}>\` to the explicit reply line. Split chains / 5+ options: one prose block per per-option call, in order. Before an interactive prose question, finish the tool calls that do not depend on its answer; then send the complete brief as the turn's final message and STOP and wait for the typed answer. Do not publish an earlier copy during tool work or follow it with tools or a waiting message. In plan mode this satisfies end-of-turn like a tool call.
|
||||
|
||||
**Continuation — mapping a typed reply back to a brief.** Each brief carries a stable label (\`D<N>\`, or \`D<N>.k\` in a split chain) the user references (e.g. "3.2: B"). A bare letter maps to the most-recent UNANSWERED brief; if more than one is open (a split chain), do NOT guess — ask which \`D<N>.k\` it answers. Never apply a bare letter ambiguously across a chain.
|
||||
|
||||
@@ -45,18 +45,7 @@ Layout: a \`D<N>\` title; an explicit reply line listing the offered selectors;
|
||||
### Format
|
||||
|
||||
Every AskUserQuestion is a decision brief and must be sent as tool_use, not prose — unless the documented failure fallback above applies (interactive session + the call is unavailable/erroring), when the prose fallback is the correct output.
|
||||
${openQuestions ? `
|
||||
**Open-question prose form (\`Q<N>\`)** — for open-ended questions with no fixed option set (the Phase 2A/2B diagnostic questions) when you are in prose:
|
||||
|
||||
\`\`\`text
|
||||
Q<N> — <question, verbatim>
|
||||
Why I'm asking: <1-2 sentences: stakes, what a weak answer would mean>
|
||||
What a strong answer sounds like: <the section's "push until you hear" line>
|
||||
Reply in your own words — I'll wait.
|
||||
\`\`\`
|
||||
|
||||
Q-numbering starts at \`Q1\` per invocation, independent of D-numbering. Questions with discrete options always use \`D<N>\`.
|
||||
` : ''}
|
||||
\`\`\`
|
||||
D<N> — <one-line question title>
|
||||
Project/branch/task: <1 short grounding sentence using _BRANCH>
|
||||
@@ -137,5 +126,18 @@ ${planReview ? `Before emitting a tool or prose decision brief, verify:
|
||||
- [ ] If you had 5+ options, you split (or batched into ≤4-groups) — did NOT drop any
|
||||
- [ ] If you split, you checked dependencies between options before firing the chain
|
||||
- [ ] If a per-option Hold fires, you stopped the chain immediately (didn't queue)`}
|
||||
`;
|
||||
${openQuestions ? `
|
||||
### Open-ended questions
|
||||
|
||||
**Open-question prose form (\`Q<N>\`)** — for open-ended questions with no fixed option set (the Phase 2A/2B diagnostic questions) when you are in prose, use this form instead of a \`D<N>\` brief; a free-text reply answers the most recent unanswered \`Q<N>\`:
|
||||
|
||||
\`\`\`text
|
||||
Q<N> — <question, verbatim>
|
||||
Why I'm asking: <1-2 sentences: stakes, what a weak answer would mean>
|
||||
What a strong answer sounds like: <the section's "push until you hear" line>
|
||||
Reply in your own words — I'll wait.
|
||||
\`\`\`
|
||||
|
||||
Q-numbering starts at \`Q1\` per invocation, independent of D-numbering. Questions with discrete options always use \`D<N>\`.
|
||||
` : ''}`;
|
||||
}
|
||||
@@ -218,20 +218,22 @@ Core findings never earn a specialist confidence boost. Preserve \`advisory\`,
|
||||
|
||||
#### 4. Lead judgment
|
||||
|
||||
Reviewers fill the space they are given. Before any merged finding counts, the
|
||||
parent reads each one and drops or downgrades it on these grounds, saying so:
|
||||
- **Nit gravity.** When a reviewer's whole return is style, naming and preference,
|
||||
the code is probably fine; write that down and count nothing from it as a defect.
|
||||
Reviewers fill the space they are given. Before a merged finding counts, the
|
||||
parent reads it and drops or downgrades it on these grounds, saying so:
|
||||
- **Nit gravity.** A reviewer whose whole return is style, naming and preference
|
||||
found nothing; say so and count no defect.
|
||||
- **Hypothetical, not actual.** A defect names the call site or input that reaches
|
||||
it. One that does not is a note until someone traces it.
|
||||
it; one that does not is a note until someone traces it.
|
||||
- **Structure for a case that does not exist.** Advice to abstract, generalize or
|
||||
guard a path nothing takes is advisory, never a defect.
|
||||
guard a path nothing takes is advisory.
|
||||
- **A different design is not a defect.** "I would have done it differently" with
|
||||
no failing case behind it is dropped.
|
||||
no failing case is dropped.
|
||||
- **Missing context.** A finding that assumes a rule the repository documents
|
||||
otherwise is dropped with the pointer.
|
||||
Agreement between reviewers that did not see each other's output is the strongest
|
||||
signal; the \`MULTI-SPECIALIST CONFIRMED\` boost above records it. A reviewer that
|
||||
- **On record.** A class \`docs/review-policy.md\` rules below the floor is a note,
|
||||
named by its ruling.
|
||||
Independent agreement between reviewers is the strongest signal (the
|
||||
\`MULTI-SPECIALIST CONFIRMED\` boost records it). A reviewer that
|
||||
returned \`NO FINDINGS\` was right until a traced defect says otherwise.
|
||||
|
||||
#### 5. Apply specialist confidence gates
|
||||
|
||||
@@ -235,6 +235,7 @@ export const FREE_ONLY_PR_FILES = [
|
||||
'scripts/paid-test-durations.json',
|
||||
'scripts/ubicloud/**',
|
||||
'capy/skill/**', // The vendored Capy skill (Drive bootstrapper): run by Capy agents, read by no paid case; test/capy-skill-wrapper.test.ts covers it.
|
||||
'captain/templates/**', // The /captain crew templates (CREW.md, merge-ask.md, slot-call.md, …): copied into a Captain's drive by people and Captains, read by no paid case; free test/captain-skill.test.ts covers them (#3499).
|
||||
'tsconfig.test.json', // Read only by `tsc -p tsconfig.test.json` (typecheck:test); Bun's runtime reads tsconfig.json.
|
||||
'.gitignore', // Changes which untracked files git reports, never tracked content a case reads.
|
||||
'.gstack/generated.json', // gstack's own regen registry: gstack-regen/gstack-pregate read it from origin/<base> of the repo they run in; paid cases run in fixture repos.
|
||||
@@ -285,6 +286,14 @@ export const FREE_ONLY_PR_FILES = [
|
||||
'.github/workflows/windows-setup-e2e.yml',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Release metadata no paid case reads: the third-party notices and licence
|
||||
* texts, and the root LICENSE. A courtesy entry or a licence line never
|
||||
* widens the PR gate (#3499); CHANGELOG.md and VERSION, which the train stamp
|
||||
* rewrites on every slot, are already exempt in knownNonBehaviorFile.
|
||||
*/
|
||||
export const RELEASE_METADATA_FILES = ['NOTICE.md', 'LICENSE', 'licenses/**'] as const;
|
||||
|
||||
/**
|
||||
* Inputs every paid case consumes that must always restore the full gate
|
||||
* (fail closed, even when a touchfile or a derived reference also names
|
||||
@@ -317,6 +326,7 @@ export function knownNonBehaviorFile(file: string): boolean {
|
||||
// by free doc/generation checks and are not copied into paid fixtures.
|
||||
|| ['AGENTS.md', 'CLAUDE.md', 'agents-digest/gstack-AGENTS.md'].includes(file)
|
||||
|| FREE_ONLY_PR_FILES.some(pattern => matchGlob(file, pattern))
|
||||
|| RELEASE_METADATA_FILES.some(pattern => matchGlob(file, pattern))
|
||||
|| (PERIODIC_ONLY_PR_FILES as readonly string[]).includes(file)
|
||||
|| (file.startsWith('test/fixtures/') && Object.keys(FREE_FIXTURES).some(pattern => matchGlob(file, pattern)))
|
||||
// Free test files anywhere (test/, browse/test/, design/test/, make-pdf/test/): no paid case runs or reads a free test.
|
||||
|
||||
+23
-5
@@ -26,7 +26,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { normalizeChangelog } from '../lib/changelog-check';
|
||||
import { normalizeLockfile } from '../lib/evidence-bundle';
|
||||
import { rewriteStamps, versionAtRef } from '../lib/restamp';
|
||||
import { stampMentions, rewriteStamps, versionAtRef } from '../lib/restamp';
|
||||
import { loadPolicyFromHead, matchesAny, type ShipPolicy } from '../lib/ship-policy';
|
||||
import type { BisectPrefixRecord, TrainGateRecord } from '../lib/ship-queue-train-verdict';
|
||||
import type { CasePanel } from '../lib/ship-queue-train-record';
|
||||
@@ -132,20 +132,38 @@ export function fullGateCases(rootDir = ROOT): Map<string, string> {
|
||||
return plannedCases(buildRunManifest({ tier: 'gate', profile: 'full', sliceCount: 1, evalsAll: true, env: { EVALS_ALL: '1' }, rootDir }), rootDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* A release output or stamp path without its stamps: every version mentioned
|
||||
* in a stamp form (the built-in forms and the policy's `stamp_patterns`
|
||||
* templates) collapses to one placeholder. Normalising only the commit's own
|
||||
* version left a tentative `*_KEY_VERSION = '<next minor>'` as written on the
|
||||
* unstamped prefix and normalised once the slot stamp moved it to the released
|
||||
* version, so the two digests differed and train-backstop found no covering
|
||||
* record for main after that train (#3512), and a base-version mention on a
|
||||
* line the base already carried was normalised on the prefix and kept on the
|
||||
* stamped main. Every stamp-form mention collapsing makes the digest blind to
|
||||
* which version a stamp names; the receipt (`stampOnlyLine`) is what judges a
|
||||
* stamp commit's content.
|
||||
*/
|
||||
export function stampFreeText(text: string, templates: readonly string[]): string {
|
||||
let out = text;
|
||||
for (const v of [...stampMentions(text, templates)].sort((a, b) => b.length - a.length)) out = rewriteStamps(out, v, '\0VERSION', templates).text;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A commit's tree modulo stamps: every path with its mode and blob, except the
|
||||
* files a restamp rewrites, which contribute their content without the stamp
|
||||
* (the version file nothing, package.json without its version, lockfiles
|
||||
* without their root version, the CHANGELOG with its headings normalized,
|
||||
* release outputs and stamp paths with their own version replaced). Two
|
||||
* commits that differ only by a restamp have the same digest.
|
||||
* release outputs and stamp paths with every stamp-form version mention
|
||||
* replaced). Two commits that differ only by a restamp have the same digest.
|
||||
*/
|
||||
export function stampFreeTree(git: Git, commit: string, repoRoot: string, policy: ShipPolicy): string | { error: string } {
|
||||
const listed = git(['ls-tree', '-r', '-z', '--full-tree', commit]);
|
||||
if (!listed.ok) return { error: `cannot list ${commit.slice(0, 12)} (${listed.out.split('\n')[0]})` };
|
||||
const at = versionAtRef(repoRoot, commit);
|
||||
const versionRel = 'rel' in at ? at.rel : 'VERSION';
|
||||
const version = 'version' in at ? at.version : null;
|
||||
const lines: string[] = [];
|
||||
for (const item of listed.out.split('\0').filter(Boolean)) {
|
||||
const [meta, rel] = [item.slice(0, item.indexOf('\t')), item.slice(item.indexOf('\t') + 1)];
|
||||
@@ -161,7 +179,7 @@ export function stampFreeTree(git: Git, commit: string, repoRoot: string, policy
|
||||
if (stamp === 'manifest') { try { const parsed = JSON.parse(text); delete parsed.version; text = JSON.stringify(parsed); } catch { /* not JSON: kept verbatim */ } }
|
||||
else if (stamp === 'lockfile') text = normalizeLockfile(base, text);
|
||||
else if (stamp === 'changelog') text = normalizeChangelog(text);
|
||||
else if (version) text = rewriteStamps(text, version, '\0VERSION', policy.stamp_patterns).text;
|
||||
else text = stampFreeText(text, policy.stamp_patterns);
|
||||
lines.push(`${rel}\0${mode}\0${createHash('sha256').update(text).digest('hex')}`);
|
||||
}
|
||||
return createHash('sha256').update(lines.sort().join('\n')).digest('hex');
|
||||
|
||||
@@ -393,7 +393,7 @@ vcpus() { [[ "$1" =~ -([0-9]+)$ ]] && echo "${BASH_REMATCH[1]}"; }
|
||||
# maximum the last refusal printed; without either the API's own refusal rules.
|
||||
QUOTA_CACHE_MAX_AGE=86400
|
||||
quota_preflight() {
|
||||
local location=$1 name=$2 size=$3 need max used wait_until remaining source="UBI_QUOTA_VCPUS" cache="$STATE_ROOT/quota-max"
|
||||
local location=$1 name=$2 size=$3 need max used remaining step source="UBI_QUOTA_VCPUS" cache="$STATE_ROOT/quota-max"
|
||||
need=$(vcpus "$size") || return 0
|
||||
max=$QUOTA_VCPUS
|
||||
if [ -z "$max" ] && [ -f "$cache" ]; then
|
||||
@@ -406,19 +406,22 @@ quota_preflight() {
|
||||
fi
|
||||
fi
|
||||
[[ "$max" =~ ^[0-9]+$ ]] || return 0
|
||||
wait_until=$(( $(date +%s) + QUOTA_WAIT ))
|
||||
# The budget counts seconds spent waiting for room, not the usage calls
|
||||
# between them: a slow first usage read must not shave the printed budget.
|
||||
remaining=$QUOTA_WAIT
|
||||
while :; do
|
||||
used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}')
|
||||
[[ "$used" =~ ^[0-9]+$ ]] || return 0
|
||||
[ $(( used + need )) -gt "$max" ] || return 0
|
||||
remaining=$(( wait_until - $(date +%s) ))
|
||||
if [ "$remaining" -le 0 ]; then
|
||||
log "quota preflight refused $location/$name ($size): it needs $need vCPUs and the project's VMs already use $used of $max (ceiling from $source; other usage such as managed runners counts on top)"
|
||||
cmd_usage >&2 || true
|
||||
die "create refused before any request: vCPU quota exhausted (quota full); wait for running VMs to finish (UBI_QUOTA_WAIT=<seconds> waits here), run $0 reap, use a smaller size, or set UBI_QUOTA_VCPUS to the project's real ceiling"
|
||||
fi
|
||||
log "quota: $used of $max vCPUs in use and $need needed; waiting up to ${remaining}s more for room"
|
||||
sleep $(( remaining < 30 ? remaining : 30 ))
|
||||
step=$(( remaining < 30 ? remaining : 30 ))
|
||||
sleep "$step"
|
||||
remaining=$(( remaining - step ))
|
||||
done
|
||||
}
|
||||
|
||||
@@ -587,35 +590,69 @@ cmd_down() {
|
||||
}
|
||||
|
||||
# pack SRC: write a gzipped checkout tarball to stdout (tracked + untracked
|
||||
# files that are not ignored, plus .git; a non-git directory is taken whole).
|
||||
# A linked worktree's .git is a one-line pointer into the main repository,
|
||||
# useless on the VM, so it is replaced by a self-contained clone of the
|
||||
# checked-out branch carrying the worktree's own index.
|
||||
# files that are not ignored, plus a .git snapshot; a non-git directory is
|
||||
# taken whole). The live .git is never tarred: another process running git in
|
||||
# the checkout (a review subagent's diff, a trial merge's worktree add) writes
|
||||
# index.lock, packed-refs and objects while tar reads, and GNU tar exits 1
|
||||
# on a file that changed under it (GSTA-121's PREGATE_REMOTE_INFRA). Instead
|
||||
# the refs are fetched into a fresh repository through git's own transport,
|
||||
# which reads a consistent view, the remote.* config is replayed so
|
||||
# origin/<base> still resolves, and the index is copied (git writes it
|
||||
# atomically through a rename). A linked worktree's .git is a one-line
|
||||
# pointer into the main repository, useless on the VM; the same snapshot
|
||||
# replaces it, on the worktree's own branch and index. Not carried: submodule
|
||||
# repositories (.git/modules) and a partial clone's promisor (the fetch pulls
|
||||
# every missing blob first); gstack has neither.
|
||||
cmd_pack() {
|
||||
local src tmp="" gitdir branch
|
||||
local src tmp gitdir branch head entry
|
||||
src=$(cd "${1:-.}" && pwd)
|
||||
if git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
if [ -f "$src/.git" ]; then
|
||||
branch=$(git -C "$src" rev-parse --abbrev-ref HEAD)
|
||||
[ "$branch" != HEAD ] || die "pack: $src is a linked worktree on a detached HEAD; check out a branch so the VM gets a self-contained .git"
|
||||
tmp=$(mktemp -d)
|
||||
gitdir=$(cd "$src" && cd "$(git rev-parse --git-common-dir)" && pwd)
|
||||
git clone -q --bare --no-local --single-branch --branch "$branch" "$gitdir" "$tmp/.git"
|
||||
git -C "$tmp/.git" config core.bare false
|
||||
# The worktree's index rides along so the VM's status matches, except a
|
||||
# split index, whose shared half lives only in the original git dir.
|
||||
[ "$(git -C "$src" config --bool core.splitIndex 2>/dev/null)" = true ] \
|
||||
|| cp "$(cd "$src" && cd "$(dirname "$(git rev-parse --git-path index)")" && pwd)/index" "$tmp/.git/index"
|
||||
head=$(git -C "$src" rev-parse --verify -q HEAD) || die "pack: $src has no commits; the VM needs a HEAD to fingerprint"
|
||||
branch=$(git -C "$src" symbolic-ref -q --short HEAD || true)
|
||||
tmp=$(mktemp -d)
|
||||
# The path is baked in: a set -e failure unwinds the function before the trap runs, and a local $tmp is unset there.
|
||||
trap "rm -rf $(printf %q "$tmp")" EXIT
|
||||
# No template hooks from a global init.templateDir, and no auto-gc under the tar.
|
||||
git -c gc.auto=0 init -q --bare --template= "$tmp/.git"
|
||||
git -C "$tmp/.git" -c gc.auto=0 fetch -q --no-tags --update-shallow "$src" '+refs/*:refs/*' \
|
||||
|| die "pack: could not snapshot the refs of $src"
|
||||
if [ -z "$branch" ]; then
|
||||
branch="detached-${head:0:12}"
|
||||
git -C "$tmp/.git" fetch -q --no-tags --update-shallow "$src" "+HEAD:refs/heads/$branch"
|
||||
fi
|
||||
git -C "$tmp/.git" symbolic-ref HEAD "refs/heads/$branch"
|
||||
git -C "$tmp/.git" config core.bare false
|
||||
gitdir=$(cd "$src" && cd "$(git rev-parse --git-common-dir)" && pwd)
|
||||
while IFS= read -r -d '' entry; do
|
||||
# A key with no value (`[remote "x"] mirror`) is boolean true.
|
||||
case $entry in *$'\n'*) ;; *) entry="$entry"$'\n'true ;; esac
|
||||
git -C "$tmp/.git" config --add "${entry%%$'\n'*}" "${entry#*$'\n'}"
|
||||
done < <(git -C "$gitdir" config --local --null --get-regexp '^(remote|branch)\.' 2>/dev/null || true)
|
||||
# The checkout's index rides along so the VM's status matches, except a
|
||||
# split index, whose shared half lives only in the original git dir. The
|
||||
# index's blobs that HEAD's tree lacks (staged, intent-to-add, the stages
|
||||
# of an unmerged entry) are behind no ref, so they travel as a pack of
|
||||
# their own; without them the VM's write-tree (gstack-wtree) cannot read a
|
||||
# staged file.
|
||||
if [ "$(git -C "$src" config --bool core.splitIndex 2>/dev/null)" != true ]; then
|
||||
cp "$(cd "$src" && cd "$(dirname "$(git rev-parse --git-path index)")" && pwd)/index" "$tmp/.git/index"
|
||||
# Read the copy, not the live index, so the pack matches it; a gitlink's
|
||||
# sha (mode 160000) is a submodule commit this object store lacks.
|
||||
GIT_INDEX_FILE="$tmp/.git/index" git -C "$src" ls-files -s | awk '$1 != "160000" {print $2}' | sort -u \
|
||||
| comm -23 - <(git -C "$src" ls-tree -r HEAD | awk '{print $3}' | sort -u) \
|
||||
| git -C "$src" pack-objects -q --stdout \
|
||||
| git -C "$tmp/.git" unpack-objects -q \
|
||||
|| die "pack: could not snapshot the staged blobs of $src"
|
||||
fi
|
||||
(
|
||||
cd "$src"
|
||||
{ git ls-files -z -co --exclude-standard; [ -n "$tmp" ] || printf '.git\0'; } \
|
||||
git ls-files -z -co --exclude-standard \
|
||||
| while IFS= read -r -d '' f; do
|
||||
if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi
|
||||
done \
|
||||
| if [ -n "$tmp" ]; then tar --null -T - -c -C "$tmp" .git -zf -; else tar --null -T - -czf -; fi
|
||||
| tar --null -T - -c -C "$tmp" .git -zf -
|
||||
)
|
||||
[ -z "$tmp" ] || rm -rf "$tmp"
|
||||
rm -rf "$tmp"; trap - EXIT
|
||||
else
|
||||
tar -C "$src" -czf - .
|
||||
fi
|
||||
|
||||
Reference in new issue
Block a user