pi-mono/scripts/preview-packages.test.mjs
Christian Klotz 7a52c2108d
feat: release version tags on pkg.earendil.com (#11686)
## Problem

`scripts/release.mjs` pushes `Release vX.Y.Z` and `Add [Unreleased]
section…` to main in one push, then pushes the tag. CI builds only the
pushed head, so the tagged release commit is never built and
pkg.earendil.com has no build of a release. Example: for v1.4.0, v1.3.0
and v1.2.0 the tagged commits have Build Binaries and Nix runs, but no
CI run.

Internal users want to install a release from pkg.earendil.com as soon
as it is tagged, without waiting for the npm release.

## Change

- `ci.yml` also runs for `v*.*.*` tags (the pattern `nix.yml` uses), so
the tagged commit is built and packed.
- `preview-packages.yml` also publishes passing CI runs of release tags:
`POST /_/releases` keeps the packages for good under the tag (it never
moves), then the `latest` ref moves to it. Pre-releases (`v1.2.3-rc.1`)
are released without moving `latest`.
- `scripts/preview-packages.mjs publish` decides what a push run
publishes (the workflow passes the run's branch and conclusion), reading
the GitHub API with `fetch` before anything is uploaded. A tag push's
`head_branch` is the tag's name, and a branch or tag can carry its own
copy of CI, so:
- every push run must be for a commit on main, comparing against main's
resolved SHA (a bare `main` in compare would resolve to a tag named
`main`);
- a release tag must match `^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`, have
passing CI (CI packs before its tests run), and point at the commit,
dereferencing annotated tags, also nested;
  - other names publish nothing.

This also closes an existing hole: a tag named `main` on an unreviewed
commit could move the `main` ref.
- A release happens after the uploads and before moving refs, so
`latest` never points at a failed release. The job summary lists the
release's install commands.
- A passing push run whose CI artifact expired (3 days) fails with
"re-run CI" instead of passing silently, so a release tag cannot go
unreleased unnoticed.

## Before / after for a release

| | Before | After |
|---|---|---|
| CI of the tagged commit | none | build, test, pack |
| `pkg.earendil.com/<package>@vX.Y.Z` | 404 | kept for good |
| `…@<release sha>` | 404 | kept for good |
| `…@latest` | none | moves to each release |
| npm, binaries, pi.dev, Nix `stable` | unchanged | unchanged |
| main and PR previews | unchanged | unchanged |

Tag CI runs trigger Publish Model Catalog and Nix, which skip them
(`head_branch != main`).

## Before merging

- [x] Deploy pkg.earendil.com with `POST /_/releases` (earendil-pkg
`7955990`); until then a tag run fails at the release call and `latest`
does not move.

## Recommended (not required by this PR)

Add a ruleset restricting creation, update and deletion of `v*` tags to
maintainers / the release process. The checks above already keep
unreviewed code out (every push run must be for a commit on main), but
anyone with write access can push a `v*` tag on any main commit. On
pkg.earendil.com that release is kept for good, takes the version number
(the real release then gets `409` there) and moves `latest`, with no
approval gate like npm's `npm-publish` environment. Build Binaries (npm)
and Nix (`stable`) also act on `v*` tags today.

## Checks

`npm run check`; `node --test scripts/preview-packages.test.mjs` (10
tests, under 2s, against a fake server for pkg.earendil.com and the
GitHub API with raw API responses). Each of these mutations fails a
test: bare `main` in compare, wrong API field names, no on-main check,
no tag target check, one-level tag dereference, `latest` for
pre-releases, unanchored or loose tag pattern, no CI conclusion check,
dropping `identical`. actionlint reports only the existing `queue:`
complaint.
2026-11-05 10:01:01 +00:00

258 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import { execFile, execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const run = promisify(execFile);
const script = join(dirname(fileURLToPath(import.meta.url)), "preview-packages.mjs");
const sha = "a".repeat(40);
const newerSha = "b".repeat(40);
const mainSha = "c".repeat(40);
const REPO_API = "/repos/earendil-works/pi/";
function temporaryDirectory(t) {
const directory = mkdtempSync(join(tmpdir(), "pi-preview-packages-test-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
return directory;
}
function writePackage(repo, directory, manifest) {
mkdirSync(join(repo, "packages", directory), { recursive: true });
writeFileSync(join(repo, "packages", directory, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`);
}
/** GitHub's answers for a commit on main: compare against main's SHA says how the commit relates to main. */
function onMain(status = "behind") {
return { "git/ref/heads/main": { object: { sha: mainSha } }, [`compare/${mainSha}...${sha}`]: { status } };
}
/** GitHub's answers for a release tag of the commit on main. */
function released(tag) {
return { ...onMain(), [`git/ref/tags/${tag}`]: { object: { type: "commit", sha } } };
}
/**
* Stands in for the pkg.earendil.com worker, GitHub's OIDC token endpoint and GitHub's REST API, recording requests.
* `refsSha` is what the worker reports each ref resolves to after the update; `uploadBody` overrides the upload
* response; `releaseStatus` the release's status. `github` maps paths below the repository to API responses; any other
* API path is a 404.
*/
async function startServer(t, { refsSha = sha, uploadBody, releaseStatus = 201, github = {} } = {}) {
const requests = [];
const server = createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization, body });
const url = new URL(req.url, "http://localhost");
if (url.pathname === "/token") return res.end(JSON.stringify({ value: `oidc-for-${url.searchParams.get("audience")}` }));
if (url.pathname.startsWith(REPO_API)) {
const response = github[url.pathname.slice(REPO_API.length)];
res.statusCode = response ? 200 : 404;
return res.end(JSON.stringify(response ?? { message: "Not Found" }));
}
if (req.method === "PUT") {
const name = decodeURIComponent(url.pathname.split("/").slice(4).join("/"));
return res.end(uploadBody ?? JSON.stringify({ name, sha, existing: false }));
}
if (url.pathname === "/_/releases") {
const { tag } = JSON.parse(body);
res.statusCode = releaseStatus;
const packages = [{ name: "@scope/a", url: `http://pkg/@scope/a@${tag}` }];
return res.end(JSON.stringify(releaseStatus === 201 ? { tag, sha, packages, existing: false } : { error: "no" }));
}
const { refs } = JSON.parse(body);
res.end(JSON.stringify({ refs: refs.map((ref) => ({ ref, sha: refsSha })) }));
});
await new Promise((ready) => server.listen(0, "127.0.0.1", ready));
t.after(() => server.close());
return { url: `http://127.0.0.1:${server.address().port}`, requests };
}
function writeArtifact(directory) {
mkdirSync(directory, { recursive: true });
writeFileSync(join(directory, "scope-a-1.0.0.tgz"), "tarball a");
const packages = [{ name: "@scope/a", file: "scope-a-1.0.0.tgz" }];
writeFileSync(join(directory, "manifest.json"), JSON.stringify({ sha, packages }));
}
function publish(server, dir, args = ["--event", "pull_request", "--ref", "pr-1"]) {
const { GITHUB_STEP_SUMMARY: _, ...env } = process.env;
const common = ["publish", "--sha", sha, "--run", "7", "--dir", dir];
return run(process.execPath, [script, ...common, ...args, "--base-url", server.url], {
env: {
...env,
ACTIONS_ID_TOKEN_REQUEST_URL: `${server.url}/token`,
ACTIONS_ID_TOKEN_REQUEST_TOKEN: "runner",
GITHUB_API_URL: server.url,
GITHUB_REPOSITORY: "earendil-works/pi",
GH_TOKEN: "job-token",
},
});
}
/** The arguments of a CI push run of `branch`. */
function pushRun(branch, conclusion = "success") {
return ["--event", "push", "--branch", branch, "--conclusion", conclusion];
}
/** The POST requests to pkg.earendil.com, as [path, body]. */
function posts(server) {
const requests = server.requests.filter((request) => request.method === "POST");
return requests.map((request) => [request.url, JSON.parse(request.body)]);
}
test("pack points internal dependencies at the commit's builds and restores package.json", async (t) => {
const repo = temporaryDirectory(t);
writePackage(repo, "a", { name: "@scope/a", version: "1.0.0", dependencies: { "@scope/b": "1.0.0", other: "2.0.0" } });
writePackage(repo, "b", { name: "@scope/b", version: "1.0.0" });
writePackage(repo, "internal", { name: "@scope/internal", version: "1.0.0", private: true });
writePackage(repo, "env", { name: "@earendil-works/pi-env", version: "1.0.0" });
const original = readFileSync(join(repo, "packages/a/package.json"), "utf8");
const out = join(repo, "out");
await run(process.execPath, [script, "pack", "--sha", sha, "--out", out], { cwd: repo });
const manifest = JSON.parse(readFileSync(join(out, "manifest.json"), "utf8"));
assert.equal(manifest.sha, sha);
assert.deepEqual(manifest.packages.map((pkg) => pkg.name).sort(), ["@scope/a", "@scope/b"]);
assert.deepEqual(readdirSync(out).sort(), ["manifest.json", ...manifest.packages.map((pkg) => pkg.file)].sort());
const fileA = manifest.packages.find((pkg) => pkg.name === "@scope/a").file;
const packedA = JSON.parse(execFileSync("tar", ["-xOzf", join(out, fileA), "package/package.json"], { encoding: "utf8" }));
assert.deepEqual(packedA.dependencies, { "@scope/b": `https://pkg.earendil.com/@scope/b@${sha}`, other: "2.0.0" });
assert.equal(readFileSync(join(repo, "packages/a/package.json"), "utf8"), original);
});
test("publish uploads each tarball, moves refs and prints install commands", async (t) => {
const server = await startServer(t);
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
const { stdout } = await publish(server, dir);
const [token, upload, refs] = server.requests;
assert.equal(token.url, "/token?audience=https%3A%2F%2Fpkg.earendil.com");
assert.deepEqual(
{ method: upload.method, url: upload.url, authorization: upload.authorization, body: upload.body },
{
method: "PUT",
url: `/_/upload/${sha}/%40scope%2Fa?event=pull_request`,
authorization: "Bearer oidc-for-https://pkg.earendil.com",
body: "tarball a",
},
);
assert.deepEqual(JSON.parse(refs.body), { sha, refs: ["pr-1"], run: 7 });
assert.match(stdout, new RegExp(`npm i ${server.url}/@scope/a@pr-1`));
assert.match(stdout, new RegExp(`npm i ${server.url}/@scope/a@${sha}`));
});
test("publish leaves the summary to a newer run that already owns the ref", async (t) => {
const server = await startServer(t, { refsSha: newerSha });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
const { stdout, stderr } = await publish(server, dir);
assert.equal(stdout, "");
assert.match(stderr, new RegExp(`a newer run already published pr-1 \\(${newerSha}\\)`));
});
test("publish fails on a successful response that is not JSON", async (t) => {
const server = await startServer(t, { uploadBody: "<html>proxy</html>" });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
await assert.rejects(publish(server, dir), /not valid JSON/);
assert.equal(server.requests.filter((request) => request.method === "POST").length, 0);
});
test("publish decides what a push run publishes", async (t) => {
const release = (tag) => ["/_/releases", { sha, tag }];
const latest = ["/_/refs", { sha, refs: ["latest"], run: 7 }];
const nested = {
...onMain(),
"git/ref/tags/v1.2.3": { object: { type: "tag", sha: "t1" } },
"git/tags/t1": { object: { type: "tag", sha: "t2" } },
"git/tags/t2": { object: { type: "commit", sha } },
};
const cases = [
["main", onMain("identical"), [["/_/refs", { sha, refs: ["main"], run: 7 }]]],
["v1.2.3", released("v1.2.3"), [release("v1.2.3"), latest]],
// An annotated tag can point at another annotated tag before the commit.
["v1.2.3", nested, [release("v1.2.3"), latest]],
// A pre-release is kept under its tag, but `latest` stays on the newest release.
["v1.2.3-beta.1", released("v1.2.3-beta.1"), [release("v1.2.3-beta.1")]],
];
for (const [branch, github, expected] of cases) {
const server = await startServer(t, { github });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
await publish(server, dir, pushRun(branch));
assert.deepEqual([branch, posts(server)], [branch, expected]);
}
});
test("publish uploads nothing for push runs of other names", async (t) => {
for (const branch of ["vfoo", "v1.2", "v1.2.3.4", "release-v1.2.3", "v1.2.3-"]) {
const server = await startServer(t, { github: released(branch) });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
const { stderr } = await publish(server, dir, pushRun(branch));
assert.match(stderr, /nothing to publish/);
assert.deepEqual([branch, server.requests], [branch, []]);
}
});
test("publish refuses push runs off main, of other commits or with failing CI", async (t) => {
const otherCommit = { ...onMain(), "git/ref/tags/v1.2.3": { object: { type: "commit", sha: newerSha } } };
const cases = [
// A tag named main on an unreviewed commit: a bare `main` in compare would resolve to the tag.
["tag named main", "main", "success", { ...onMain("diverged"), [`compare/main...${sha}`]: { status: "identical" } }],
["release tag off main", "v1.2.3", "success", { ...released("v1.2.3"), ...onMain("ahead") }],
["tag of another commit", "v1.2.3", "success", otherCommit],
["missing tag", "v1.2.3", "success", onMain()],
["failing CI", "v1.2.3", "failure", released("v1.2.3")],
];
for (const [label, branch, conclusion, github] of cases) {
const server = await startServer(t, { github });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
await assert.rejects(publish(server, dir, pushRun(branch, conclusion)), undefined, label);
const uploads = server.requests.filter((request) => request.method === "PUT");
assert.deepEqual([label, uploads], [label, []]);
}
});
const RELEASE_RUN = pushRun("v1.2.3");
test("publish prints the release's install commands", async (t) => {
const server = await startServer(t, { github: released("v1.2.3") });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
const { stdout } = await publish(server, dir, RELEASE_RUN);
assert.match(stdout, new RegExp(`npm i ${server.url}/@scope/a@v1\\.2\\.3`));
assert.match(stdout, new RegExp(`npm i ${server.url}/@scope/a@latest`));
});
test("publish moves no refs when the release fails", async (t) => {
const server = await startServer(t, { releaseStatus: 409, github: released("v1.2.3") });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
await assert.rejects(publish(server, dir, RELEASE_RUN), /409/);
assert.equal(server.requests.filter((request) => request.url === "/_/refs").length, 0);
});
test("publish still prints the release when a newer release owns latest", async (t) => {
const server = await startServer(t, { refsSha: newerSha, github: released("v1.2.3") });
const dir = join(temporaryDirectory(t), "artifact");
writeArtifact(dir);
const { stdout } = await publish(server, dir, RELEASE_RUN);
assert.match(stdout, /@scope\/a@v1\.2\.3/);
assert.doesNotMatch(stdout, /@latest/);
});