Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/scripts/release-pipeline/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ export function tryGit(cwd: string, ...args: string[]): string | null {
}
}

/** A push as git ran it. A refusal is never read from git's words: the caller re-reads origin, and a ref that
* moved since it was observed is a lost compare-and-set to retry, an unmoved one a refusal to throw. */
/** A push as git ran it. A refusal is never read from git's words: the caller re-reads origin. A ref that moved
* since it was observed is a lost compare-and-set to retry, an unmoved one a refusal to throw; a refused deletion
* whose refs are all gone is done. */
export function push(cwd: string, ...pushArgs: string[]): Error | null {
try {
execFileSync("git", ["push", ...pushArgs], { cwd, encoding: "utf8" });
Expand Down
37 changes: 27 additions & 10 deletions .github/scripts/release-pipeline/tags.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* The packaged commits and the refs that name them: a main commit's package under its build tag, the version tag
* minted once on the release's package, and the pointers (latest, vX) that move forward along main alone. Every
* push is a create-once or a lease, judged by re-reading origin, never by git's words.
* push is a create-once, a lease, or the prune's deletion, and a refused one is judged by re-reading origin, never
* by git's words.
*/

import { execFileSync } from "node:child_process";
Expand Down Expand Up @@ -310,14 +311,24 @@ function ensurePackaged(cwd: string, sourceSha: string, tree: string, runUrl?: s
);
}

/** The build tags beyond the `keep` newest by position, deleted in one push. Two runs pruning at once commute: the
* server answers the deletion of a ref a rival deleted first with a warning, not a refusal. A ref under build/ this
* pipeline would not name stops the prune: sorted in, it could push a genuine tag out of the window. */
export function pruneBuildTags(cwd: string, keep = KEPT_BUILD_TAGS): string[] {
const refs = git(cwd, "ls-remote", "origin", `${BUILD_TAG_PREFIX}*`)
/** Every ref origin holds under build/, by name. */
function remoteBuildRefs(cwd: string): string[] {
return git(cwd, "ls-remote", "origin", `${BUILD_TAG_PREFIX}*`)
.split("\n")
.map((line) => line.split("\t")[1] ?? "")
.filter((ref) => ref !== "" && !ref.endsWith("^{}"))
.filter((ref) => ref !== "" && !ref.endsWith("^{}"));
}

/**
* The build tags beyond the `keep` newest by position, deleted in one push. A ref under build/ this pipeline would
* not name stops the prune: sorted in, it could push a genuine tag out of the window.
*
* Two runs pruning at once commute. The server answers a deletion a rival made before this push with a warning, and
* one that lands after the push read origin's refs with a refusal, so a refused push counts as done when origin
* holds none of the tags it named.
*/
export function pruneBuildTags(cwd: string, keep = KEPT_BUILD_TAGS): string[] {
const refs = remoteBuildRefs(cwd)
.map((ref) => {
const position = Number(ref.match(BUILD_TAG)?.[1]);
if (Number.isNaN(position)) {
Expand All @@ -331,7 +342,13 @@ export function pruneBuildTags(cwd: string, keep = KEPT_BUILD_TAGS): string[] {
.slice(keep)
.map((tag) => tag.ref);
if (refs.length > 0) {
git(cwd, "push", "origin", ...refs.map((ref) => `:${ref}`));
const refused = push(cwd, "origin", ...refs.map((ref) => `:${ref}`));
if (refused !== null) {
const held = new Set(remoteBuildRefs(cwd));
if (refs.some((ref) => held.has(ref))) {
throw refused;
}
}
}
return refs;
}
Expand Down Expand Up @@ -443,7 +460,7 @@ interface PackageCommitOptions {
}

interface PackageCommitResult extends EnsuredTag {
/** The build tags this run deleted, beyond the kept window. */
/** The build tags beyond the kept window: deleted by this run, or by a rival pruning at once. */
pruned: string[];

/** latest's move: forward to this package, or left where it is. */
Expand Down Expand Up @@ -486,7 +503,7 @@ interface PackagedRelease {
/** The packaged commit the version tag names. */
packagedSha: string;

/** The build tags this run deleted, beyond the kept window. */
/** The build tags beyond the kept window: deleted by this run, or by a rival pruning at once. */
pruned: string[];

/** latest's move: forward to this package, or left where it is. */
Expand Down
45 changes: 45 additions & 0 deletions test/scripts/release-pipeline-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
manifestJson,
moveOf,
originHolds,
originHooks,
PERMANENT,
PLANTED_PACKAGES,
packagedOf,
Expand Down Expand Up @@ -871,6 +872,50 @@ describe("pruneBuildTags", () => {
expect(buildTags(fx).sort()).toEqual(refs.slice(2).sort());
});

test("a rival deleting every candidate inside the delete push, after the push read origin and before origin locks the refs, gets the push refused, and the prune succeeds on its postcondition: the tags it named are gone", () => {
const fx = seedFixture();
const { refs, first, second } = plantWindow(fx);
// The marker proves the rival ran inside the push; the next test pins that git refuses a ref it lost that way.
const marker = join(fx.root, "rival-deleted-inside");
originHooks(fx, {
"pre-receive": `git update-ref -d ${first} && git update-ref -d ${second} && : > "${marker}"\n`,
});
let deleted: string[] | undefined;
const pushes = withPushPlans(fx, [], () => {
deleted = pruneBuildTags(fx.work);
});
expect(existsSync(marker)).toBe(true);
expect(deleted).toEqual([second, first]);
expect(pushes).toEqual([deleteOf(second, first)]);
expect(buildTags(fx).sort()).toEqual(refs.slice(2).sort());
});

test("a refused delete push that leaves one candidate on origin fails with git's own words, though a rival took the other inside it: the postcondition is every tag gone, not some", () => {
const fx = seedFixture();
const { refs, first, second } = plantWindow(fx);
originHooks(fx, {
"pre-receive": `git update-ref -d ${second}\n`,
update: `test "$1" != ${first}\n`,
});
let error: unknown;
const pushes = withPushPlans(fx, [], () => {
try {
pruneBuildTags(fx.work);
} catch (thrown) {
error = thrown;
}
});
expect(pushes).toEqual([deleteOf(second, first)]);
const message = error instanceof Error ? error.message : "";
expect(message).toStartWith(`git push origin :${second} :${first} failed: `);
// git's reason for the ref the rival took has changed across versions; that it rejected the ref has not.
expect(message).toContain(` ! [remote rejected] ${second.replace("refs/tags/", "")} (`);
expect(message).toContain(
` ! [remote rejected] ${first.replace("refs/tags/", "")} (hook declined)`,
);
expect(buildTags(fx).sort()).toEqual(refs.filter((ref) => ref !== second).sort());
});

test("a ref under build/ this pipeline would not name stops the prune", () => {
const fx = seedFixture();
plantWindow(fx, 3);
Expand Down
111 changes: 111 additions & 0 deletions test/scripts/release-pipeline-fixture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test";
import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { packageCommit } from "../../.github/scripts/release-pipeline/tags.js";
import {
buildTagOf,
buildTags,
type Fixture,
installReleasePipelineFixture,
originHooks,
seedFixture,
} from "./release-pipeline-fixture.js";

setDefaultTimeout(60_000);

/** Two variables a launcher can leave in the environment (git exports GIT_DIR to a hook); each redirects every git

Check warning on line 25 in test/scripts/release-pipeline-fixture.test.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

test/scripts/release-pipeline-fixture.test.ts:25: one-line-comment
* run below it. */
const LAUNCHER = ["GIT_CONFIG", "GIT_DIR"] as const;

/** What the suite itself was launched with, put back when the file ends. */
const launchedWith = new Map<string, string | undefined>();

/** The launcher's side: a config file and a repository, hand-written so no git runs before the fixture's seal. */
let launcher = "";
const launcherConfig = (): string => join(launcher, "launcher.gitconfig");
const launcherRepo = (): string => join(launcher, "launched-from.git");

/** The launcher's side as it was written. */
let written: Record<string, string | null> = {};

/** Every path under `dir` with its bytes, a directory as null. */
function snapshot(dir: string): Record<string, string | null> {
return Object.fromEntries(
readdirSync(dir, { recursive: true, encoding: "utf8" })
.sort()
.map((entry) => [
entry,
statSync(join(dir, entry)).isDirectory() ? null : readFileSync(join(dir, entry), "latin1"),
]),
);
}

// Registered ahead of the fixture's own hooks, so the fixture starts in an environment that already redirects git.
beforeAll(() => {
launcher = mkdtempSync(join(tmpdir(), "release-pipeline-launcher-"));
writeFileSync(launcherConfig(), "[launcher]\n\tkept = true\n");
mkdirSync(join(launcherRepo(), "objects"), { recursive: true });
mkdirSync(join(launcherRepo(), "refs"));
writeFileSync(join(launcherRepo(), "HEAD"), "ref: refs/heads/main\n");
writeFileSync(
join(launcherRepo(), "config"),
"[core]\n\trepositoryformatversion = 0\n\tbare = true\n",
);
written = snapshot(launcher);
for (const name of LAUNCHER) {
launchedWith.set(name, process.env[name]);
}
process.env.GIT_CONFIG = launcherConfig();
process.env.GIT_DIR = launcherRepo();
});

installReleasePipelineFixture();

// Registered after the fixture's hooks, so it runs once the fixture ended: the launcher's variables are back.
afterAll(() => {
try {
expect({ config: process.env.GIT_CONFIG, dir: process.env.GIT_DIR }).toEqual({
config: launcherConfig(),
dir: launcherRepo(),
});
} finally {
for (const [name, value] of launchedWith) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
rmSync(launcher, { recursive: true, force: true });
}
});

test("a fixture git command never writes into the repository the tests were launched from: with the launcher's GIT_DIR and GIT_CONFIG in the environment, both stay byte-identical while origin takes its own config and the pipeline's tag", () => {
let fx: Fixture | undefined;
let failure: unknown;
try {
fx = seedFixture();
originHooks(fx, { "pre-receive": "exit 0\n" });
packageCommit({ cwd: fx.work, sourceSha: fx.mergeSha });
} catch (thrown) {
failure = thrown;
}
expect(snapshot(launcher)).toEqual(written);
expect(failure).toBeUndefined();
if (fx === undefined) {
return;
}
const originConfig = readFileSync(join(fx.origin, "config"), "utf8");
expect(originConfig).toContain("[maintenance]\n\tauto = false\n");
expect(originConfig).toContain(`\thooksPath = ${join(fx.root, "origin-hooks")}\n`);
expect(buildTags(fx)).toEqual([buildTagOf(fx, fx.mergeSha)]);
});
52 changes: 48 additions & 4 deletions test/scripts/release-pipeline-fixture.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* The fixture harness behind the release pipeline tests: a bare origin plus clones playing the CI checkouts, a git
* shim first on PATH that refuses a push outside the fixture area and plays scripted remotes (rivals, refusals,
* a landing between a read and the write it informs), and the helpers the test files share.
* a landing between a read and the write it informs), origin's hooks for what lands inside a push, and the helpers
* the test files share.
*/

import { afterAll, beforeAll, expect } from "bun:test";
Expand All @@ -16,7 +17,7 @@
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { devNull, tmpdir } from "node:os";
import { dirname, join } from "node:path";

export function git(cwd: string, ...args: string[]): string {
Expand Down Expand Up @@ -46,8 +47,22 @@
/** The real git, which the shim execs. */
let realGit = "";

/** The per-file hooks: the shim goes first on PATH before the file's tests and comes off after them, and the
* fixture roots the file created are removed; a test file calls this once at module scope. */
/** The caller's GIT_* variables, out of the environment while a file's tests run; undefined until they were. */
let callerGit: Record<string, string> | undefined;

/** What stands in for them: no global and no system config, so a git the suite runs reads its own repository's

Check warning on line 53 in test/scripts/release-pipeline-fixture.ts

View workflow job for this annotation

GitHub Actions / platform / standard-checks

test/scripts/release-pipeline-fixture.ts:53: one-line-comment
* config alone. Identity stays in each clone's config, which GIT_AUTHOR_* and GIT_COMMITTER_* would outrank. */
const SEALED_GIT = { GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_SYSTEM: devNull };

/** Every GIT_* variable the environment holds now. */
const gitVariables = (): [string, string][] =>
Object.entries(process.env).filter(
(entry): entry is [string, string] => entry[0].startsWith("GIT_") && entry[1] !== undefined,
);

/** The per-file hooks: before the file's tests the shim goes first on PATH and the caller's GIT_* variables leave
* the environment, after them both are put back, and the fixture roots the file created are removed; a test file
* calls this once at module scope. */
export function installReleasePipelineFixture(): void {
afterAll(() => {
for (const root of roots.splice(0)) {
Expand Down Expand Up @@ -98,9 +113,22 @@
writeFileSync(join(shimDir, "git"), script, { mode: 0o755 });
realPath = process.env.PATH ?? "";
process.env.PATH = `${shimDir}:${realPath}`;
// The fixture's git calls and the pipeline's both inherit this environment: a GIT_DIR or GIT_CONFIG left in it
// (git exports GIT_DIR to a hook) would send them into the repository the tests were launched from.
callerGit = Object.fromEntries(gitVariables());
for (const name of Object.keys(callerGit)) {
delete process.env[name];
}
Object.assign(process.env, SEALED_GIT);
});
// Undo only what beforeAll got to, so a setup failure surfaces as itself.
afterAll(() => {
if (callerGit !== undefined) {
for (const [name] of gitVariables()) {
delete process.env[name];
}
Object.assign(process.env, callerGit);
}
if (realPath !== undefined) {
process.env.PATH = realPath;
}
Expand Down Expand Up @@ -658,6 +686,22 @@
.map((line) => line.split(" "));
}

/**
* What origin runs inside every later push, after the push read origin's refs and before origin writes any: the gap
* a push plan, which runs before the push starts, cannot reach. `pre-receive` plays a rival landing there, and
* `update` declines the ref in $1 by exiting non-zero.
*
* Origin's own hooksPath, so a developer's global one cannot answer instead.
*/
export function originHooks(fx: Fixture, hooks: { "pre-receive"?: string; update?: string }): void {
const dir = join(fx.root, "origin-hooks");
mkdirSync(dir);
for (const [name, script] of Object.entries(hooks)) {
writeFileSync(join(dir, name), `#!/bin/sh\n${script}`, { mode: 0o755 });
}
git(fx.origin, "config", "core.hooksPath", dir);
}

/** The moving pointer's ref. */
export const LATEST = "refs/tags/latest";

Expand Down
Loading