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: 5 additions & 0 deletions .github/founder-roster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"issue_number": 3,
"policy_commit": "d56279b4f3c25dd8f9e68699d61be23a0c7a6b07",
"founder_logins": ["a-51-50", null, null]
}
47 changes: 47 additions & 0 deletions .github/workflows/founder-adoption.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Verify and close founder agreement

on:
issue_comment:
types: [created, edited, deleted]
issues:
types: [edited, reopened]
push:
branches: [main]
paths:
- '.github/founder-roster.json'
- '.github/workflows/founder-adoption.yml'
- 'scripts/founder-adoption*.mjs'
workflow_dispatch:

permissions:
contents: read
issues: write

concurrency:
group: opendkg-founder-issue-3
cancel-in-progress: false

jobs:
verify:
if: >-
github.repository == 'OpenDKG/.github' &&
(github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' ||
(github.event.issue.number == 3 && !github.event.issue.pull_request))
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout trusted default branch
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Verify adoption validation logic
run: node --test scripts/founder-adoption-validator.test.mjs
- name: Read founder acceptances and update adoption issue if appropriate
run: node scripts/founder-adoption.mjs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 changes: 33 additions & 0 deletions .github/workflows/test-founder-adoption.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Test founder adoption automation

on:
pull_request:
paths:
- '.github/founder-roster.json'
- '.github/workflows/founder-adoption.yml'
- '.github/workflows/test-founder-adoption.yml'
- 'scripts/founder-adoption*.mjs'
push:
branches: [main]
paths:
- '.github/founder-roster.json'
- '.github/workflows/founder-adoption.yml'
- '.github/workflows/test-founder-adoption.yml'
- 'scripts/founder-adoption*.mjs'

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Run founder acceptance unit tests
run: node --test scripts/founder-adoption-validator.test.mjs
44 changes: 44 additions & 0 deletions FOUNDER_ADOPTION_AUTOMATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Automatic closing of the three-cofounder adoption issue

## What it does

The GitHub Actions workflow `.github/workflows/founder-adoption.yml` watches comments on [Issue #3](https://github.com/OpenDKG/.github/issues/3), as well as edits/deletions, issue reopening, and changes to the roster.

It **only** marks the founding agreement adopted when **three specific, pre-registered, distinct personal GitHub accounts** have each publicly posted the **exact required declaration**, personally accepting the **same pinned** Contributor Agreement and Governance revision:

`d56279b4f3c25dd8f9e68699d61be23a0c7a6b07`.

The automation checks the stable GitHub **numeric account IDs** behind the roster handles, and the **currently visible** comment text. It will ignore an extra or impersonating comment, a GitHub bot, another policy revision, or a different Issue number. It does not claim to verify civil identities or the legal validity of a signature.

Once all three pass, it:
1. Writes a public completion receipt listing each acceptance comment permalink, GitHub user ID, SHA-256 comment-body fingerprint, policy revision, and UTC verification timestamp.
2. Adds label `founders:agreement-complete`.
3. **Closes Issue #3** as completed.

If a signature is **later edited or deleted**, the same workflow reassesses the three accounts. If a formerly completed issue no longer has all three currently valid declarations, it **reopens Issue #3**, removes the completed label, and writes a warning. A completion receipt is a *historical observation*, not an immutable or legally qualified e-signature; administrators can still change GitHub issue state manually. GitHub's API and Git history do not make comments undeletable.

## Initial configuration — required before it can auto-close

The committed configuration [`.github/founder-roster.json`](.github/founder-roster.json) starts with three reserved seats:

```json
{
"issue_number": 3,
"policy_commit": "d56279b4f3c25dd8f9e68699d61be23a0c7a6b07",
"founder_logins": ["a-51-50", null, null]
}
```

The **two null entries intentionally prevent automatic closing**. Once the two other *original equal cofounders* give you their actual GitHub usernames, open a reviewed PR that replaces **only those two null values** with their handles, e.g., `"cofounder-two"` and `"cofounder-three"`. Do **not** invent account names, use GitHub profile display names, or fill the seats with a random contributor. Keep the three original persons' identities distinct.

After roster change merges into `main`, a `push` event automatically checks already-posted comments; a new or edited comment on Issue #3 also triggers it. The three founders **do not need to be members of OpenDKG to post** on the public Issue, and no additional acceptance is required from `@a-51-50` if the **existing exact-revision declaration remains valid**.

Do not edit the policy commit in the roster just to bypass missing consent. If substantive policy terms change, arrange a separate new version with each person's explicit approval; never auto-count an older acceptance as agreement to new terms.

## Access and permissions

The workflow **never sends GitHub organization invitations, grants Repository Admin/Owner roles, or assigns legal liability**. Those changes still require explicit authorized organizational action. The three original cofounders retain equal intended governance standing regardless of who created the GitHub organization, and ordinary future contributors do not acquire founder rights.

GitHub Actions uses the repository-scoped default `GITHUB_TOKEN` with `contents: read` and `issues: write`; no personal token, dedicated external server, subscription, or third-party service is required for this task. The workflow runs only on trusted `main` code; comment text is data and never executed as a shell command.

The main requirement is to keep the [Issue #3](https://github.com/OpenDKG/.github/issues/3) available and preserve the public acceptance records. For stronger proof, export the receipt and public evidence separately after completion, and obtain appropriate legal advice before relying on it as a binding cross-jurisdiction founder agreement.
6 changes: 6 additions & 0 deletions FOUNDER_ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ These are **three equal founding seats**. Who created the GitHub account first d
6. After unanimous adoption, assign GitHub privileges by an explicit, documented founders' decision. If all three are meant to have equal technical control, **Organization Owner** for all three is more equivalent than giving two people only **Repository Admin**. But GitHub Owners have powerful unilateral platform capabilities, so all three should understand this risk and use MFA, separate recovery methods, least privilege and review controls. This process **does not automatically grant any account Owner or Admin**.
7. Ordinary later contributors apply via [the membership form](https://github.com/OpenDKG/.github/issues/new?template=membership-agreement.yml) or submit PRs as external contributors. They have **no cofounder status**, no governance vote and no automatic admin permissions.

## Automatic verification once the three usernames are known

The [founder adoption automation](FOUNDER_ADOPTION_AUTOMATION.md) will verify and close Issue #3 when all three **specific** original GitHub accounts have personally accepted the **exact same policy revision**. It records a verification receipt. It cannot run to completion until the two original cofounders' actual GitHub handles replace the placeholders in [the founder roster](.github/founder-roster.json) through a reviewed PR. The first account's existing valid acceptance is preserved; do not require `@a-51-50` to sign again without a material policy change.

Issue comments can still be edited or deleted. On such a change after automatic closure, the bot will reopen Issue #3 if any previously verified acceptance is no longer currently present. This is an integrity check, not an immutable legal e-signature. No GitHub privileges are granted automatically.

## Accountability and limits

No statement in GitHub governance makes the three persons automatically jointly liable for each other's activities, automatically shields `@a-51-50` from liability, or turns a GitHub organization into a limited-liability company. This must be addressed separately under the relevant jurisdictions and in actual contracts, if any. Do not represent an unregistered collaboration as an LLC.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ OpenDKG was started as a joint effort of **three equal cofounders**. The fact th

- [Founding acceptance and onboarding](FOUNDER_ONBOARDING.md)
- [Founding adoption record (Issue #3)](https://github.com/OpenDKG/.github/issues/3)
- [Automated final verification and Issue closing](FOUNDER_ADOPTION_AUTOMATION.md) — checks three pre-registered accounts; inactive while two founder handles remain unconfigured.

Future non-founder members use the separate membership form. No one is automatically promoted to admin/Owner based on a form or comment. Equal cofounder status does not automatically transfer assets or divide statutory liability.

Expand Down
92 changes: 92 additions & 0 deletions scripts/founder-adoption-validator.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// OpenDKG three-founder adoption: pure, dependency-free checks.
import { createHash } from "node:crypto";

export const POLICY_COMMIT = "d56279b4f3c25dd8f9e68699d61be23a0c7a6b07";
export const FOUNDER_ISSUE = 3;
export const COMPLETE_LABEL = "founders:agreement-complete";

// Match the exact declaration displayed in Issue #3 at the pinned policy revision.
// Allow only Markdown quote/code styling and whitespace normalization.
export const DECLARATION =
"I confirm that I am one of the three original equal cofounders of OpenDKG. " +
"I have read and affirmatively accept OpenDKG Contributor Agreement v1.0 and Governance v1.0 at commit " +
POLICY_COMMIT + ". " +
"I accept the scope covering present and future OpenDKG repositories. " +
"I understand that the sequence of GitHub invitations does not give one cofounder superior status; " +
"my agreement does not transfer third-party assets or automatically allocate or eliminate personal legal liability. " +
"I consent to this public GitHub acceptance record under my own account. " +
"I understand prior code is not retroactively licensed without required permission.";

export function normalizeDeclaration(body) {
if (typeof body !== "string") return "";
return body.replace(/^\s*>\s?/gm, "").replace(/\x60/g, "").replace(/\s+/g, " ").trim();
}

const loginPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/;

export function validateRoster(config) {
const errors = [];
if (config?.issue_number !== FOUNDER_ISSUE) errors.push("Issue number must be exactly 3.");
if (config?.policy_commit !== POLICY_COMMIT) errors.push("Policy commit mismatch.");
const logins = config?.founder_logins;
if (!Array.isArray(logins) || logins.length !== 3) {
errors.push("Exactly three founder seats must exist.");
return { ready: false, errors, logins: [] };
}
if (logins[0] !== "a-51-50") errors.push("Original custodian account must occupy the first founding seat.");
const active = logins.filter((login) => typeof login === "string" && login.length > 0);
for (const login of active) {
if (!loginPattern.test(login)) errors.push("Invalid GitHub login in roster.");
}
if (new Set(active.map((login) => login.toLowerCase())).size !== active.length) {
errors.push("Founding account logins must be distinct.");
}
// Null/empty handles are intentional placeholders. Do not close while any remain.
const ready = errors.length === 0 && active.length === 3;
return { ready, errors, logins: ready ? logins : [] };
}

export function evaluateFounderAcceptance(config, githubAccounts, issueComments) {
const roster = validateRoster(config);
if (!roster.ready) return { complete: false, configured: false, missing: [], records: [], errors: roster.errors };
const errors = [];
const records = [];
const missing = [];
const ids = new Set();
for (const login of roster.logins) {
const user = githubAccounts[login.toLowerCase()];
if (!user || user.type !== "User" || !Number.isSafeInteger(user.id) || user.id <= 0) {
errors.push("GitHub user not found or not a human user: " + login);
missing.push(login);
continue;
}
if (ids.has(user.id)) {
errors.push("Two roster entries resolved to the same GitHub identity.");
missing.push(login);
continue;
}
ids.add(user.id);
const valid = issueComments.filter((comment) =>
Number.isSafeInteger(comment?.id) &&
comment.user?.id === user.id &&
comment.user?.type === "User" &&
normalizeDeclaration(comment.body) === DECLARATION
);
if (!valid.length) {
missing.push(login);
continue;
}
const chosen = valid.sort((a, b) => a.id - b.id)[0];
records.push({
login: user.login,
user_id: user.id,
comment_id: chosen.id,
comment_url: chosen.html_url || "https://github.com/OpenDKG/.github/issues/3#issuecomment-" + chosen.id,
created_at: chosen.created_at || null,
updated_at: chosen.updated_at || null,
declaration_sha256: createHash("sha256").update(chosen.body, "utf8").digest("hex")
});
}
return { complete: missing.length === 0 && errors.length === 0 && records.length === 3,
configured: true, missing, records, errors };
}
110 changes: 110 additions & 0 deletions scripts/founder-adoption-validator.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
POLICY_COMMIT, DECLARATION, normalizeDeclaration,
validateRoster, evaluateFounderAcceptance
} from "./founder-adoption-validator.mjs";

const configured = {
issue_number: 3,
policy_commit: POLICY_COMMIT,
founder_logins: ["a-51-50", "founder-two", "founder-three"]
};
const people = {
"a-51-50": { login: "a-51-50", id: 51213010, type: "User" },
"founder-two": { login: "founder-two", id: 203, type: "User" },
"founder-three": { login: "founder-three", id: 304, type: "User" }
};
function signingComment(login, id, body = DECLARATION) {
const user = people[login];
return { id, user, body, created_at: "2026-11-05T11:00:00Z",
html_url: "https://github.com/OpenDKG/.github/issues/3#issuecomment-" + id };
}
const comments = [
signingComment("a-51-50", 51),
signingComment("founder-two", 52),
signingComment("founder-three", 53)
];

test("unconfigured two founder seats are fail-closed", () => {
const r = evaluateFounderAcceptance({
...configured, founder_logins: ["a-51-50", null, null]
}, people, comments);
assert.equal(r.configured, false);
assert.equal(r.complete, false);
});

test("valid configured roster needs exactly three distinct account handles", () => {
assert.equal(validateRoster(configured).ready, true);
assert.equal(validateRoster({ ...configured, founder_logins: ["a-51-50", "founder-two", "FOUNDER-TWO"] }).ready, false);
assert.equal(validateRoster({ ...configured, founder_logins: ["someone-else", "founder-two", "founder-three"] }).ready, false);
assert.equal(validateRoster({ ...configured, founder_logins: ["a-51-50", "x"] }).ready, false);
});

test("rejects wrong policy revision or issue number", () => {
assert.equal(validateRoster({ ...configured, policy_commit: "old" }).ready, false);
assert.equal(validateRoster({ ...configured, issue_number: 4 }).ready, false);
});

test("three different human GitHub accounts signing the exact statement can complete", () => {
const result = evaluateFounderAcceptance(configured, people, comments);
assert.equal(result.complete, true);
assert.deepEqual(result.missing, []);
assert.deepEqual(result.records.map((x) => x.user_id), [51213010, 203, 304]);
assert.ok(result.records.every((x) => x.declaration_sha256.length === 64));
});

test("current original custodian acceptance is accepted without forcing another signature", () => {
const result = evaluateFounderAcceptance(configured, people, [comments[0]]);
assert.equal(result.complete, false);
assert.deepEqual(result.records.map((x) => x.login), ["a-51-50"]);
assert.deepEqual(result.missing, ["founder-two", "founder-three"]);
});

test("not any three commenters: account mismatch never counts", () => {
const bad = { ...comments[2], user: { login: "unlisted", id: 999, type: "User" } };
const result = evaluateFounderAcceptance(configured, people, [comments[0], comments[1], bad]);
assert.equal(result.complete, false);
assert.deepEqual(result.missing, ["founder-three"]);
});

test("rejects fabricated GitHub identity with same login but different user ID", () => {
const bad = { ...comments[2], user: { login: "founder-three", id: 900, type: "User" } };
assert.equal(evaluateFounderAcceptance(configured, people, [comments[0], comments[1], bad]).complete, false);
});

test("rejects one account filling two founder seats, even if names differ", () => {
const badAccounts = { ...people, "founder-three": { ...people["founder-three"], id: 203 } };
const result = evaluateFounderAcceptance(configured, badAccounts, comments);
assert.equal(result.complete, false);
assert.ok(result.errors.some((x) => x.includes("same GitHub identity")));
});

test("edited or deleted declaration no longer counts", () => {
const edited = { ...comments[2], body: DECLARATION.replace("affirmatively accept", "might accept") };
const changed = evaluateFounderAcceptance(configured, people, [comments[0], comments[1], edited]);
assert.equal(changed.complete, false);
assert.equal(evaluateFounderAcceptance(configured, people, comments.slice(0, 2)).complete, false);
});

test("comment with older policy revision is not current acceptance", () => {
const stale = { ...comments[1], body: DECLARATION.replace(POLICY_COMMIT, "2e429d904db1059a678085792ad44f1d732d7ea1") };
assert.equal(evaluateFounderAcceptance(configured, people, [comments[0], stale, comments[2]]).complete, false);
});

test("the declaration may be quoted/formatting-wrapped but must be otherwise exact", () => {
const normalized = normalizeDeclaration("> " + DECLARATION.replace(POLICY_COMMIT, "\x60" + POLICY_COMMIT + "\x60"));
assert.equal(normalized, DECLARATION);
assert.equal(normalizeDeclaration(DECLARATION + " extra"), DECLARATION + " extra");
});

test("bot accounts are not founding signers", () => {
const badAccounts = { ...people, "founder-three": { ...people["founder-three"], type: "Bot" } };
assert.equal(evaluateFounderAcceptance(configured, badAccounts, comments).complete, false);
});

test("wrong actor's comment never counts for another founding seat", () => {
const impostor = { ...comments[1], user: people["founder-three"] };
const result = evaluateFounderAcceptance(configured, people, [comments[0], impostor]);
assert.equal(result.complete, false);
});
Loading
Loading