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
19 changes: 14 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ jobs:

# The same check `npm version` runs first (see CONTRIBUTING.md): an
# open issue labelled release-hold stops the release. Both jobs that
# release need this one and check again themselves, so a held tag
# reaches neither npm nor the GitHub release notes. A dispatch on a
# release need this one and check again themselves, each when it
# runs, so a hold open at this check stops both. A dispatch on a
# branch releases nothing, so it isn't held.
- name: Release holds
if: startsWith(github.ref, 'refs/tags/v')
Expand Down Expand Up @@ -108,7 +108,10 @@ jobs:
needs: build-and-test
# Deliberately independent of the npm publish job: some consumers run
# github-clone checkouts, and release notes must exist even when npm
# publish fails — the two jobs succeed or fail on their own.
# publish fails — the two jobs succeed or fail on their own. Both need
# the build job, so a failed build stops both; past it, so do a tag with
# no changelog section, which neither can release, and an open release
# hold, which each checks.
if: startsWith(github.ref, 'refs/tags/v')

# issues: read lets the release-hold check list the holds.
Expand All @@ -121,9 +124,15 @@ jobs:
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 24

# Checked again here, as prepublishOnly checks it again in the
# publish job: a hold filed after the build job's check stops these
# notes as it stops the npm publish.
# publish job, each when its job reaches it: a hold filed after the
# build job's check stops whichever of the two checks after it. A
# hold filed between the two checks can stop one and not the other.
- name: Release holds
run: node scripts/release-holds.mjs
env:
Expand Down
28 changes: 18 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,22 +133,30 @@ never do.
(subsections emitted in `### Breaking` / `### Added` / `### Changed` /
`### Fixed` order), deletes the consumed fragments, keeps a fresh empty
`Unreleased` above, and refuses to release when there is nothing to
release; npm then commits and tags. `git push --follow-tags` triggers CI,
which refuses a tag with no matching changelog section, publishes
release; npm then commits and tags. `git push --follow-tags` triggers
CI, which refuses a tag with no matching changelog section, publishes
`@animalabs/agent-framework` to npm, and creates the GitHub release with
that section as its notes. The two release jobs are independent: some
consumers run github-clone checkouts, so release notes must exist even
when npm publish fails. Version bumps are a maintainer release-time
action, not part of feature PRs.
when npm publish fails. Both need the build job, so a failed build stops
both; past it, so do a tag with no changelog section, which neither can
release, and an open release hold (below), which each checks. Version
bumps are a maintainer release-time action, not part of feature PRs.
- **Release holds.** When releasing `main` as it stands would harm
someone running it, open an issue that says what has to happen first
and label it `release-hold`, or ask a maintainer to label it. Close it
once that's happened, or if it no longer applies. While one is open,
`npm version` stops before it bumps anything, `npm publish` refuses,
and the publish workflow refuses both its npm publish and its GitHub
release notes. The check also stops a release when it can't read the
holds from GitHub, and says why. npm's `--ignore-scripts` skips it, as
it skips every lifecycle script.
once that's happened, or if it no longer applies. When what has to
happen is a fix that this package itself must release, close the hold
once the fix is merged, then release. While one is open, `npm version`
stops before it bumps anything, `npm publish` refuses, and the publish
workflow refuses both its npm publish and its GitHub release notes,
each checking when it runs. The check also stops a release when it
can't read the holds from GitHub, and says why. Closing the hold is
the way past it. npm's `--ignore-scripts` skips the check, but on
`npm version` it skips the changelog fold too, so the tag lands
without its changelog section and the publish workflow's changelog
check fails it. Publishing a tarball made earlier with `npm pack` runs
no lifecycle script, so no check sees it.

## Building and testing

Expand Down
55 changes: 33 additions & 22 deletions scripts/release-holds.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Runs as npm's `preversion` lifecycle hook (see package.json), before
// `npm version` bumps anything; as `prepublishOnly`, before `npm publish`;
// and in both of the publish workflow's releasing jobs for the pushed tag.
// and in the publish workflow for a pushed tag: in its build job, and in
// each of its two releasing jobs.
// Stops the release while the repository has an open issue labelled
// `release-hold`: someone found that releasing `main` as it stands would harm
// whoever runs it, and the issue says what has to happen first.
//
// The repository is the GitHub repository package.json names, as a URL or in
// npm's shorthand, so a fork or a copy asks the package's home;
// GITHUB_REPOSITORY (as in GitHub Actions) is used only when package.json
// names none. A token in GH_TOKEN or GITHUB_TOKEN is sent when present; the
// names none (no repository field, or a blank one). A token in GH_TOKEN or GITHUB_TOKEN is sent when present; the
// issues of a public repository read without one. If the holds can't be read,
// the release stops too, since an open hold can't be ruled out.
import { readFileSync, writeSync } from "node:fs";
Expand All @@ -25,14 +26,16 @@ function fail(message) {
}

/** owner/repo from package.json's repository field: a github.com URL, or
* npm's shorthand (github:owner/repo, or owner/repo alone). */
* npm's shorthand (github:owner/repo, or owner/repo alone). A URL can go on
* past the repository (a path, such as a monorepo's /tree/main/pkg, a query
* or a fragment), and the shorthand can name a ref after `#`. */
function githubRepository(field) {
const value = typeof field === "string" ? field : field?.url;
if (typeof value !== "string" || value.trim() === "") return {};
const url = /github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(value);
if (url) return { repo: url[1] };
const shorthand = /^(?:github:)?([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(value);
if (shorthand) return { repo: shorthand[1] };
const url = /github\.com[/:]([^/#?\s]+)\/([^/#?\s]+?)(?:\.git)?(?:[/#?].*)?$/.exec(value);
if (url) return { repo: `${url[1]}/${url[2]}` };
const shorthand = /^(?:github:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#.*)?$/.exec(value);
if (shorthand) return { repo: `${shorthand[1]}/${shorthand[2]}` };
return { unreadable: value };
}

Expand Down Expand Up @@ -60,6 +63,16 @@ const { repo, namedBy } = repository();
const api = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/+$/, "");
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
const where = `https://github.com/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${LABEL}`;
// How to go on after a stop, for where it ran: npm version's hook, wherever
// it runs, comes before any tag; in the workflow, the tag is there to re-run.
const lifecycle = process.env.npm_lifecycle_event;
const rerun = lifecycle === "preversion"
? "run npm version again"
: process.env.GITHUB_ACTIONS === "true"
? "re-run this workflow for the tag"
: lifecycle === "prepublishOnly"
? "run npm publish again"
: "run this again";

/** What a maintainer can do about an answer other than the list. */
function refusal(status) {
Expand All @@ -76,7 +89,10 @@ function refusal(status) {
? `no repository ${repo} is visible to the token in GH_TOKEN or GITHUB_TOKEN; check ${namedBy} and the token's access`
: `no repository ${repo} is visible without a token; check ${namedBy}, or set GH_TOKEN if the repository is private`;
}
if (status >= 500) return "GitHub had an error; run this again once it answers normally";
if (status === 410) {
return `issues are turned off for ${repo}, so no hold can be filed or read there; turn them on, or point ${namedBy} at the repository that tracks them`;
}
if (status >= 500) return `GitHub had an error; ${rerun} once it answers normally`;
return "this check doesn't know that answer; see what GitHub shows";
}

Expand All @@ -98,7 +114,7 @@ async function page(number) {
const why = error.name === "TimeoutError"
? "no answer within 30 s"
: error.cause?.code ?? error.cause?.message ?? error.message;
fail(`release-holds: couldn't reach GitHub to read the release holds (${why}). Check ${where}, and run this again once GitHub can be reached.`);
fail(`release-holds: couldn't reach GitHub to read the release holds (${why}). Check ${where}, and ${rerun} once GitHub can be reached.`);
}
if (!response.ok) {
fail(`release-holds: GitHub answered ${`${response.status} ${response.statusText}`.trim()} when asked for the release holds: ${refusal(response.status)}. ${where}`);
Expand All @@ -107,39 +123,34 @@ async function page(number) {
try {
items = await response.json();
} catch (error) {
fail(`release-holds: couldn't read GitHub's answer (${error.name === "TimeoutError" ? "no answer within 30 s" : error.message}). Check ${where}, and run this again.`);
fail(`release-holds: couldn't read GitHub's answer (${error.name === "TimeoutError" ? "no answer within 30 s" : error.message}). Check ${where}, and ${rerun}.`);
}
if (!Array.isArray(items)) {
fail(`release-holds: GitHub's answer wasn't a list of issues. Check ${where}, and run this again.`);
fail(`release-holds: GitHub's answer wasn't a list of issues. Check ${where}, and ${rerun}.`);
}
return items;
}

// The issues endpoint lists pull requests too. A hold is an issue: a pull
// request isn't on main until it merges.
// Only a page after the last it reads that holds something is past the
// limit: a full last page leads to one more read, which may be empty.
const holds = [];
for (let number = 1; ; number++) {
if (number > MAX_PAGES) {
const items = await page(number);
if (number > MAX_PAGES && items.length > 0) {
fail(`release-holds: more than ${MAX_PAGES * PAGE} open items are labelled ${LABEL} in ${repo}, more than this check reads. ${where}`);
}
const items = await page(number);
holds.push(...items.filter((item) => !item.pull_request));
if (items.length < PAGE) break;
}

if (holds.length > 0) {
const again = process.env.GITHUB_ACTIONS === "true"
? "then re-run this workflow for the tag"
: process.env.npm_lifecycle_event === "preversion"
? "then run npm version again"
: process.env.npm_lifecycle_event === "prepublishOnly"
? "then run npm publish again"
: "then run this again";
fail(
[
`Release held: ${holds.length === 1 ? "an open issue is" : `${holds.length} open issues are`} labelled ${LABEL} in ${repo}.`,
`Release held: ${holds.length === 1 ? "an open issue is" : `${holds.length} open issues are`} labelled ${LABEL} in ${repo} (named by ${namedBy}).`,
...holds.map((hold) => ` #${hold.number} ${hold.title}`),
`Resolve each one, or close it if it no longer applies, ${again}. ${where}`,
`Resolve each one, or close it if it no longer applies, then ${rerun}. ${where}`,
].join("\n"),
);
}
Loading
Loading