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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ These switches are in no preset:
- `modules.automerge: true` adds a workflow that turns on auto-merge for Dependabot's patch and minor
updates, so they merge once the checks and reviews the default branch requires have passed; majors
stay manual (GitHub only; see [CI and releases](docs/ci-and-releases.md#dependabot-auto-merge)).
- `modules.publish: true` adds a job to the release workflow that publishes each release of a node
package to npm, or of a python package to PyPI, through trusted publishing: no token is stored, and
npm adds provenance. The registry needs a one-time trusted publisher entry (GitHub only; see
[CI and releases](docs/ci-and-releases.md#publishing-to-npm-and-pypi)).

You can also start from the file instead of from detection: put a `.repokeeper.yml` you wrote
yourself in the repository and run `repokeeper init`. When the file is there and repokeeper has not
Expand Down Expand Up @@ -128,7 +132,7 @@ Requires Node.js 22.12 or newer.

| Guide | What it covers |
| --- | --- |
| [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script and Dockerfile linting, release-please, the drift check, update pull requests, code scanning, cache cleanup, Dependabot auto-merge |
| [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script and Dockerfile linting, release-please, publishing to npm and PyPI, the drift check, update pull requests, code scanning, cache cleanup, Dependabot auto-merge |
| [Monorepos](docs/monorepos.md) | Stacks in folders: `stack_options.<stack>.directory`, what runs where, the limits |
| [GitHub settings](docs/github-settings.md) | `repokeeper github apply`: description, topics, merge settings and auto-merge, security, branch protection |
| [GitLab](docs/gitlab.md) | What differs on GitLab, the image each stack's job runs in, how releases write the version, the two jobs that need a token, and `repokeeper gitlab apply`: merge settings, branch protection, the Renovate schedule |
Expand Down
70 changes: 70 additions & 0 deletions docs/ci-and-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repok
the version that wrote the standard, so a pull request that edits a managed file fails until the edit
is resolved.

One edit is not drift: a step of a managed job that uses the same action at another commit or tag, as
a Dependabot pull request leaves it. `check` passes, and `update` keeps the newer version. Any other
change to the job still counts, and so does the ref a job calls a reusable workflow at
(`uses: …/.github/workflows/…@v0`), which repokeeper pins on purpose.

## Update pull requests

Set `modules.autoupdate: true` and repokeeper writes `.github/workflows/repokeeper-autoupdate.yml`: every
Expand Down Expand Up @@ -266,6 +271,71 @@ merge click and nothing more.
On GitLab the switch writes nothing: Renovate opens the updates there and has its own `automerge`
setting. It needs repokeeper 0.11.0 or newer.

## Publishing to npm and PyPI

Set `modules.publish: true` and repokeeper adds a `publish` job to `.github/workflows/release.yml`. When
the release job has created a release (the moment release-please's pull request is merged), the job
checks out the release and publishes it:

| Released stack | Registry | What the job runs |
| --- | --- | --- |
| `node` | npm | Node.js 24, the stack's install command (`npm ci`, `pnpm install`, ...), then `npm publish --access public` |
| `python` | PyPI | `python -m build`, then [`pypa/gh-action-pypi-publish`](https://github.com/pypa/gh-action-pypi-publish) |

It uses **trusted publishing**: the job has `id-token: write`, and the registry accepts the short-lived
OIDC token that GitHub gives the run. No npm or PyPI token is stored anywhere. On npm, a package
published this way from a public repository gets a
[provenance statement](https://docs.npmjs.com/generating-provenance-statements) by itself. That is why
the job does not pass `--provenance`: npm makes no provenance for a private repository and would refuse
the flag there.

The job is part of `release.yml` itself, not a reusable workflow. PyPI does not accept a reusable
workflow as a trusted publisher, and npm checks the name of the calling workflow. So on both registries
the trusted publisher names `release.yml`.

**Set up once, before the first release** (until then the job fails):

- **npm:** on npmjs.com, open the package → *Settings* → *Trusted publishing* → *GitHub Actions*, and
enter the owner, the repository and the workflow filename `release.yml`. Leave the environment empty.
Under the allowed actions, allow `npm publish`. A package that is not on npm yet has to be published
once by hand first, because the setting belongs to an existing package. The `repository.url` in
`package.json` has to point at this repository too.
- **PyPI:** on pypi.org, under *Your projects* → *Publishing* (or, for a new project, *Add a pending
publisher*), add a GitHub publisher with the owner, the repository and the workflow `release.yml`,
and leave the environment empty.

**The trap.** Every field is matched exactly and is case-sensitive, and the registry does not check the
entry when you save it. When the owner, the repository or the workflow filename differs in any way (for
example `release.yaml`, `publish.yml`, or an owner spelled with different capitals), the token is
refused. npm then answers the publish with **404 Not Found** or **403 Forbidden**, which reads like a
missing package or a missing permission rather than a mismatch. If the first publish fails that way,
compare the entry with the repository's URL and `release.yml` letter by letter. PyPI states the reason
more plainly (`invalid-publisher`), but the fix is the same.

What else to know:

- The job builds what the package itself builds: npm runs the package's `prepack` and `prepublishOnly`
scripts during `npm publish`, so put the build there. For Python, `python -m build` uses the build
backend in `pyproject.toml`.
- It publishes the stack that is released, which is the first stack with a release type of its own (see
[Releases](#releases)). When that stack is neither `node` nor `python`, the switch writes nothing, and
`init`, `update` and `check` say so. The same happens when `modules.release` is off.
- **Folders are not supported.** release-please names the outputs of a package in a folder after the
folder, and the release workflow does not pass those on. A stack under
`stack_options.<stack>.directory` is therefore not published, and the switch says so.
- The actions the job uses are pinned to commits, the same ones repokeeper's own workflows use.
Dependabot keeps them up to date like any other action: a newer commit or tag of the same action is
not drift (see [Drift check](#drift-check)). While the job is still as repokeeper wrote it,
`repokeeper update` moves the pins itself.
- No preset turns the module on: not every package is meant to be public, and the registry has to be
set up first.
- A `publish` job you wrote yourself, as repokeeper's own `release.yml` has, is reported as unmanaged.
Adopt it with `--adopt`, or keep it by listing `.github/workflows/release.yml#jobs.publish` under
`owned`.
- On GitLab the switch writes nothing: releases there are semantic-release's.

It needs repokeeper 0.11.0 or newer.

---

Back to the [README](../README.md).
1 change: 1 addition & 0 deletions playground/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const AUTOUPDATE_NEEDS_VERSION: string;
export const SECURITY_NEEDS_VERSION: string;
export const CACHE_CLEANUP_NEEDS_VERSION: string;
export const AUTOMERGE_NEEDS_VERSION: string;
export const PUBLISH_NEEDS_VERSION: string;
export const STACK_NEEDS_VERSION: Record<string, string>;
export const STACKS: Array<{ id: string; label: string }>;
export const PLATFORM_STACKS: Record<"github" | "gitlab", string[]>;
Expand Down
18 changes: 17 additions & 1 deletion playground/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const SECURITY_NEEDS_VERSION = "0.9.0";
export const CACHE_CLEANUP_NEEDS_VERSION = "0.11.0";
/** The same for Dependabot auto-merge. */
export const AUTOMERGE_NEEDS_VERSION = "0.11.0";
/** The same for publishing. */
export const PUBLISH_NEEDS_VERSION = "0.11.0";

/** The same for the stacks added after the standard the file names: an earlier repokeeper does not know them. */
export const STACK_NEEDS_VERSION = { docker: "0.9.0" };
Expand Down Expand Up @@ -86,6 +88,11 @@ export const MODULES = [
label: "Dependabot auto-merge",
hint: "auto-merge for patch and minor updates, after the required checks and reviews (GitHub)",
},
{
id: "publish",
label: "Publishing",
hint: "a release job that publishes to npm or PyPI with trusted publishing, no token (GitHub)",
},
];

/** What stands in for a detail the form was left empty on; `problems` reports each one still in use. */
Expand All @@ -106,10 +113,11 @@ const ESSENTIAL_OFF = [
"security",
"cache_cleanup",
"automerge",
"publish",
];

/** The switches no preset turns on. */
const IN_NO_PRESET = ["drift", "autoupdate", "security", "cache_cleanup", "automerge"];
const IN_NO_PRESET = ["drift", "autoupdate", "security", "cache_cleanup", "automerge", "publish"];

/** The module switches of a preset, `health` as a plain on or off. */
export function presetModules(preset) {
Expand Down Expand Up @@ -185,6 +193,7 @@ export function buildConfig(input) {
...(modules.security ? { security: true } : {}),
...(modules.cache_cleanup ? { cache_cleanup: true } : {}),
...(modules.automerge ? { automerge: true } : {}),
...(modules.publish ? { publish: true } : {}),
health: modules.health
? {
license: health.license ? "MIT" : false,
Expand Down Expand Up @@ -272,6 +281,13 @@ export function problems(input) {
: "Dependabot auto-merge is written for GitHub only: on GitLab the switch writes nothing.",
);
}
if (input.modules.publish) {
found.push(
input.platform === "github"
? `Publishing needs repokeeper ${PUBLISH_NEEDS_VERSION} or newer, a node or python stack at the root, and a trusted publisher on npmjs.com or pypi.org.`
: "Publishing is written for GitHub only: on GitLab the switch writes nothing.",
);
}
if (input.platform === "github") {
const { mode, ref, source } = input.workflows;
if ((mode === "ref" || (mode === "mirror" && ref.trim() !== "")) && !/^[A-Za-z0-9_./-]+$/.test(ref.trim())) {
Expand Down
5 changes: 5 additions & 0 deletions src/commands/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const MODULE_IDS: ModuleId[] = [
"security",
"cache_cleanup",
"automerge",
"publish",
];

/**
Expand All @@ -39,6 +40,8 @@ export const MODULE_IDS: ModuleId[] = [
* none either: it deletes things, so it is yours to ask for. Nor is Dependabot auto-merge
* (`modules.automerge`): merging without a person is a choice the repository's owner makes. Under
* `strict` it still waits for the review the ruleset requires, since the workflow never approves.
* Nor is publishing (`modules.publish`): it needs a trusted publisher set up on the registry first, and
* not every package is meant to be public.
*/
export function applyPreset(config: RepokeeperConfig, preset: Preset): RepokeeperConfig {
if (preset === "standard") return config;
Expand All @@ -56,6 +59,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe
security: false,
cache_cleanup: false,
automerge: false,
publish: false,
health: false,
},
};
Expand Down Expand Up @@ -127,6 +131,7 @@ export async function chooseModules(
security: true,
cache_cleanup: true,
automerge: true,
publish: true,
health,
},
};
Expand Down
25 changes: 25 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ import type { Io } from "../cli.js";
import { duplicateTestRuns } from "../duplicates.js";
import { crlfTrackedFiles } from "../git.js";
import { describeOutput, type ModuleContext, nodeAtRoot, type Output } from "../model.js";
import { publishPlan, type Registry } from "../modules/publish.js";
import { overriddenAttributes } from "../sync/attributes.js";
import type { RemovalAction } from "../sync/decide.js";
import type { SyncResult } from "../sync/sync.js";
import { TOOL_VERSIONS } from "../version.js";

const AUTOMERGE_WORKFLOW = ".github/workflows/automerge.yml";

/** What to enter on the registry once, so that it takes the release workflow's word for who publishes. */
function trustedPublisherStep(ctx: ModuleContext, registry: Registry): string {
const repo = ctx.repo.owner ? `${ctx.repo.owner}/${ctx.repo.name}` : "this repository";
const where = registry === "npm" ? "npmjs.com (allow npm publish)" : "pypi.org (environment left empty)";
return `add a trusted publisher on ${where}: repository ${repo}, workflow release.yml, exactly; until then the publish job fails`;
}

export interface CommandOptions {
dryRun: boolean;
force: boolean;
Expand Down Expand Up @@ -104,6 +112,13 @@ export async function nextSteps(root: string, ctx: ModuleContext, result: SyncRe
"run `repokeeper github apply` to allow auto-merge; Dependabot's updates then merge once the checks and reviews the default branch requires have passed, and not at all while it requires none",
);
}
const publish = result.decisions.find(
(d) => d.output.module === "publish" && d.output.kind === "yaml" && d.action === "create",
);
if (publish && ctx.platform.id === "github") {
const plan = publishPlan(ctx);
if (!("problem" in plan)) steps.push(trustedPublisherStep(ctx, plan.registry));
}
if (wrote(".gitattributes")) {
const crlf = await crlfTrackedFiles(root);
if (crlf.length > 0) {
Expand Down Expand Up @@ -143,6 +158,16 @@ export async function printWarnings(root: string, ctx: ModuleContext, io: Io): P
if (ctx.platform.id === "gitlab" && ctx.config.modules.automerge) {
io.out("note: modules.automerge writes nothing on gitlab; Renovate's own automerge setting does that work there");
}
if (ctx.config.modules.publish) {
const plan = publishPlan(ctx);
if (ctx.platform.id === "gitlab") {
io.out(
"note: modules.publish writes nothing on gitlab; publishing is written for GitHub's release workflow only",
);
} else if ("problem" in plan) {
io.out(`note: modules.publish writes nothing: ${plan.problem}`);
}
}
if (ctx.platform.id === "gitlab") {
for (const stack of ctx.stacks) {
const os = stack.ci?.with.os;
Expand Down
4 changes: 3 additions & 1 deletion src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ export function parseConfig(text: string): RepokeeperConfig {

export function renderConfig(config: RepokeeperConfig): string {
// written only when on: a file without the line is one every earlier repokeeper reads as well
const { autoupdate, security, cache_cleanup, automerge, ...modules } = config.modules;
const { autoupdate, security, cache_cleanup, automerge, publish, ...modules } = config.modules;
const asked = {
...(autoupdate ? { autoupdate } : {}),
...(security ? { security } : {}),
...(cache_cleanup ? { cache_cleanup } : {}),
...(automerge ? { automerge } : {}),
...(publish ? { publish } : {}),
};
const written = {
...config,
Expand Down Expand Up @@ -123,6 +124,7 @@ function withDefaults(
security: modules.security ?? false,
cache_cleanup: modules.cache_cleanup ?? false,
automerge: modules.automerge ?? false,
publish: modules.publish ?? false,
health: modules.health,
},
owned: data.owned ?? [],
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const configSchema = {
security: flag,
cache_cleanup: flag,
automerge: flag,
publish: flag,
health: {
anyOf: [
{ const: false },
Expand Down
3 changes: 3 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface ModulesConfig {
cache_cleanup: boolean;
/** A workflow that turns on auto-merge for Dependabot's patch and minor updates; off unless asked for. */
automerge: boolean;
/** A job in the release workflow that publishes each release to npm or PyPI; off unless asked for. */
publish: boolean;
health: HealthConfig | false;
}

Expand Down Expand Up @@ -92,6 +94,7 @@ export function defaultConfig(input: {
security: false,
cache_cleanup: false,
automerge: false,
publish: false,
health: { license: "MIT", copyright: input.copyright, contact: input.contact, codeowners: input.codeowners },
},
owned: [],
Expand Down
2 changes: 2 additions & 0 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export interface PlatformAdapter {
cacheCleanup(ctx: ModuleContext): Output[];
/** A workflow that turns on auto-merge for patch and minor dependency updates; empty where the platform has none. */
dependencyAutomerge(ctx: ModuleContext): Output[];
/** A job that publishes each release of `stack` to `registry`; empty where the platform has none written. */
registryPublish(ctx: ModuleContext, stack: ResolvedStack, registry: "npm" | "pypi"): Output[];
/** release-please configuration, its manifest and the caller release workflow. `directory` is the released stack's folder. */
releaseAutomation(ctx: ModuleContext, release: ReleaseInfo, directory?: string): Output[];
}
Expand Down
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { editorconfigModule } from "./editorconfig.js";
import { gitignoreModule } from "./gitignore.js";
import { healthModule } from "./health.js";
import { hooksModule } from "./hooks.js";
import { publishModule } from "./publish.js";
import { releaseModule } from "./release.js";
import { securityModule } from "./security.js";

Expand All @@ -25,4 +26,5 @@ export const MODULES: Module[] = [
securityModule,
cacheCleanupModule,
automergeModule,
publishModule,
];
34 changes: 34 additions & 0 deletions src/modules/publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Module, ModuleContext, ReleaseType, ResolvedStack } from "../model.js";
import { pickReleaseStack } from "./release.js";

export type Registry = "npm" | "pypi";

/** The registry each release type publishes to; a type with no line here is not published. */
export const REGISTRIES: Partial<Record<ReleaseType, Registry>> = { node: "npm", python: "pypi" };

export type PublishPlan = { stack: ResolvedStack; registry: Registry } | { problem: string };

/**
* What `modules.publish` publishes, or why it writes nothing: the released stack has to be one with a
* registry, at the root. A stack in a folder is released by release-please under outputs named after
* the folder, which the release workflow does not pass on.
*/
export function publishPlan(ctx: ModuleContext): PublishPlan {
if (!ctx.config.modules.release) return { problem: "it publishes what modules.release releases, which is off" };
const stack = pickReleaseStack(ctx.stacks);
const registry = stack ? REGISTRIES[stack.release.type] : undefined;
if (!stack || !registry) {
return { problem: `it publishes node packages to npm and python packages to PyPI, and ${stack?.id} is neither` };
}
if (stack.directory) return { problem: `publishing a stack in a folder (${stack.directory}) is not supported` };
return { stack, registry };
}

export const publishModule: Module = {
id: "publish",
enabled: (config) => config.modules.publish,
outputs: (ctx) => {
const plan = publishPlan(ctx);
return "problem" in plan ? [] : ctx.platform.registryPublish(ctx, plan.stack, plan.registry);
},
};
Loading
Loading