Skip to content
Open
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
138 changes: 138 additions & 0 deletions .github/scripts/release-source-checksums.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";

export const CHECKSUMS_START = "<!-- source-checksums:start -->";
export const CHECKSUMS_END = "<!-- source-checksums:end -->";

export function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}

export async function sha256Stream(stream) {
const hash = createHash("sha256");
for await (const chunk of stream) hash.update(chunk);
return hash.digest("hex");
}

export function sourceArchiveUrls(repository, tag) {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository))
throw new Error(`Invalid GitHub repository: ${repository}`);
if (!tag) throw new Error("Release tag is required.");

const encodedTag = tag
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const baseUrl = `https://github.com/${repository}/archive/refs/tags/${encodedTag}`;
return [
{ label: "Source code (zip)", url: `${baseUrl}.zip` },
{ label: "Source code (tar.gz)", url: `${baseUrl}.tar.gz` },
];
}

export function renderChecksumSection(archives) {
if (!Array.isArray(archives) || archives.length === 0)
throw new Error("At least one source archive is required.");

const rows = archives.map(({ label, url, digest }) => {
if (!label || !url || !/^[a-f0-9]{64}$/.test(digest))
throw new Error("Archive label, URL, and SHA-256 digest are required.");
return `| [${label}](${url}) | \`${digest}\` |`;
});

return [
CHECKSUMS_START,
"## Source checksums",
"",
"| Archive | SHA-256 |",
"| --- | --- |",
...rows,
CHECKSUMS_END,
].join("\n");
}

export function upsertChecksumSection(releaseBody, checksumSection) {
const body = releaseBody ?? "";
const startIndex = body.indexOf(CHECKSUMS_START);
const endIndex = body.indexOf(CHECKSUMS_END);

if ((startIndex === -1) !== (endIndex === -1) || endIndex < startIndex)
throw new Error("Release notes contain an incomplete checksum section.");

if (startIndex === -1)
return [body.trimEnd(), checksumSection].filter(Boolean).join("\n\n");

const before = body.slice(0, startIndex).trimEnd();
const after = body.slice(endIndex + CHECKSUMS_END.length).trimStart();
return [before, checksumSection, after].filter(Boolean).join("\n\n");
}

async function request(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const details = await response.text();
throw new Error(
`Request failed (${response.status} ${response.statusText}): ${details}`
);
}
return response;
}

export async function publishSourceChecksums({
repository,
releaseId,
tag,
token,
}) {
if (!/^\d+$/.test(String(releaseId)))
throw new Error("A numeric release ID is required.");
if (!token) throw new Error("GH_TOKEN is required.");

const archives = [];
for (const archive of sourceArchiveUrls(repository, tag)) {
const response = await request(archive.url, { redirect: "follow" });
if (!response.body)
throw new Error(`Source archive response was empty: ${archive.url}`);
archives.push({ ...archive, digest: await sha256Stream(response.body) });
}

const apiUrl = `https://api.github.com/repos/${repository}/releases/${releaseId}`;
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "anything-llm-release-checksums",
"X-GitHub-Api-Version": "2022-11-28",
};
const release = await request(apiUrl, { headers }).then((response) =>
response.json()
);
const body = upsertChecksumSection(
release.body,
renderChecksumSection(archives)
);

await request(apiUrl, {
method: "PATCH",
headers,
body: JSON.stringify({ body }),
});
}

async function main() {
await publishSourceChecksums({
repository: process.env.GITHUB_REPOSITORY,
releaseId: process.env.RELEASE_ID,
tag: process.env.RELEASE_TAG,
token: process.env.GH_TOKEN,
});
console.log(
`Published source archive checksums for ${process.env.RELEASE_TAG}.`
);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
135 changes: 135 additions & 0 deletions .github/scripts/release-source-checksums.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { Readable } from "node:stream";
import { describe, test } from "node:test";
import {
CHECKSUMS_END,
CHECKSUMS_START,
publishSourceChecksums,
renderChecksumSection,
sha256,
sha256Stream,
sourceArchiveUrls,
upsertChecksumSection,
} from "./release-source-checksums.mjs";

const archive = {
label: "Source code (zip)",
url: "https://github.com/Mintplex-Labs/anything-llm/archive/refs/tags/v1.2.3.zip",
digest: "a".repeat(64),
};

describe("release source checksums", () => {
test("builds source archive URLs for tags with path segments", () => {
assert.deepEqual(
sourceArchiveUrls("Mintplex-Labs/anything-llm", "release/v1.2.3"),
[
{
label: "Source code (zip)",
url: "https://github.com/Mintplex-Labs/anything-llm/archive/refs/tags/release/v1.2.3.zip",
},
{
label: "Source code (tar.gz)",
url: "https://github.com/Mintplex-Labs/anything-llm/archive/refs/tags/release/v1.2.3.tar.gz",
},
]
);
});

test("calculates a SHA-256 digest", () => {
assert.equal(
sha256("AnythingLLM"),
"fc9085f77a432be18cda8b8266bc6a6893889f6270d68ef29fe863a03852ae35"
);
});

test("calculates a SHA-256 digest from a stream", async () => {
assert.equal(
await sha256Stream(Readable.from(["Anything", "LLM"])),
"fc9085f77a432be18cda8b8266bc6a6893889f6270d68ef29fe863a03852ae35"
);
});

test("renders a marked Markdown checksum table", () => {
const section = renderChecksumSection([archive]);
assert.match(section, new RegExp(`^${CHECKSUMS_START}`));
assert.match(section, /\| Archive \| SHA-256 \|/);
assert.match(section, new RegExp(`${"a".repeat(64)}`));
assert.match(section, new RegExp(`${CHECKSUMS_END}$`));
});

test("appends checksums without replacing existing release notes", () => {
const section = renderChecksumSection([archive]);
assert.equal(
upsertChecksumSection("# Release notes\n\nExisting details.\n", section),
`# Release notes\n\nExisting details.\n\n${section}`
);
});

test("replaces an existing checksum section and preserves trailing notes", () => {
const section = renderChecksumSection([archive]);
const existing = [
"# Release notes",
"",
CHECKSUMS_START,
"old checksums",
CHECKSUMS_END,
"",
"Postscript.",
].join("\n");

assert.equal(
upsertChecksumSection(existing, section),
`# Release notes\n\n${section}\n\nPostscript.`
);
});

test("rejects an incomplete checksum section", () => {
assert.throws(
() => upsertChecksumSection(`Notes\n${CHECKSUMS_START}`, "replacement"),
/incomplete checksum section/
);
});

test("downloads both archives and updates the existing release body", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
let patchedBody;
globalThis.fetch = async (url, options = {}) => {
requests.push({ url, options });
if (url.endsWith(".zip")) return new Response("zip archive");
if (url.endsWith(".tar.gz")) return new Response("tar archive");
if (options.method === "PATCH") {
patchedBody = JSON.parse(options.body).body;
return new Response("{}", { status: 200 });
}
return new Response(
JSON.stringify({ body: "# Existing release notes" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
};

try {
await publishSourceChecksums({
repository: "Mintplex-Labs/anything-llm",
releaseId: "123",
tag: "v1.2.3",
token: "test-token",
});
} finally {
globalThis.fetch = originalFetch;
}

assert.equal(requests.length, 4);
assert.equal(
requests[2].options.headers.Authorization,
"Bearer test-token"
);
assert.equal(requests[3].options.method, "PATCH");
assert.match(patchedBody, /^# Existing release notes/);
assert.match(patchedBody, new RegExp(sha256("zip archive")));
assert.match(patchedBody, new RegExp(sha256("tar archive")));
});
});
52 changes: 52 additions & 0 deletions .github/workflows/publish-source-checksums.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Publish source archive checksums

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- ".github/scripts/release-source-checksums.mjs"
- ".github/scripts/release-source-checksums.test.mjs"
- ".github/workflows/publish-source-checksums.yaml"
release:
types: [published]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Test release checksum script
run: node --test .github/scripts/release-source-checksums.test.mjs

publish:
if: github.event_name == 'release'
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Publish source archive checksums
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ github.event.release.id }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: node .github/scripts/release-source-checksums.mjs