From 5bbc0e907d11a1acfc95b0db9854cc25c6ddf80a Mon Sep 17 00:00:00 2001
From: Pedro Lobato <69770518+Lob26@users.noreply.github.com>
Date: Mon, 7 Sep 2026 14:32:46 -0500
Subject: [PATCH 1/2] feat(cli): take instance bootstrap values from the
environment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Binding the first owner is the one step between a started bundle and a
usable instance, and today it needs a local Node toolchain, the published
CLI, and ten flags typed correctly. The CLI already travels inside the api
image for exactly this reason, but nothing in docker-compose.yml reaches
it.
`facility instance bootstrap` now reads each option from its
`FACILITY_` variable — `--org-slug` from `FACILITY_ORG_SLUG` — so
a one-shot container task carries the binding in its environment and needs
no command line at all. A `bootstrap` Compose profile runs it:
docker compose --profile bootstrap run --rm bootstrap
Environment rather than arguments is the point, not a convenience. An
organization name and a GitHub login are operator input, and the shape
that would otherwise fit a Compose service is `sh -c "facility instance
bootstrap --org-name $FACILITY_ORG_NAME ..."`, which interpolates that
input into a command. Passing values as variables to an exec-form command
removes the shell from the path instead of quoting around it.
Precedence and validation are deliberate: an explicit option wins over its
variable, a malformed option fails rather than being rescued by an ambient
one, and a value is validated identically whichever way it arrived — a
variable is not more trusted for having come from the environment. Missing
values are reported under both spellings, because the operator reading
that error in a container log has only the variable.
The profile keeps `bootstrap` out of `docker compose up`, and its
variables use `:-` rather than `:?` on purpose: a required-variable
interpolation is evaluated for the whole file and would fail `up` itself.
The CLI names what is missing.
Covered without Postgres or the network by refusing at the first database
call: an environment-only run reaches it, an explicit option overrides a
variable, a blank option still defers to one, a malformed option does not,
and the missing-value message is pinned in full.
---
apps/docs/docs/reference/cli.md | 14 ++++
apps/docs/docs/self-host/production.md | 5 +-
docker-compose.yml | 29 ++++++++
packages/cli/src/instance.mjs | 57 ++++++++++-----
packages/cli/test/instance.test.mjs | 96 ++++++++++++++++++++++++++
5 files changed, 183 insertions(+), 18 deletions(-)
diff --git a/apps/docs/docs/reference/cli.md b/apps/docs/docs/reference/cli.md
index 12891439..904c9028 100644
--- a/apps/docs/docs/reference/cli.md
+++ b/apps/docs/docs/reference/cli.md
@@ -92,5 +92,19 @@ The command takes a PostgreSQL advisory lock. Repeating the exact binding is saf
it already exists; a different binding against a populated instance is refused. Use `--json` for
automation and protect `DATABASE_URL` as an administrative secret.
+Every option also reads a `FACILITY_ ` environment variable — `--org-slug` reads
+`FACILITY_ORG_SLUG`, `--github-installation-id` reads `FACILITY_GITHUB_INSTALLATION_ID` — so a
+container task can supply the binding without a command line and without a shell to expand it. An
+option given on the command line wins over its variable, and a malformed option fails rather than
+falling back to the environment. Values are validated identically whichever way they arrive, and a
+missing one is reported under both names.
+
+The Compose bundle runs this as a one-shot service, which needs no local Node toolchain because the
+CLI ships inside the API image:
+
+```bash
+docker compose --profile bootstrap run --rm bootstrap
+```
+
Run `facility --help` for local usage. Unknown options and missing option values fail
instead of being ignored.
diff --git a/apps/docs/docs/self-host/production.md b/apps/docs/docs/self-host/production.md
index 00e5073d..00871c03 100644
--- a/apps/docs/docs/self-host/production.md
+++ b/apps/docs/docs/self-host/production.md
@@ -90,7 +90,10 @@ secret value when separate rotation is useful.
Create an empty PostgreSQL database, run the deployment migration entrypoint, and require a zero
exit status before starting the API or worker. Then run `facility instance bootstrap` once to bind
the first organization owner and GitHub App installation. Repeating the exact binding is safe; a
-different binding against a populated instance is refused.
+different binding against a populated instance is refused. The command reads each value from its
+`FACILITY_` variable when no option is given, so a one-shot task can carry the binding in
+its environment; the single-host bundle exposes it as `docker compose --profile bootstrap run --rm
+bootstrap`.
For application upgrades:
diff --git a/docker-compose.yml b/docker-compose.yml
index 3429ee50..48fe0d85 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -117,6 +117,35 @@ services:
migrate: { condition: service_completed_successfully }
runner-image: { condition: service_completed_successfully }
+ # One-shot operator bootstrap: binds the first organization, owner identity and
+ # GitHub App installation, then reconciles the bundled roles. Not part of `up`;
+ # run it once after the GitHub App is installed.
+ # docker compose --profile bootstrap run --rm bootstrap
+ # Every value arrives as an environment variable rather than as an argument, so
+ # nothing here needs a shell: an organization name or a GitHub login is
+ # operator input, and a `sh -c` command would interpolate it. Missing values
+ # are named by the CLI itself, which is why none of these use `:?` — that would
+ # fail interpolation for the whole file, `up` included.
+ bootstrap:
+ profiles: ["bootstrap"]
+ build: { context: ., target: api }
+ command: ["facility", "instance", "bootstrap"]
+ environment:
+ DATABASE_URL: postgres://facility:${POSTGRES_PASSWORD:-facility}@postgres:5432/facility
+ FACILITY_ORG_NAME: ${FACILITY_ORG_NAME:-}
+ FACILITY_ORG_SLUG: ${FACILITY_ORG_SLUG:-}
+ FACILITY_OWNER_EMAIL: ${FACILITY_OWNER_EMAIL:-}
+ FACILITY_OWNER_NAME: ${FACILITY_OWNER_NAME:-}
+ FACILITY_GITHUB_USER_ID: ${FACILITY_GITHUB_USER_ID:-}
+ FACILITY_GITHUB_LOGIN: ${FACILITY_GITHUB_LOGIN:-}
+ FACILITY_GITHUB_ACCOUNT_ID: ${FACILITY_GITHUB_ACCOUNT_ID:-}
+ FACILITY_GITHUB_ACCOUNT_LOGIN: ${FACILITY_GITHUB_ACCOUNT_LOGIN:-}
+ FACILITY_GITHUB_INSTALLATION_ID: ${FACILITY_GITHUB_INSTALLATION_ID:-}
+ FACILITY_GITHUB_ACCOUNT_TYPE: ${FACILITY_GITHUB_ACCOUNT_TYPE:-organization}
+ depends_on:
+ migrate: { condition: service_completed_successfully }
+ restart: "no"
+
web:
build:
context: .
diff --git a/packages/cli/src/instance.mjs b/packages/cli/src/instance.mjs
index a6bdf1f5..fcf9b0e7 100644
--- a/packages/cli/src/instance.mjs
+++ b/packages/cli/src/instance.mjs
@@ -4,23 +4,39 @@ import postgres from "postgres";
export async function bootstrapInstance(flags, options = {}) {
if (flags.help) {
console.log("facility instance bootstrap --org-name --org-slug --owner-email --owner-name --github-user-id --github-login --github-account-id --github-account-login --github-installation-id [--github-account-type ] [--json]");
+ console.log("Each option also reads its FACILITY_ environment variable, so a container task needs no command line. An option given on the command line wins.");
return 0;
}
- const databaseUrl = options.databaseUrl ?? process.env.DATABASE_URL;
+ const environment = options.environment ?? process.env;
+ const databaseUrl = options.databaseUrl ?? environment.DATABASE_URL;
if (!databaseUrl) return failure(flags, "DATABASE_URL is required");
- const input = {
- orgName: stringFlag(flags, "org-name"),
- orgSlug: stringFlag(flags, "org-slug"),
- ownerEmail: stringFlag(flags, "owner-email")?.toLowerCase(),
- ownerName: stringFlag(flags, "owner-name"),
- githubUserId: positiveInteger(flags, "github-user-id"),
- githubLogin: stringFlag(flags, "github-login"),
- githubAccountId: positiveInteger(flags, "github-account-id"),
- githubInstallationId: positiveInteger(flags, "github-installation-id"),
- githubAccountLogin: stringFlag(flags, "github-account-login"),
- githubAccountType: (stringFlag(flags, "github-account-type") ?? "organization").toLowerCase(),
+ // Resolve every option to its raw string before parsing, so a malformed
+ // command line fails instead of being rescued by an ambient variable.
+ const option = (name) => stringFlag(flags, name) ?? trimmed(environment[environmentName(name)]);
+ const fields = {
+ orgName: ["org-name", option("org-name")],
+ orgSlug: ["org-slug", option("org-slug")],
+ ownerEmail: ["owner-email", option("owner-email")?.toLowerCase()],
+ ownerName: ["owner-name", option("owner-name")],
+ githubUserId: ["github-user-id", positiveInteger(option("github-user-id"))],
+ githubLogin: ["github-login", option("github-login")],
+ githubAccountId: ["github-account-id", positiveInteger(option("github-account-id"))],
+ githubInstallationId: [
+ "github-installation-id",
+ positiveInteger(option("github-installation-id")),
+ ],
+ githubAccountLogin: ["github-account-login", option("github-account-login")],
+ githubAccountType: [
+ "github-account-type",
+ (option("github-account-type") ?? "organization").toLowerCase(),
+ ],
};
- const missing = Object.entries(input).filter(([, value]) => value === undefined).map(([key]) => key);
+ const input = Object.fromEntries(Object.entries(fields).map(([key, [, value]]) => [key, value]));
+ // Name both spellings: this command runs as often from a container task, where
+ // only the variable exists, as from a shell.
+ const missing = Object.values(fields)
+ .filter(([, value]) => value === undefined)
+ .map(([name]) => `--${name} (${environmentName(name)})`);
if (missing.length) return failure(flags, `Missing required bootstrap values: ${missing.join(", ")}`);
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.orgSlug)) return failure(flags, "--org-slug must be a lowercase URL slug");
if (!/^\S+@\S+\.\S+$/.test(input.ownerEmail)) return failure(flags, "--owner-email must be valid");
@@ -94,13 +110,20 @@ export async function bootstrapInstance(flags, options = {}) {
}
function stringFlag(flags, name) {
- const value = flags[name];
+ return trimmed(flags[name]);
+}
+
+function trimmed(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
-function positiveInteger(flags, name) {
- const value = Number(stringFlag(flags, name));
- return Number.isSafeInteger(value) && value > 0 ? value : undefined;
+function environmentName(option) {
+ return `FACILITY_${option.replaceAll("-", "_").toUpperCase()}`;
+}
+
+function positiveInteger(value) {
+ const parsed = Number(value);
+ return value !== undefined && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function id(prefix) {
diff --git a/packages/cli/test/instance.test.mjs b/packages/cli/test/instance.test.mjs
index 330e7030..31752679 100644
--- a/packages/cli/test/instance.test.mjs
+++ b/packages/cli/test/instance.test.mjs
@@ -16,10 +16,106 @@ const valid = {
json: true,
};
+const environment = {
+ FACILITY_ORG_NAME: "Facility Test",
+ FACILITY_ORG_SLUG: "facility-test",
+ FACILITY_OWNER_EMAIL: "Owner@Example.com",
+ FACILITY_OWNER_NAME: "Owner",
+ FACILITY_GITHUB_USER_ID: "123",
+ FACILITY_GITHUB_LOGIN: "owner",
+ FACILITY_GITHUB_ACCOUNT_ID: "456",
+ FACILITY_GITHUB_INSTALLATION_ID: "789",
+ FACILITY_GITHUB_ACCOUNT_LOGIN: "facility-test",
+};
+
+// Fails at the first database call, so a run that reaches it has passed every
+// validation without needing Postgres or the network.
+function refusingPostgres() {
+ const sql = () => {
+ throw new Error("unreachable");
+ };
+ sql.begin = async () => {
+ throw new Error("reached-the-database");
+ };
+ sql.end = async () => {};
+ return () => sql;
+}
+
+async function captureJson(run) {
+ const written = [];
+ const original = console.log;
+ console.log = (line) => written.push(line);
+ try {
+ return { code: await run(), output: written.map((line) => JSON.parse(line)) };
+ } finally {
+ console.log = original;
+ }
+}
+
test("bootstrap validates all identity and installation bindings before connecting", async () => {
assert.equal(await bootstrapInstance({ ...valid, "github-user-id": "not-a-number" }, { databaseUrl: "postgres://unused" }), 1);
});
+test("bootstrap takes every value from the environment when no options are given", async () => {
+ const { code, output } = await captureJson(() =>
+ bootstrapInstance(
+ { json: true },
+ { databaseUrl: "postgres://unused", environment, postgres: refusingPostgres() },
+ ),
+ );
+ assert.equal(code, 1);
+ assert.equal(output[0].error.message, "reached-the-database");
+});
+
+test("bootstrap prefers an explicit option over its environment variable", async () => {
+ const { output } = await captureJson(() =>
+ bootstrapInstance(
+ { json: true, "org-slug": "" },
+ {
+ databaseUrl: "postgres://unused",
+ environment: { ...environment, FACILITY_ORG_SLUG: "Not A Slug" },
+ postgres: refusingPostgres(),
+ },
+ ),
+ );
+ // A blank option is not a value, so the variable still supplies one — and it
+ // is validated rather than trusted for having come from the environment.
+ assert.equal(output[0].error.message, "--org-slug must be a lowercase URL slug");
+
+ const explicit = await captureJson(() =>
+ bootstrapInstance(
+ { json: true, "org-slug": "from-option" },
+ {
+ databaseUrl: "postgres://unused",
+ environment: { ...environment, FACILITY_ORG_SLUG: "from-environment" },
+ postgres: refusingPostgres(),
+ },
+ ),
+ );
+ assert.equal(explicit.output[0].error.message, "reached-the-database");
+});
+
+test("bootstrap refuses a malformed option instead of falling back to the environment", async () => {
+ const { code, output } = await captureJson(() =>
+ bootstrapInstance(
+ { json: true, "github-user-id": "not-a-number" },
+ { databaseUrl: "postgres://unused", environment, postgres: refusingPostgres() },
+ ),
+ );
+ assert.equal(code, 1);
+ assert.match(output[0].error.message, /^Missing required bootstrap values: /);
+});
+
+test("bootstrap names the environment variable for every value it is missing", async () => {
+ const { output } = await captureJson(() =>
+ bootstrapInstance({ json: true }, { databaseUrl: "postgres://unused", environment: {} }),
+ );
+ assert.equal(
+ output[0].error.message,
+ "Missing required bootstrap values: --org-name (FACILITY_ORG_NAME), --org-slug (FACILITY_ORG_SLUG), --owner-email (FACILITY_OWNER_EMAIL), --owner-name (FACILITY_OWNER_NAME), --github-user-id (FACILITY_GITHUB_USER_ID), --github-login (FACILITY_GITHUB_LOGIN), --github-account-id (FACILITY_GITHUB_ACCOUNT_ID), --github-installation-id (FACILITY_GITHUB_INSTALLATION_ID), --github-account-login (FACILITY_GITHUB_ACCOUNT_LOGIN)",
+ );
+});
+
test("bootstrap is transactional, idempotent for identical input, and rejects conflicts", async (t) => {
const databaseUrl = process.env.DATABASE_URL ?? "postgres://facility:facility@localhost:5461/facility_test";
const admin = postgres(databaseUrl, { max: 1, connect_timeout: 2 });
From 5aee4403e7c102e199c772e1c714918ec6bebd55 Mon Sep 17 00:00:00 2001
From: Pedro Lobato <69770518+Lob26@users.noreply.github.com>
Date: Tue, 15 Sep 2026 08:17:55 -0500
Subject: [PATCH 2/2] test(cli): cover the environment bootstrap through to the
rows it writes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The added coverage stopped at the first database call. That proved the
environment path passes validation and nothing more: the feature is that a
container task writes a binding, and no test watched one being written.
Three Postgres-backed cases now do, each in its own schema so one cannot
read another's binding:
- an environment-only run writes the organization, owner, GitHub identity,
owner membership and installation rows, normalizes the address the same
way the option path does, and re-running it stays one organization with
an identical binding;
- an option beats its variable in the row that lands, including the second
place the installation id is copied to, so precedence is asserted where
it is observable rather than only where it is parsed;
- a conflicting binding from the environment is refused across each
dimension it is made of — identity, person, installation, organization —
and every row is compared before and after, so a refusal cannot be a
partial write.
All five fail with the environment lookup removed. The schema fixture and
the row snapshot are shared with the existing option test rather than
copied, since this change is what would have duplicated them.
---
packages/cli/test/instance.test.mjs | 155 +++++++++++++++++++++++-----
1 file changed, 130 insertions(+), 25 deletions(-)
diff --git a/packages/cli/test/instance.test.mjs b/packages/cli/test/instance.test.mjs
index 31752679..dff7a3ab 100644
--- a/packages/cli/test/instance.test.mjs
+++ b/packages/cli/test/instance.test.mjs
@@ -52,6 +52,46 @@ async function captureJson(run) {
}
}
+// The bootstrap writes into whatever search_path it is handed, so each test
+// gets a schema of its own and drops it afterwards. Isolation is the point:
+// these assert the rows that were written, and a shared schema would make the
+// second test read the first one's binding.
+async function withBootstrapSchema(t, run) {
+ const databaseUrl = process.env.DATABASE_URL ?? "postgres://facility:facility@localhost:5461/facility_test";
+ const admin = postgres(databaseUrl, { max: 1, connect_timeout: 2 });
+ try { await admin`select 1`; } catch { await admin.end(); t.skip("Postgres unreachable"); return; }
+ const schema = `cli_bootstrap_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ await admin.unsafe(`CREATE SCHEMA "${schema}"`);
+ const scoped = new URL(databaseUrl);
+ scoped.searchParams.set("options", `-csearch_path=${schema}`);
+ try {
+ await admin.unsafe(`
+ CREATE TABLE "${schema}".roles (id text primary key, org_id text, name text);
+ CREATE TABLE "${schema}".orgs (id text primary key, name text, slug text unique, settings jsonb);
+ CREATE TABLE "${schema}".users (id text primary key, email text unique, name text, status text);
+ CREATE TABLE "${schema}".user_identities (id text primary key, user_id text, provider text, provider_subject text, login text, metadata jsonb);
+ CREATE TABLE "${schema}".org_members (id text primary key, org_id text, user_id text, role_id text);
+ CREATE TABLE "${schema}".github_installations (id text primary key, org_id text, installation_id bigint, account_id bigint, account_login text, target_type text);
+ INSERT INTO "${schema}".roles (id, org_id, name) VALUES ('role_bundled_owner', null, 'owner');
+ `);
+ await run({ admin, schema, databaseUrl: scoped.toString() });
+ } finally {
+ await admin.unsafe(`DROP SCHEMA "${schema}" CASCADE`);
+ await admin.end();
+ }
+}
+
+// Every row the bootstrap is responsible for, in one shape, so a test can
+// compare the whole binding before and after a refused attempt.
+async function storedBinding(admin, schema) {
+ const [org] = await admin.unsafe(`SELECT name, slug, settings FROM "${schema}".orgs`);
+ const [user] = await admin.unsafe(`SELECT email, name, status FROM "${schema}".users`);
+ const [identity] = await admin.unsafe(`SELECT provider, provider_subject, login FROM "${schema}".user_identities`);
+ const [member] = await admin.unsafe(`SELECT role_id FROM "${schema}".org_members`);
+ const [installation] = await admin.unsafe(`SELECT installation_id::int AS installation_id, account_id::int AS account_id, account_login, target_type FROM "${schema}".github_installations`);
+ return { org, user, identity, member, installation };
+}
+
test("bootstrap validates all identity and installation bindings before connecting", async () => {
assert.equal(await bootstrapInstance({ ...valid, "github-user-id": "not-a-number" }, { databaseUrl: "postgres://unused" }), 1);
});
@@ -117,31 +157,96 @@ test("bootstrap names the environment variable for every value it is missing", a
});
test("bootstrap is transactional, idempotent for identical input, and rejects conflicts", async (t) => {
- const databaseUrl = process.env.DATABASE_URL ?? "postgres://facility:facility@localhost:5461/facility_test";
- const admin = postgres(databaseUrl, { max: 1, connect_timeout: 2 });
- try { await admin`select 1`; } catch { await admin.end(); t.skip("Postgres unreachable"); return; }
- const schema = `cli_bootstrap_${Date.now()}`;
- await admin.unsafe(`CREATE SCHEMA "${schema}"`);
- try {
- await admin.unsafe(`
- CREATE TABLE "${schema}".roles (id text primary key, org_id text, name text);
- CREATE TABLE "${schema}".orgs (id text primary key, name text, slug text unique, settings jsonb);
- CREATE TABLE "${schema}".users (id text primary key, email text unique, name text, status text);
- CREATE TABLE "${schema}".user_identities (id text primary key, user_id text, provider text, provider_subject text, login text, metadata jsonb);
- CREATE TABLE "${schema}".org_members (id text primary key, org_id text, user_id text, role_id text);
- CREATE TABLE "${schema}".github_installations (id text primary key, org_id text, installation_id bigint, account_id bigint, account_login text, target_type text);
- INSERT INTO "${schema}".roles (id, org_id, name) VALUES ('role_bundled_owner', null, 'owner');
- `);
- const scoped = new URL(databaseUrl);
- scoped.searchParams.set("options", `-csearch_path=${schema}`);
- assert.equal(await bootstrapInstance(valid, { databaseUrl: scoped.toString() }), 0);
- assert.equal(await bootstrapInstance(valid, { databaseUrl: scoped.toString() }), 0);
- assert.equal(await bootstrapInstance({ ...valid, "github-user-id": "124" }, { databaseUrl: scoped.toString() }), 1);
- assert.equal(await bootstrapInstance({ ...valid, "owner-name": "Different owner" }, { databaseUrl: scoped.toString() }), 1);
+ await withBootstrapSchema(t, async ({ admin, schema, databaseUrl }) => {
+ assert.equal(await bootstrapInstance(valid, { databaseUrl }), 0);
+ assert.equal(await bootstrapInstance(valid, { databaseUrl }), 0);
+ assert.equal(await bootstrapInstance({ ...valid, "github-user-id": "124" }, { databaseUrl }), 1);
+ assert.equal(await bootstrapInstance({ ...valid, "owner-name": "Different owner" }, { databaseUrl }), 1);
const rows = await admin.unsafe(`SELECT count(*)::int AS count FROM "${schema}".orgs`);
assert.equal(rows[0].count, 1);
- } finally {
- await admin.unsafe(`DROP SCHEMA "${schema}" CASCADE`);
- await admin.end();
- }
+ });
+});
+
+test("bootstrap from the environment writes the same binding the options write", async (t) => {
+ await withBootstrapSchema(t, async ({ admin, schema, databaseUrl }) => {
+ // No option carries a value: this is how the Compose bootstrap profile
+ // invokes it, and the point of the PR is that this path reaches the rows.
+ assert.equal(await bootstrapInstance({ json: true }, { databaseUrl, environment }), 0);
+
+ const stored = await storedBinding(admin, schema);
+ assert.deepEqual(stored.org, {
+ name: "Facility Test",
+ slug: "facility-test",
+ settings: { githubAccountId: 456, githubInstallationId: 789 },
+ });
+ // FACILITY_OWNER_EMAIL is "Owner@Example.com": normalized on the way in,
+ // exactly as the option path normalizes it.
+ assert.deepEqual(stored.user, { email: "owner@example.com", name: "Owner", status: "active" });
+ assert.deepEqual(stored.identity, { provider: "github", provider_subject: "123", login: "owner" });
+ assert.equal(stored.member.role_id, "role_bundled_owner");
+ assert.deepEqual(stored.installation, {
+ installation_id: 789,
+ account_id: 456,
+ account_login: "facility-test",
+ target_type: "Organization",
+ });
+
+ // Re-running a container task must not be a second organization.
+ assert.equal(await bootstrapInstance({ json: true }, { databaseUrl, environment }), 0);
+ const [{ count }] = await admin.unsafe(`SELECT count(*)::int AS count FROM "${schema}".orgs`);
+ assert.equal(count, 1);
+ assert.deepEqual(await storedBinding(admin, schema), stored);
+ });
+});
+
+test("an option beats its variable in the row that is written, not only in validation", async (t) => {
+ await withBootstrapSchema(t, async ({ admin, schema, databaseUrl }) => {
+ assert.equal(
+ await bootstrapInstance(
+ { json: true, "org-slug": "from-option", "github-installation-id": "999" },
+ {
+ databaseUrl,
+ environment: {
+ ...environment,
+ FACILITY_ORG_SLUG: "from-environment",
+ FACILITY_GITHUB_INSTALLATION_ID: "789",
+ },
+ },
+ ),
+ 0,
+ );
+
+ const stored = await storedBinding(admin, schema);
+ assert.equal(stored.org.slug, "from-option");
+ // Precedence has to hold everywhere the value lands, not just in the column
+ // the flag is named after: the installation id is also copied into settings.
+ assert.equal(stored.org.settings.githubInstallationId, 999);
+ assert.equal(stored.installation.installation_id, 999);
+ });
+});
+
+test("a conflicting binding from the environment is refused and leaves every row untouched", async (t) => {
+ await withBootstrapSchema(t, async ({ admin, schema, databaseUrl }) => {
+ assert.equal(await bootstrapInstance({ json: true }, { databaseUrl, environment }), 0);
+ const before = await storedBinding(admin, schema);
+
+ // One conflict per dimension the binding is made of: identity, person, and
+ // installation. Each must be refused rather than merged into the existing
+ // instance, and the refusal must not be a partial write.
+ for (const conflict of [
+ { FACILITY_GITHUB_USER_ID: "124" },
+ { FACILITY_OWNER_NAME: "Different owner" },
+ { FACILITY_GITHUB_INSTALLATION_ID: "790" },
+ { FACILITY_ORG_SLUG: "other-instance" },
+ ]) {
+ assert.equal(
+ await bootstrapInstance({ json: true }, { databaseUrl, environment: { ...environment, ...conflict } }),
+ 1,
+ );
+ }
+
+ assert.deepEqual(await storedBinding(admin, schema), before);
+ const [{ count }] = await admin.unsafe(`SELECT count(*)::int AS count FROM "${schema}".orgs`);
+ assert.equal(count, 1);
+ });
});