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
97 changes: 97 additions & 0 deletions .github/workflows/publish-supporters.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: publish-supporters

on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
inputs:
base_branch:
default: main
description: Branch that receives the generated register.
required: false
type: string

permissions:
contents: write
pull-requests: write

concurrency:
group: publish-supporters
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
PUBLISH_BASE: ${{ inputs.base_branch || 'main' }}
PUBLISH_BRANCH: publish/supporters
steps:
- name: Checkout public repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
path: pdpp

- name: Checkout private register branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: supporters-private
persist-credentials: false
ref: signatures
repository: PDP-Connect/supporters-private
token: ${{ secrets.PDPP_PRIVATE_REPO_TOKEN }}

- name: Start the publisher branch from its base
working-directory: pdpp
run: |
set -euo pipefail
if [ "$PUBLISH_BRANCH" = "$DEFAULT_BRANCH" ]; then
echo "::error::Publisher branch $PUBLISH_BRANCH must not be the repository default branch $DEFAULT_BRANCH."
exit 1
fi
git fetch --no-tags origin "$PUBLISH_BASE"
git checkout --force -B "$PUBLISH_BRANCH" "origin/$PUBLISH_BASE"

- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"

- name: Build public supporters register
run: node pdpp/apps/site/scripts/publish-supporters.mjs supporters-private pdpp/apps/site/public/principles/supporters.json

- name: Commit and publish changed register
id: publish
working-directory: pdpp
run: |
set -euo pipefail
if git diff --quiet -- apps/site/public/principles/supporters.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Supporters register is unchanged."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add apps/site/public/principles/supporters.json
git commit -s -m "chore(site): publish supporters register"
git push --force origin "HEAD:refs/heads/$PUBLISH_BRANCH"
echo "changed=true" >> "$GITHUB_OUTPUT"

- name: Create or update the supporters pull request
id: pull-request
if: steps.publish.outputs.changed == 'true'
working-directory: pdpp
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
number="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$PUBLISH_BRANCH" --base "$PUBLISH_BASE" --state open --json number --jq '.[0].number')"
if [ -z "$number" ]; then
gh pr create --repo "$GITHUB_REPOSITORY" --base "$PUBLISH_BASE" --head "$PUBLISH_BRANCH" --title "chore(site): publish supporters register" --body "Automated public-register publication."
number="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$PUBLISH_BRANCH" --base "$PUBLISH_BASE" --state open --json number --jq '.[0].number')"
else
gh pr edit "$number" --repo "$GITHUB_REPOSITORY" --title "chore(site): publish supporters register"
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
80 changes: 80 additions & 0 deletions apps/site/scripts/publish-supporters.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env node
// Copyright The PDP-Connect Contributors
// SPDX-License-Identifier: Apache-2.0

// Reads confirmed records from the checked-out private register and writes the
// public register. The explicit pick in toPublicEntry is the security boundary:
// a future private field cannot reach the public file unless it is added here.

import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";

function toPublicEntry(record) {
return {
country: record.country,
principlesVersion: record.principlesVersion,
publicName: record.publicName,
signedOn: String(record.confirmedAt).slice(0, 10),
type: record.type,
};
}

export async function readSignatories(sourceDirectory) {
const root = path.join(sourceDirectory, "signatories");
let years;
try {
years = await readdir(root, { withFileTypes: true });
} catch (error) {
throw new Error(`Private register has no signatories directory: ${root}`, { cause: error });
}

const directories = years.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
const filePaths = (
await Promise.all(
directories.map(async (year) => {
const directory = path.join(root, year.name);
const files = await readdir(directory);
return files
.filter((entry) => entry.endsWith(".json"))
.sort()
.map((file) => path.join(directory, file));
})
)
).flat();
return await Promise.all(filePaths.map(async (filePath) => JSON.parse(await readFile(filePath, "utf8"))));
}

export async function publishSupporters(sourceDirectory, outputPath) {
const published = [];
for (const record of await readSignatories(sourceDirectory)) {
if (record?.consent?.register === true) {
published.push(toPublicEntry(record));
}
}
published.sort(
(left, right) => left.signedOn.localeCompare(right.signedOn) || left.publicName.localeCompare(right.publicName)
);
const output = `${JSON.stringify(published, null, 2)}\n`;

await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, output);
return published.length;
}

async function main() {
const [sourceDirectory, outputPath] = process.argv.slice(2);
if (sourceDirectory && outputPath) {
const count = await publishSupporters(sourceDirectory, outputPath);
console.log(`Wrote ${count} public signatories.`);
} else {
console.error("Usage: publish-supporters.mjs <private-register-directory> <public-output-path>");
process.exitCode = 1;
}
}

if (import.meta.main) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
92 changes: 92 additions & 0 deletions apps/site/scripts/publish-supporters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright The PDP-Connect Contributors
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, test } from "node:test";
import { publishSupporters } from "./publish-supporters.mjs";

const temporaryDirectories: string[] = [];
const MISSING_SIGNATORIES_DIRECTORY = /Private register has no signatories directory/;

afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map(async (directory) => await rm(directory, { force: true, recursive: true }))
);
});

test("publish supporters writes only consented public fields", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "pdpp-supporters-"));
temporaryDirectories.push(directory);
const sourceDirectory = path.join(directory, "private");
const signatoriesDirectory = path.join(sourceDirectory, "signatories", "2026");
const outputPath = path.join(directory, "public", "supporters.json");
await mkdir(signatoriesDirectory, { recursive: true });

await writeFile(
path.join(signatoriesDirectory, "listed.json"),
JSON.stringify({
confirmedAt: "2026-09-03T14:15:16.000Z",
consent: { register: true, updates: true },
country: "United States",
displayName: "Private Display Name",
email: "private@example.test",
ip: "203.0.113.42",
organisation: "Private Organisation",
privateMetadata: { identityProviderToken: "nested-private-token" },
principlesVersion: "v1.0",
publicName: "Public P.",
signatoryName: "Private Signatory",
signatoryRole: "Private Role",
type: "Individual",
})
);
await writeFile(
path.join(signatoriesDirectory, "unlisted.json"),
JSON.stringify({
confirmedAt: "2026-09-04T14:15:16.000Z",
consent: { register: false },
country: "United States",
email: "unlisted@example.test",
principlesVersion: "v1.0",
publicName: "Not Listed",
type: "Individual",
})
);

assert.equal(await publishSupporters(sourceDirectory, outputPath), 1);
const output = await readFile(outputPath, "utf8");
assert.deepEqual(JSON.parse(output), [
{
country: "United States",
principlesVersion: "v1.0",
publicName: "Public P.",
signedOn: "2026-09-03",
type: "Individual",
},
]);
for (const privateValue of [
"Private Display Name",
"private@example.test",
"203.0.113.42",
"Private Organisation",
"nested-private-token",
"Private Signatory",
"Private Role",
"unlisted@example.test",
]) {
assert.equal(output.includes(privateValue), false, `${privateValue} must not reach the public register`);
}
});

test("publish supporters refuses a missing private register instead of clearing the public file", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "pdpp-supporters-"));
temporaryDirectories.push(directory);

await assert.rejects(
async () => await publishSupporters(path.join(directory, "missing"), path.join(directory, "supporters.json")),
MISSING_SIGNATORIES_DIRECTORY
);
});
4 changes: 3 additions & 1 deletion apps/site/src/lib/signing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ function requireEnv(name: string): string {
}

/** Thrown when the system is not provisioned. Never leaks to the client body. */
export class SigningUnavailableError extends Error {}
export class SigningUnavailableError extends Error {
override name = "SigningUnavailableError";
}

/** Thrown when the submission itself is bad. Its message IS shown. */
export class SigningRejectedError extends Error {}
Expand Down
106 changes: 106 additions & 0 deletions apps/site/src/lib/signing/providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright The PDP-Connect Contributors
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";

const execFile = promisify(execFileCallback);
const PROVIDERS_URL = pathToFileURL(fileURLToPath(new URL("./providers.ts", import.meta.url))).href;
const SITE_DIRECTORY = fileURLToPath(new URL("../../..", import.meta.url));
const DEFAULT_BRANCH_WRITE_ERROR = /private repo branch signatures responded 404/;
const MISSING_BRANCH_ERROR = /private repo branch missing-branch is unavailable \(404\)/;

interface ProviderCall {
body?: Record<string, unknown>;
method: string;
url: string;
}

interface ProviderResult {
calls: ProviderCall[];
error: { message: string; name: string } | null;
}

async function runProvider(
operation: "write" | "withdraw",
statuses: readonly number[],
branch?: string
): Promise<ProviderResult> {
const scenario = JSON.stringify({ branch, operation, statuses });
const program = `
const scenario = ${scenario};
Object.assign(process.env, {
PDPP_PRIVATE_REPO_NAME: "supporters-private",
PDPP_PRIVATE_REPO_OWNER: "PDP-Connect",
PDPP_PRIVATE_REPO_TOKEN: "test-token",
});
if (scenario.branch === undefined) delete process.env.PDPP_PRIVATE_REPO_BRANCH;
else process.env.PDPP_PRIVATE_REPO_BRANCH = scenario.branch;
const calls = [];
const statuses = [...scenario.statuses];
globalThis.fetch = async (input, init = {}) => {
calls.push({ body: init.body ? JSON.parse(init.body) : undefined, method: init.method ?? "GET", url: String(input) });
return new Response(null, { status: statuses.shift() ?? 200 });
};
const providers = await import(${JSON.stringify(PROVIDERS_URL)});
let error = null;
try {
if (scenario.operation === "write") {
await providers.writeSignatory({
confirmedAt: "2026-09-03T00:00:00.000Z",
consent: { ageOrAuthority: true, principles: true, register: true, updates: false },
country: "United States", displayName: "Private Display Name", email: "private@example.test", id: "signatory-id",
organisation: null, principlesVersion: "v1.0", publicName: "Public P.", signatoryName: null, signatoryRole: null, type: "Individual",
}, "signatories/2026/signatory-id.json");
} else await providers.withdrawSignatory("signatory-id");
} catch (caught) { error = { message: caught.message, name: caught.name }; }
process.stdout.write(JSON.stringify({ calls, error }));
`;
const { stdout } = await execFile(
process.execPath,
["--conditions=react-server", "--import", "tsx", "--input-type=module", "--eval", program],
{ cwd: SITE_DIRECTORY }
);
return JSON.parse(stdout) as ProviderResult;
}

test("signatory PUT sends the configured branch and bot DCO trailer", async () => {
const result = await runProvider("write", [201], "staged-signatures");
const [request] = result.calls;

assert.equal(result.error, null);
assert.equal(request?.method, "PUT");
assert.equal(
request?.url,
"https://api.github.com/repos/PDP-Connect/supporters-private/contents/signatories/2026/signatory-id.json"
);
assert.equal(request?.body?.branch, "staged-signatures");
assert.equal(
request?.body?.message,
"Add signatory signatory-id\n\nSigned-off-by: pdpp-supporters-bot <bot@pdpp.dev>"
);
});

test("default branch write failures are SigningUnavailableError responses", async () => {
const result = await runProvider("write", [404]);

assert.equal(result.calls[0]?.body?.branch, "signatures");
assert.equal(result.error?.name, "SigningUnavailableError");
assert.match(result.error?.message ?? "", DEFAULT_BRANCH_WRITE_ERROR);
});

test("withdrawal stops before probing files when its branch is unavailable", async () => {
const result = await runProvider("withdraw", [404], "missing-branch");

assert.deepEqual(result.calls, [
{
method: "GET",
url: "https://api.github.com/repos/PDP-Connect/supporters-private/git/ref/heads/missing-branch",
},
]);
assert.equal(result.error?.name, "SigningUnavailableError");
assert.match(result.error?.message ?? "", MISSING_BRANCH_ERROR);
});
Loading