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.
This commit is contained in:
Christian Klotz 2026-11-05 10:01:01 +00:00 • committed by GitHub
parent 5732bac060
commit 7a52c2108d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 239 additions and 24 deletions

View file

@ -3,6 +3,9 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main]
# The release commit is pushed together with the next commit, so a push to main never builds it; the tag run does,
# and preview-packages.yml releases its packages on pkg.earendil.com.
tags: ['v*.*.*']
pull_request: pull_request:
branches: [main] branches: [main]
@ -29,7 +32,7 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
# Previews are published for main and PRs from branches of this repo only (see preview-packages.yml). # Previews are published for main, release tags and PRs from branches of this repo only (see preview-packages.yml).
- name: Pack preview packages - name: Pack preview packages
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
env: env:

View file

@ -1,6 +1,9 @@
# Publishes the preview packages that CI packed for main and for PRs from branches of this repo to pkg.earendil.com, # Publishes the preview packages that CI packed for main, for release tags and for PRs from branches of this repo to
# moving the `main` or `pr-<number>` ref, and deletes a PR's ref when the PR closes. It reads the CI artifact as data # pkg.earendil.com, moving the `main` or `pr-<number>` ref, and deletes a PR's ref when the PR closes. A release tag
# and never checks out or runs PR code. # (v1.2.3) is released there: its packages are kept for good under the tag, and the `latest` ref moves to it. Internal
# users can install a release from there as soon as CI passed, without waiting for the npm release. Releases come from
# main in version order: `latest` follows the newest tag run, except for pre-releases (v1.2.3-rc.1), and a tag on a
# commit off main is not released. It reads the CI artifact as data and never checks out or runs PR code.
# #
# Uploads are only accepted if pkg.earendil.com allows this repository, this workflow on main, and every package it # Uploads are only accepted if pkg.earendil.com allows this repository, this workflow on main, and every package it
# publishes. # publishes.
@ -18,12 +21,15 @@ permissions: {}
jobs: jobs:
publish: publish:
# Only CI runs for pushes to main and for PRs from branches of this repo. A branch can make its own copy of CI run # Only CI runs for pushes to main, passing runs for release tags, and PRs from branches of this repo. A branch can
# on push; such runs are not reviewed and must not be published as push builds. # make its own copy of CI run on push; such runs are not reviewed and must not be published as push builds. A tag
# run's head_branch is the tag name, which a branch can also have: preview-packages.mjs verifies push runs.
if: >- if: >-
github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'cancelled' && github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'cancelled' &&
github.event.workflow_run.head_repository.full_name == github.repository && github.event.workflow_run.head_repository.full_name == github.repository &&
((github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') || ((github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main') ||
(github.event.workflow_run.event == 'push' && startsWith(github.event.workflow_run.head_branch, 'v') &&
github.event.workflow_run.conclusion == 'success') ||
github.event.workflow_run.event == 'pull_request') github.event.workflow_run.event == 'pull_request')
# Publish and cleanup runs for the same branch execute one at a time, in order. The group is set per job, so runs # Publish and cleanup runs for the same branch execute one at a time, in order. The group is set per job, so runs
# whose job is skipped (forks) never wait in it. `queue: max` keeps every waiting run instead of replacing one. # whose job is skipped (forks) never wait in it. `queue: max` keeps every waiting run instead of replacing one.
@ -42,9 +48,16 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }} RUN_ID: ${{ github.event.workflow_run.id }}
EVENT: ${{ github.event.workflow_run.event }}
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
run: | run: |
count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" \ count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" \
--jq '[.artifacts[] | select(.name == "preview-packages")] | length') --jq '[.artifacts[] | select(.name == "preview-packages")] | length')
# A passing push run packed, so its artifact expired (after 3 days); a release tag would go unreleased.
if [ "$count" = 0 ] && [ "$EVENT" = push ] && [ "$CONCLUSION" = success ]; then
echo "::error::The CI artifact expired; re-run CI to publish this commit"
exit 1
fi
if [ "$count" = 0 ]; then echo "::notice::CI produced no preview packages (it failed before packing)"; fi if [ "$count" = 0 ]; then echo "::notice::CI produced no preview packages (it failed before packing)"; fi
echo "found=$([ "$count" != 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" echo "found=$([ "$count" != 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
@ -72,13 +85,15 @@ jobs:
EVENT: ${{ github.event.workflow_run.event }} EVENT: ${{ github.event.workflow_run.event }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }} HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
PULL_REQUESTS: ${{ toJSON(github.event.workflow_run.pull_requests) }} PULL_REQUESTS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
RUN_ID: ${{ github.event.workflow_run.id }} RUN_ID: ${{ github.event.workflow_run.id }}
DIR: ${{ runner.temp }}/preview-packages DIR: ${{ runner.temp }}/preview-packages
run: | run: |
args=(--dir "$DIR" --sha "$HEAD_SHA" --run "$RUN_ID" --event "$EVENT") args=(--dir "$DIR" --sha "$HEAD_SHA" --run "$RUN_ID" --event "$EVENT")
if [ "$EVENT" = push ] && [ "$HEAD_BRANCH" = main ]; then if [ "$EVENT" = push ]; then
args+=(--ref main) # The script decides what a push run publishes (main or a release tag) and checks the commit is on main.
args+=(--branch "$HEAD_BRANCH" --conclusion "$CONCLUSION")
else else
# The branch's PR into main; a branch can also have PRs into other branches. It may have closed or received # The branch's PR into main; a branch can also have PRs into other branches. It may have closed or received
# new commits since CI ran, so the ref moves only while the PR is open with this commit as its head. # new commits since CI ran, so the ref moves only while the PR is open with this commit as its head.

View file

@ -4,8 +4,9 @@
// //
// pack Point internal dependencies at this commit's builds, then `npm pack` every public package. // pack Point internal dependencies at this commit's builds, then `npm pack` every public package.
// Runs in the regular CI job after the build. // Runs in the regular CI job after the build.
// publish Upload the packed tarballs and move refs (main, pr-1234). Runs in the trusted workflow_run job and // publish Upload the packed tarballs and move refs (main, pr-1234), or release a tag (v1.2.3) and move `latest`.
// authenticates with the job's GitHub OIDC token (`permissions: id-token: write`). // Runs in the trusted workflow_run job and authenticates with the job's GitHub OIDC token
// (`permissions: id-token: write`). A release keeps the packages for good under the tag.
// delete-ref Remove a ref, e.g. pr-1234 when the PR closes. // delete-ref Remove a ref, e.g. pr-1234 when the PR closes.
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
@ -16,6 +17,8 @@ import { getPublicWorkspacePackages } from "./release-packages.mjs";
const BASE_URL = "https://pkg.earendil.com"; const BASE_URL = "https://pkg.earendil.com";
const FULL_SHA = /^[0-9a-f]{40}$/; const FULL_SHA = /^[0-9a-f]{40}$/;
// Release tags as CI builds them (v*.*.*); a suffix marks a pre-release (v1.2.3-rc.1).
const RELEASE_TAG = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
// Its daemon binaries are cross-compiled only by the release workflow; previews would ship without them. // Its daemon binaries are cross-compiled only by the release workflow; previews would ship without them.
const EXCLUDED = new Set(["@earendil-works/pi-env"]); const EXCLUDED = new Set(["@earendil-works/pi-env"]);
@ -92,10 +95,55 @@ async function call(method, url, token, body) {
return JSON.parse(text); return JSON.parse(text);
} }
function renderSummary({ baseUrl, sha, refs, names }) { /** Reads `path` of this repository from the GitHub REST API with the job's token. */
function github(path) {
const api = process.env.GITHUB_API_URL ?? "https://api.github.com";
return call("GET", `${api}/repos/${process.env.GITHUB_REPOSITORY}/${path}`, process.env.GH_TOKEN);
}
/**
* Decides what a CI push run publishes: `main`, a release tag with `latest` (without it for a pre-release), or nothing
* for any other name. A tag push's branch is the tag's name, and a branch or tag can carry its own copy of CI, so a
* tag named `main` or `v1.2.3` on an unreviewed commit would pass a name check: the commit must also be on main, and a
* release tag must point at it.
*/
async function pushTarget({ branch, sha, conclusion }) {
if (branch === "main") {
await assertOnMain(sha);
return { refs: ["main"] };
}
if (!RELEASE_TAG.test(branch)) return undefined;
// CI packs before its tests run; a release is kept for good.
if (conclusion !== "success") throw new Error(`CI for ${branch} did not pass; not releasing it`);
await assertOnMain(sha);
const target = await tagCommit(branch);
if (target !== sha) throw new Error(`${branch} points at ${target}, not ${sha}`);
return { tag: branch, refs: branch.includes("-") ? [] : ["latest"] };
}
// main is resolved as the branch: in compare, a tag named `main` would win over a bare `main`.
async function assertOnMain(sha) {
const main = await github("git/ref/heads/main");
const { status } = await github(`compare/${main.object.sha}...${sha}`);
if (status !== "behind" && status !== "identical") throw new Error(`${sha} is not on main (${status})`);
}
// Annotated tags point at a tag object, which can point at another tag.
async function tagCommit(tag) {
let { object } = await github(`git/ref/tags/${tag}`);
while (object.type === "tag") ({ object } = await github(`git/tags/${object.sha}`));
return object.sha;
}
function renderSummary({ baseUrl, sha, release, refs, names }) {
const lines = ["### Preview packages", "", `Built from \`${sha.slice(0, 7)}\`.`, ""]; const lines = ["### Preview packages", "", `Built from \`${sha.slice(0, 7)}\`.`, ""];
if (release) {
lines.push(`Release \`${release.tag}\` (kept for good):`, "", "```sh");
for (const { name } of release.packages) lines.push(`npm i ${baseUrl}/${name}@${release.tag}`);
lines.push("```", "");
}
if (refs.length > 0) { if (refs.length > 0) {
lines.push(`Latest build of \`${refs[0]}\` (updates with new pushes):`, "", "```sh"); lines.push(`Latest build of \`${refs[0]}\` (moves with newer builds):`, "", "```sh");
for (const name of names) lines.push(`npm i ${baseUrl}/${name}@${refs[0]}`); for (const name of names) lines.push(`npm i ${baseUrl}/${name}@${refs[0]}`);
lines.push("```", ""); lines.push("```", "");
} }
@ -113,13 +161,15 @@ async function publish(argv) {
dir: { type: "string", default: "preview-packages" }, dir: { type: "string", default: "preview-packages" },
sha: { type: "string" }, sha: { type: "string" },
ref: { type: "string", multiple: true, default: [] }, ref: { type: "string", multiple: true, default: [] },
branch: { type: "string" },
conclusion: { type: "string" },
run: { type: "string" }, run: { type: "string" },
event: { type: "string" }, event: { type: "string" },
"base-url": { type: "string", default: BASE_URL }, "base-url": { type: "string", default: BASE_URL },
}, },
}); });
assertSha(values.sha); assertSha(values.sha);
const { sha, ref: refs, event } = values; const { sha, event } = values;
const run = Number(values.run); const run = Number(values.run);
if (!/^\d+$/.test(values.run ?? "") || !Number.isSafeInteger(run)) { if (!/^\d+$/.test(values.run ?? "") || !Number.isSafeInteger(run)) {
throw new UsageError(`--run must be the CI run ID, got ${values.run}`); throw new UsageError(`--run must be the CI run ID, got ${values.run}`);
@ -127,8 +177,16 @@ async function publish(argv) {
if (event !== "push" && event !== "pull_request") { if (event !== "push" && event !== "pull_request") {
throw new UsageError(`--event must be push or pull_request, got ${event}`); throw new UsageError(`--event must be push or pull_request, got ${event}`);
} }
if (event === "push" && !values.branch) throw new UsageError("Push runs need --branch");
const baseUrl = values["base-url"]; const baseUrl = values["base-url"];
const dir = resolve(values.dir); const dir = resolve(values.dir);
// Push runs are checked before anything is uploaded; pull request runs move the refs the workflow passes.
const target = event === "push" ? await pushTarget(values) : { refs: values.ref };
if (!target) {
console.error(`::notice::${values.branch} is neither main nor a release tag; nothing to publish`);
return;
}
const { tag, refs } = target;
// The artifact comes from an untrusted PR build: only read tarballs from it. The SHA, run, event, and refs // The artifact comes from an untrusted PR build: only read tarballs from it. The SHA, run, event, and refs
// come from the trusted workflow_run event; the worker authorizes package names against its config. // come from the trusted workflow_run event; the worker authorizes package names against its config.
@ -147,18 +205,24 @@ async function publish(argv) {
names.push(result.name); names.push(result.name);
console.error(`${result.existing ? "exists" : "uploaded"} ${result.name}@${sha}`); console.error(`${result.existing ? "exists" : "uploaded"} ${result.name}@${sha}`);
} }
// Release before moving refs, so `latest` never points at a commit whose release failed.
const release = tag && (await call("POST", `${baseUrl}/_/releases`, token, JSON.stringify({ sha, tag })));
if (release) console.error(`${release.existing ? "already released" : "released"} ${tag}`);
let movedRefs = refs;
if (refs.length > 0) { if (refs.length > 0) {
const result = await call("POST", `${baseUrl}/_/refs`, token, JSON.stringify({ sha, refs, run })); const result = await call("POST", `${baseUrl}/_/refs`, token, JSON.stringify({ sha, refs, run }));
const newer = result.refs.filter((ref) => ref.sha !== sha); const newer = result.refs.filter((ref) => ref.sha !== sha);
if (newer.length > 0) { if (newer.length > 0) {
// A newer run already published these refs; its summary has the current install commands. // A newer run already published these refs; its summary has the current install commands.
console.error(`a newer run already published ${newer.map((ref) => `${ref.ref} (${ref.sha})`).join(", ")}`); console.error(`a newer run already published ${newer.map((ref) => `${ref.ref} (${ref.sha})`).join(", ")}`);
return; if (!tag) return;
} movedRefs = [];
} else {
console.error(`moved ${refs.join(", ")} to ${sha}`); console.error(`moved ${refs.join(", ")} to ${sha}`);
} }
}
const summary = renderSummary({ baseUrl, sha, refs, names: names.sort() }); const summary = renderSummary({ baseUrl, sha, release, refs: movedRefs, names: names.sort() });
process.stdout.write(summary); process.stdout.write(summary);
if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, summary); if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, summary);
} }
@ -175,7 +239,9 @@ async function deleteRef(argv) {
const USAGE = `Usage: const USAGE = `Usage:
node scripts/preview-packages.mjs pack --sha <sha> [--out dir] node scripts/preview-packages.mjs pack --sha <sha> [--out dir]
node scripts/preview-packages.mjs publish --sha <sha> --run <CI run id> --event <push|pull_request> node scripts/preview-packages.mjs publish --sha <sha> --run <CI run id> --event push --branch <name>
--conclusion <CI conclusion> [--dir dir]
node scripts/preview-packages.mjs publish --sha <sha> --run <CI run id> --event pull_request
[--dir dir] [--ref name]... [--dir dir] [--ref name]...
node scripts/preview-packages.mjs delete-ref --ref <name>`; node scripts/preview-packages.mjs delete-ref --ref <name>`;

View file

@ -12,6 +12,8 @@ const run = promisify(execFile);
const script = join(dirname(fileURLToPath(import.meta.url)), "preview-packages.mjs"); const script = join(dirname(fileURLToPath(import.meta.url)), "preview-packages.mjs");
const sha = "a".repeat(40); const sha = "a".repeat(40);
const newerSha = "b".repeat(40); const newerSha = "b".repeat(40);
const mainSha = "c".repeat(40);
const REPO_API = "/repos/earendil-works/pi/";
function temporaryDirectory(t) { function temporaryDirectory(t) {
const directory = mkdtempSync(join(tmpdir(), "pi-preview-packages-test-")); const directory = mkdtempSync(join(tmpdir(), "pi-preview-packages-test-"));
@ -24,11 +26,23 @@ function writePackage(repo, directory, manifest) {
writeFileSync(join(repo, "packages", directory, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`); 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 and GitHub's OIDC token endpoint, recording requests. `refsSha` is what the * Stands in for the pkg.earendil.com worker, GitHub's OIDC token endpoint and GitHub's REST API, recording requests.
* worker reports each ref resolves to after the update; `uploadBody` overrides the upload response. * `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 } = {}) { async function startServer(t, { refsSha = sha, uploadBody, releaseStatus = 201, github = {} } = {}) {
const requests = []; const requests = [];
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
let body = ""; let body = "";
@ -36,10 +50,21 @@ async function startServer(t, { refsSha = sha, uploadBody } = {}) {
requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization, body }); requests.push({ method: req.method, url: req.url, authorization: req.headers.authorization, body });
const url = new URL(req.url, "http://localhost"); 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 === "/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") { if (req.method === "PUT") {
const name = decodeURIComponent(url.pathname.split("/").slice(4).join("/")); const name = decodeURIComponent(url.pathname.split("/").slice(4).join("/"));
return res.end(uploadBody ?? JSON.stringify({ name, sha, existing: false })); 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); const { refs } = JSON.parse(body);
res.end(JSON.stringify({ refs: refs.map((ref) => ({ ref, sha: refsSha })) })); res.end(JSON.stringify({ refs: refs.map((ref) => ({ ref, sha: refsSha })) }));
}); });
@ -55,14 +80,32 @@ function writeArtifact(directory) {
writeFileSync(join(directory, "manifest.json"), JSON.stringify({ sha, packages })); writeFileSync(join(directory, "manifest.json"), JSON.stringify({ sha, packages }));
} }
function publish(server, dir) { function publish(server, dir, args = ["--event", "pull_request", "--ref", "pr-1"]) {
const { GITHUB_STEP_SUMMARY: _, ...env } = process.env; const { GITHUB_STEP_SUMMARY: _, ...env } = process.env;
const args = ["publish", "--sha", sha, "--run", "7", "--event", "pull_request", "--ref", "pr-1", "--dir", dir]; const common = ["publish", "--sha", sha, "--run", "7", "--dir", dir];
return run(process.execPath, [script, ...args, "--base-url", server.url], { 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" }, 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) => { test("pack points internal dependencies at the commit's builds and restores package.json", async (t) => {
const repo = temporaryDirectory(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, "a", { name: "@scope/a", version: "1.0.0", dependencies: { "@scope/b": "1.0.0", other: "2.0.0" } });
@ -125,3 +168,91 @@ test("publish fails on a successful response that is not JSON", async (t) => {
await assert.rejects(publish(server, dir), /not valid JSON/); await assert.rejects(publish(server, dir), /not valid JSON/);
assert.equal(server.requests.filter((request) => request.method === "POST").length, 0); 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/);
});