Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea4961980 | ||
|
|
2b8a668d06 | ||
|
|
719fb1e025 | ||
|
|
ac5630bee2 | ||
|
|
1c995fa9fc | ||
|
|
d70cb18ea0 | ||
|
|
1d71787896 | ||
|
|
eb62289f89 | ||
|
|
f5969a2f9f | ||
|
|
7f3d259cf5 | ||
|
|
721149eda1 | ||
|
|
8e27bc1e36 | ||
|
|
f1212620e4 | ||
|
|
b1b18ae390 | ||
|
|
3f0a174392 | ||
|
|
c0edebefb9 |
+24
-2
@@ -9,6 +9,8 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
export interface GiteaRepo {
|
||||
full_name: string;
|
||||
/** The URL a build clones — what a module records as its source. */
|
||||
clone_url?: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
private: boolean;
|
||||
@@ -34,6 +36,9 @@ export interface GiteaPull {
|
||||
title: string;
|
||||
state: string;
|
||||
merged: boolean;
|
||||
/** The commit the merge produced — what a build of the base branch is made from. */
|
||||
merge_commit_sha?: string;
|
||||
merged_at?: string;
|
||||
user?: string;
|
||||
head?: string;
|
||||
base?: string;
|
||||
@@ -116,9 +121,23 @@ export class GiteaClient {
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
|
||||
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
|
||||
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
|
||||
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
||||
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
|
||||
return (repos ?? []).map(GiteaClient.mapRepo);
|
||||
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
|
||||
return (found?.data ?? []).map(GiteaClient.mapRepo);
|
||||
}
|
||||
|
||||
/** Every repository, all pages. */
|
||||
async listAllRepos(): Promise<GiteaRepo[]> {
|
||||
const all: GiteaRepo[] = [];
|
||||
for (let page = 1; page < 100; page++) {
|
||||
const batch = await this.listRepos(page, 50);
|
||||
all.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async createRepo(data: {
|
||||
@@ -232,6 +251,7 @@ export class GiteaClient {
|
||||
private static mapRepo(r: any): GiteaRepo {
|
||||
return {
|
||||
full_name: r.full_name,
|
||||
clone_url: r.clone_url ?? undefined,
|
||||
name: r.name,
|
||||
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
||||
private: Boolean(r.private),
|
||||
@@ -259,6 +279,8 @@ export class GiteaClient {
|
||||
title: p.title,
|
||||
state: p.state,
|
||||
merged: Boolean(p.merged),
|
||||
merge_commit_sha: p.merge_commit_sha ?? undefined,
|
||||
merged_at: p.merged_at ?? undefined,
|
||||
user: p.user?.login,
|
||||
head: p.head?.ref,
|
||||
base: p.base?.ref,
|
||||
|
||||
+74
-2
@@ -31,7 +31,7 @@ try {
|
||||
const seen = new Set<string>();
|
||||
let primed = false;
|
||||
async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listRepos(1, 50);
|
||||
const repos = await client.listAllRepos();
|
||||
for (const repo of repos) {
|
||||
if (!seen.has(repo.full_name)) {
|
||||
if (primed) {
|
||||
@@ -49,6 +49,77 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
primed = true;
|
||||
}
|
||||
|
||||
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
|
||||
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
|
||||
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
|
||||
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
|
||||
// What has been announced is kept beside the module's state, so a restart does not announce the
|
||||
// whole history again — and the first tick on a machine with no record announces nothing, because
|
||||
// everything it sees then predates the watching.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
|
||||
const announced = new Set<string>();
|
||||
let primedMerges = false;
|
||||
// since is the moment the watching began: a merge made before it is history, whatever page of the
|
||||
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
|
||||
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
|
||||
// rebuilt everything built from that repository, once per old merge (2026-09-28).
|
||||
let since = "";
|
||||
if (mergedRecord && existsSync(mergedRecord)) {
|
||||
try {
|
||||
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
|
||||
const list = Array.isArray(kept) ? kept : kept.announced;
|
||||
for (const sha of list) announced.add(sha);
|
||||
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
|
||||
primedMerges = true;
|
||||
} catch {
|
||||
// An unreadable record is treated as no record: prime again rather than re-announce history.
|
||||
}
|
||||
}
|
||||
function keepAnnounced(): void {
|
||||
if (!mergedRecord) return;
|
||||
mkdirSync(join(mergedRecord, ".."), { recursive: true });
|
||||
const tmp = mergedRecord + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
||||
renameSync(tmp, mergedRecord);
|
||||
}
|
||||
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listAllRepos();
|
||||
let changed = false;
|
||||
for (const repo of repos) {
|
||||
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||
for (const pull of pulls) {
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||
// Announced only if merged since the watching began; recorded either way, so it is looked
|
||||
// at once.
|
||||
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
|
||||
if (primedMerges && fresh) {
|
||||
await emit("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: pull.merge_commit_sha,
|
||||
merged_at: pull.merged_at,
|
||||
clone_url: repo.clone_url,
|
||||
html_url: pull.html_url,
|
||||
});
|
||||
// Said, because a trigger that fires silently is indistinguishable from one that did not
|
||||
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
|
||||
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
|
||||
}
|
||||
announced.add(pull.merge_commit_sha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!primedMerges) since = new Date().toISOString();
|
||||
if (!primedMerges || changed) keepAnnounced();
|
||||
primedMerges = true;
|
||||
}
|
||||
|
||||
if (gitea) {
|
||||
const client = gitea;
|
||||
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
||||
@@ -70,5 +141,6 @@ if (gitea) {
|
||||
run();
|
||||
};
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
console.log("[gitea] watching for new repositories");
|
||||
tick(() => pollMerged(client), 30_000);
|
||||
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||
}
|
||||
|
||||
@@ -231,6 +231,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||
// Read it again: the merge commit only exists now, and it is what a build is made from.
|
||||
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||
await emit("pull.merged", {
|
||||
owner,
|
||||
repo,
|
||||
@@ -238,6 +240,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: merged.merge_commit_sha,
|
||||
merged_at: merged.merged_at,
|
||||
method,
|
||||
html_url: pull.html_url,
|
||||
});
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
#
|
||||
# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image:
|
||||
# the module's code is the server, which upstream already built. There is no BUILD_BASE here on
|
||||
# purpose — nothing is compiled.
|
||||
FROM nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927
|
||||
# purpose — nothing is compiled. The upstream image is declared in the manifest under build.on and
|
||||
# arrives as NATS_BASE, like every other base the mesh copies into its own store before a build
|
||||
# (novox/hq ADR 0097); the digest above is the index one for the reason given.
|
||||
ARG NATS_BASE
|
||||
FROM ${NATS_BASE}
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint
|
||||
RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"id": "server-conf",
|
||||
"type": "file",
|
||||
"path": "/var/lib/nats-module/conf/nats.conf",
|
||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\n# The mesh's own broker certificate \u2014 the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
@@ -61,18 +61,24 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh-broker-nats:/data",
|
||||
"/var/lib/nats-module/conf:/etc/nats:ro",
|
||||
"/var/lib/mesh-broker-nats-tls:/tls:ro"
|
||||
"/var/lib/mesh-broker-tls:/tls:ro"
|
||||
],
|
||||
"artifact": "server"
|
||||
}
|
||||
],
|
||||
"accesses": [
|
||||
{
|
||||
"path": "/var/lib/mesh-broker-nats-tls",
|
||||
"path": "/var/lib/mesh-broker-tls",
|
||||
"mode": "read"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "NATS_BASE",
|
||||
"image": "nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "server",
|
||||
|
||||
Reference in New Issue
Block a user