mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
ship-policy: turn changelog_fragments on for gstack (required checks, key version 1.91.336.0); mergeChangelogs keeps a stacked child's only line inside its released parent's list
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
12fac5392a
commit
7c8bdf6f8a
15 files changed
+94
-16
No files matched your search
@@ -14,5 +14,6 @@
|
||||
"pr_count": { "default_pr_count": 1, "split_requires_owner": true },
|
||||
"maintainers": ["garrytan"],
|
||||
"train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 },
|
||||
"min_gstack_version": "1.91.299.0"
|
||||
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
|
||||
"min_gstack_version": "1.91.336.0"
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**gstack now versions its own PRs at merge time: a PR adds `changelog.d/<branch-slug>.md` instead of a version, and trains merge through `train land` instead of slot calls.** `.gstack/ship-policy.json` turns `changelog_fragments` on with the checks every PR runs as `required_checks`, and `min_gstack_version` moves to 1.91.336.0, the release that introduced the key, so an older gstack refuses the file by name.
|
||||
|
||||
### Changed
|
||||
- **`changelog_fragments` is on for gstack** (`.gstack/ship-policy.json`, `changelog.d/README.md`): `required_checks` lists `free-tests`, `windows-free-tests`, `Check VERSION is not stale vs queue`, `quality`, `check-freshness` and `actionlint`. Path-filtered workflows stay out of the list, because a stamp commit of a PR outside their paths never gets those checks.
|
||||
- **`CHANGELOG_FRAGMENTS_KEY_VERSION` is 1.91.336.0**, the version #3594 landed at, in place of the placeholder 1.92.0.0.
|
||||
- **/captain teaches `train land`**: under `changelog_fragments` there is no slot call; the controller runs `gstack-ship-queue train land` per slot in order (`captain/SKILL.md.tmpl`, `docs/skills.md`).
|
||||
|
||||
### Fixed
|
||||
- **A stacked PR whose only own line sits inside its released parent's list merges its CHANGELOG instead of refusing** (`lib/changelog-check.ts`): `mergeChangelogs` filed the line as an addition to the parent's released entry and returned null, so #3613's slot needed a hand resolve. When that would leave the child nothing of its own, the inserted lines are now the child's entry; on #3613's real CHANGELOGs the result equals the resolve that shipped.
|
||||
|
||||
### Tests
|
||||
- `test/changelog-merge-damage.test.ts`: a child whose only own bullet sits at the top of the released parent's `### Changed` list keeps it as its entry and passes `ownEntryAboveMain`; a child with no line of its own stays null.
|
||||
- `test/gstack-ship-policy.test.ts`: every `required_checks` name is a job of a pull_request workflow without a path filter, and `min_gstack_version` equals the key's introducing version.
|
||||
|
||||
## [1.91.340.0] - 2026-10-30
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# CHANGELOG fragments
|
||||
|
||||
A PR adds its CHANGELOG entry here 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. It never edits VERSION, `package.json`'s version or a released CHANGELOG section. The merge step (`gstack-ship-queue land`, or `train land` in a stamped train) assigns the version, folds every fragment into one CHANGELOG section and deletes the files. This README is skipped. See docs/ship-policy.md#changelog-fragments.
|
||||
+14
-1
@@ -90,10 +90,23 @@ hard-code:
|
||||
"stamp_patterns": ["_KEY_VERSION = '{version}'"],
|
||||
"changelog": "CHANGELOG.md",
|
||||
"history": "squash-ok",
|
||||
"stamp_sensitive_gates": ["tests"]
|
||||
"stamp_sensitive_gates": ["tests"],
|
||||
"changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] },
|
||||
"min_gstack_version": "1.91.336.0"
|
||||
}
|
||||
```
|
||||
|
||||
gstack itself runs on [changelog fragments](#changelog-fragments): a PR adds
|
||||
`changelog.d/<branch-slug>.md` and never a version, and trains merge through
|
||||
[`train land`](#stamped-trains-under-changelog_fragments-train-land) instead
|
||||
of slot calls. `required_checks` names the pull_request checks that run on
|
||||
every PR (branch protection enforces only `free-tests`); a workflow with a path
|
||||
filter (Windows Setup E2E, make-pdf gate, Native ARM Setup Smoke, Dependency
|
||||
Review) is left out, because a stamp commit of a PR outside its paths never
|
||||
gets that check and `train land` would hold on it. `min_gstack_version` is
|
||||
the release that introduced the key, so an older gstack 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.
|
||||
|
||||
|
||||
+12
-4
@@ -254,11 +254,19 @@ function fragments(removed: string, added: string): { old: string; new: string }
|
||||
* the child changed inside them becomes a correction bullet under the child's
|
||||
* own `### Gate integrity` (the convention #3453's entry used for its parent),
|
||||
* so no wording the gate carried is lost and the released entry below stays
|
||||
* byte for byte main's. Null when the shape is something else: no single
|
||||
* `[Unreleased]` on our side, no released entry of theirs the base lacks whose
|
||||
* text our section holds, or nothing of the child's own left.
|
||||
* byte for byte main's. A line the child inserted between two of the parent's
|
||||
* lines counts as an addition to the parent's entry, unless that would leave
|
||||
* the child nothing of its own: then the inserted lines are the child's entry
|
||||
* (#3613 put its only bullet at the top of #3595's `### Changed` list). Null
|
||||
* when the shape is something else: no single `[Unreleased]` on our side, no
|
||||
* released entry of theirs the base lacks whose text our section holds, or
|
||||
* nothing of the child's own left either way.
|
||||
*/
|
||||
export function carryReleasedParent(ours: string, theirs: string, base: string): { text: string; notes: string[] } | null {
|
||||
return carryWith(ours, theirs, base, true) ?? carryWith(ours, theirs, base, false);
|
||||
}
|
||||
|
||||
function carryWith(ours: string, theirs: string, base: string, insertsJoinParent: boolean): { text: string; notes: string[] } | null {
|
||||
const a = parseChangelog(ours);
|
||||
const unreleased = a.sections.filter(s => s.unreleased);
|
||||
if (unreleased.length !== 1) return null;
|
||||
@@ -302,7 +310,7 @@ export function carryReleasedParent(ours: string, theirs: string, base: string):
|
||||
}
|
||||
}
|
||||
// Lines added between two of the parent's own lines, or after its last beside an edit there, belong to its entry; the rest are the child's.
|
||||
if (k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) {
|
||||
if (insertsJoinParent && k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) {
|
||||
parentAt.add(j);
|
||||
bullets.push(`- Addition to the ${v} entry below: ${strip(lines[j]!)}`);
|
||||
notes.push(`${v}: added "${strip(lines[j]!).slice(0, 60)}"`);
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0';
|
||||
// its key, so a value a stamp did not move fails there.
|
||||
export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0';
|
||||
export const DOCS_CAPS_KEY_VERSION = '1.91.270.0';
|
||||
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.92.0.0';
|
||||
export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { mergePolicy, validatePolicy } from '../lib/ship-policy';
|
||||
import { buildReceipt } from '../lib/tree-receipt';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = () => { const r = makeFixtureRepo({ policy: POLICY }); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
@@ -139,6 +139,21 @@ describe("a stacked child whose parent was released since the fork (GSTA-96, tra
|
||||
expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n- a\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull();
|
||||
});
|
||||
|
||||
// Value: protects=a stacked child whose only own line sits inside the released parent's list merges instead of refusing; fails_when=the inserted line is filed as an addition to the parent and the merge returns null (#3613 slot 2, train 57, needed a hand resolve); why_new=every shape above has an own entry outside the parent's lines; seam=none; no_claim=where a line inserted beside the child's own entry goes
|
||||
test("#3613's shape: a child whose only own line sits inside the released parent's list keeps that line as its entry", () => {
|
||||
const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\n';
|
||||
const theirs = `# C\n\n${released}\n## [1.0.0] - d\n\n- base\n`;
|
||||
const base = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n';
|
||||
const child = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- mine\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n';
|
||||
const r = carryReleasedParent(child, theirs, base)!;
|
||||
expect(r.notes).toEqual([]);
|
||||
expect(r.text).toBe('# C\n\n## [Unreleased]\n\n### Changed\n- mine\n\n## [1.0.0] - d\n\n- base\n');
|
||||
const merged = mergeChangelogs(child, theirs, base)!;
|
||||
expect(merged).toBe(`# C\n\n## [Unreleased]\n\n### Changed\n- mine\n\n${released}\n## [1.0.0] - d\n\n- base\n`);
|
||||
expect(ownEntryAboveMain(merged, theirs, new Set(['### Changed', '- mine', '- one', '- two', '- new thing', '### Added', '**Parent paragraph.**']))).toBeNull();
|
||||
expect(carryReleasedParent('# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull();
|
||||
});
|
||||
|
||||
// Value: protects=carryReleasedParent rewrites only a child that still holds the released parent's entry and keeps an existing Gate integrity section's neighbours in place; fails_when=an unrelated [Unreleased] or an already-stamped head is rewritten or the correction lands outside Gate integrity; why_new=the shapes test above never has a child without the parent's entry, a head without [Unreleased], or a Gate integrity section followed by another heading; seam=none; no_claim=the wording of corrections beyond one edited line
|
||||
test('shapes: a child that does not hold the released parent, or a head already stamped, is null; a correction joins the child\'s existing Gate integrity section without crossing the next heading', () => {
|
||||
const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n';
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy';
|
||||
import { CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue } from '../lib/ship-queue-train';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
@@ -126,6 +126,28 @@ describe("gstack's own .gstack/ship-policy.json", () => {
|
||||
for (const wf of [raw.train.gate_workflow, raw.train.free_workflow]) expect(fs.existsSync(path.join(root, '.github/workflows', wf))).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=train land on gstack itself: every changelog_fragments.required_checks name is a job of a pull_request workflow without a path filter, so a stamp commit always gets it; fails_when=a name is misspelled or names a path-filtered workflow's job, and train land holds every slot whose PR skips those paths; why_new=the key was off on gstack; seam=the policy file and .github/workflows; no_claim=that the checks pass
|
||||
test('turns changelog_fragments on with required checks every PR runs', () => {
|
||||
const root = path.join(import.meta.dir, '..');
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(root, '.gstack', 'ship-policy.json'), 'utf8'));
|
||||
expect(raw.changelog_fragments.dir).toBe('changelog.d');
|
||||
expect(fs.existsSync(path.join(root, 'changelog.d', 'README.md'))).toBe(true);
|
||||
expect(raw.min_gstack_version).toBe(CHANGELOG_FRAGMENTS_KEY_VERSION);
|
||||
const jobs = new Set<string>();
|
||||
for (const f of fs.readdirSync(path.join(root, '.github/workflows')).filter(f => f.endsWith('.yml'))) {
|
||||
const text = fs.readFileSync(path.join(root, '.github/workflows', f), 'utf8');
|
||||
const on = text.slice(text.search(/^on:/m), text.search(/^jobs:/m));
|
||||
if (!/^\s+pull_request:/m.test(on) || /^\s+paths(-ignore)?:/m.test(on)) continue;
|
||||
const body = text.slice(text.search(/^jobs:/m));
|
||||
for (const m of body.matchAll(/^ ([A-Za-z0-9_-]+):\n((?: .*\n|\s*\n)*)/gm)) {
|
||||
jobs.add(m[1]!);
|
||||
const name = /^ name:\s*(.+)$/m.exec(m[2]!)?.[1]?.trim();
|
||||
if (name) jobs.add(name);
|
||||
}
|
||||
}
|
||||
expect(raw.changelog_fragments.required_checks.filter((n: string) => !jobs.has(n))).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=the fill rule the coordinator relies on: a train dispatches at 3 READY PRs or one hour after the first READY, whichever comes first
|
||||
// (2026-10-26: 5 PRs / 3 hours left READY PRs waiting most of a day); fails_when=the live block drifts back to a larger or slower fill; why_new=the values were unpinned; seam=the policy file; no_claim=fillDue's arithmetic (ship-queue-train-logic)
|
||||
test('fills a train at 3 READY PRs or one hour after the first READY, whichever comes first', () => {
|
||||
@@ -161,7 +183,7 @@ describe('gstack-ship-policy (bin)', () => {
|
||||
expect(r.out).toMatch(/^ship: release_tool: `node gen\.js` runs only inside gstack-restamp/m);
|
||||
expect(r.out).not.toMatch(/^ship: history/m);
|
||||
expect(r.out).not.toMatch(/^ship: changelog_fragments/m);
|
||||
const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.92.0.0' } });
|
||||
const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.91.336.0' } });
|
||||
expect(runBin(frag, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out).toMatch(/^ship: changelog_fragments changelog\.d: Step 12 writes no version and Step 13 writes the entry as changelog\.d\/<branch-slug>\.md .*never an \[Unreleased\] section; lint it with `gstack-prose-lint changelog\.d\/<branch-slug>\.md --gate`/m);
|
||||
const strict = fixture({ policy: { ...BASE_POLICY, history: 'merge-only', queue_mode: 'claim-at-open', bump: 'ask', release_tool: null, preregistration_shas: ['0123456789abcdef0123456789abcdef01234567'] } });
|
||||
const lines = runBin(strict, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out.split('\n').filter(l => l.startsWith('ship: '));
|
||||
|
||||
@@ -297,7 +297,7 @@ describe.skipIf(process.platform === 'win32')('clear --receipt under changelog_f
|
||||
test('the fold of a deleted fragment is a stamp; a fold that changed the text is not', () => {
|
||||
const repo = makeFixtureRepo({
|
||||
pregate: { ...BASE_PREGATE, remote_gate: GATE },
|
||||
baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' },
|
||||
baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' },
|
||||
});
|
||||
repos.push(repo);
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolveReleaseConflicts } from '../lib/ship-queue-land';
|
||||
import { mergePolicy } from '../lib/ship-policy';
|
||||
import { persist, repoIdentity } from '../lib/ship-queue-train-record';
|
||||
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const fixtures: TrainFixture[] = [];
|
||||
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { stampChain, stampMismatch, type StampChainOptions } from '../lib/ship-q
|
||||
import type { TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('the policy gate', () => {
|
||||
|
||||
// Value: protects=under changelog_fragments ready records the READY receipt land checks, only after every ready check passed; fails_when=land has no proof of the pregate; why_new=the case above covers the key off; seam=none; no_claim=the notes push to a real GitHub remote
|
||||
test('under changelog_fragments ready records a READY receipt; a failed pregate records none', () => {
|
||||
const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' });
|
||||
const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' });
|
||||
const head = openPr(fx, '1');
|
||||
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'node:path';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, ROOT, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts');
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const fixture = () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'version-gate-frag-' }); repos.push(r); return r; };
|
||||
|
||||
Reference in new issue
Block a user