Skip to content

Commit 94eb3d0

Browse files
wittjosiahclaude
andauthored
fix: unblock the release and make the registry publish retryable (#26)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3254406 commit 94eb3d0

5 files changed

Lines changed: 114 additions & 34 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ name: Release
77
# - with none left, publishes the bumped plugins to npm (which also tags each version) and then to
88
# the AT Protocol registry via `dx registry publish`.
99
#
10+
# Dispatching with `registry_only` skips npm and republishes the current versions to the registry, so
11+
# a registry failure can be retried without a version bump — npm is append-only, so a plain re-run
12+
# would find its half already done and never reach the registry.
13+
#
1014
# Plugins carry independent version lines (`fixed: []`), but share one Version Packages PR, so a
1115
# release ships whatever accumulated since the last one — independent numbers, coupled timing.
1216
#
@@ -20,6 +24,11 @@ on:
2024
push:
2125
branches: [main]
2226
workflow_dispatch:
27+
inputs:
28+
registry_only:
29+
description: 'Republish current versions to the registry only, skipping npm. Retries a failed registry publish.'
30+
type: boolean
31+
default: false
2332

2433
permissions:
2534
contents: write
@@ -72,6 +81,7 @@ jobs:
7281
# `changeset publish` pushes to npm and tags each released version; `publishedPackages` then
7382
# reports exactly what this run released, which drives the registry publish below.
7483
- id: changesets
84+
if: ${{ !inputs.registry_only }}
7585
uses: changesets/action@v1
7686
with:
7787
# dxos-bot's PAT, not GITHUB_TOKEN: GitHub's recursion guard parks every `pull_request` run
@@ -86,9 +96,13 @@ jobs:
8696

8797
# npm is the library channel; the registry is what Composer installs from. Both ship the same
8898
# version, from different build outputs — dist/ for npm, out/ for the registry.
99+
# Runs on a `registry_only` dispatch even though nothing was published in that run: npm is
100+
# append-only, so once a version is up the release cannot be replayed to reach the registry
101+
# half, and this is the half that talks to two external services.
89102
- name: Publish released plugins to the registry
90-
if: steps.changesets.outputs.published == 'true'
103+
if: ${{ inputs.registry_only || steps.changesets.outputs.published == 'true' }}
91104
env:
105+
ALL: ${{ inputs.registry_only }}
92106
# Pinned to a pkg.pr.new preview: npm's @dxos/cli@0.10.0 is broken (its binary embeds an
93107
# absolute path to the machine that built it). Override with the DX_CLI_PACKAGE repo
94108
# variable, and drop the default once a working CLI is on npm. Installing the single
@@ -102,16 +116,19 @@ jobs:
102116
# Fail fast on a broken CLI rather than part-way through publishing.
103117
dx --version
104118
105-
# Publish only what this run released; the registry rejects a re-publish of an unchanged
106-
# version, but relying on that would hide a genuine mistake.
107-
node -e '
108-
const fs = require("fs");
109-
const released = new Set(JSON.parse(process.env.PUBLISHED).map((p) => p.name));
110-
const dirs = fs.readdirSync("packages")
111-
.filter((dir) => fs.existsSync(`packages/${dir}/package.json`))
112-
.filter((dir) => released.has(JSON.parse(fs.readFileSync(`packages/${dir}/package.json`, "utf8")).name));
113-
fs.writeFileSync("released-dirs.txt", dirs.map((dir) => `packages/${dir}`).join("\n"));
114-
'
119+
# pnpm resolves the workspace glob and reports `private`, so the publishable set never has
120+
# to be re-derived here. A `registry_only` retry takes all of them, since `publishedPackages`
121+
# is empty in a run that published nothing.
122+
pnpm list --recursive --depth=-1 --json \
123+
| jq -r --argjson all "${ALL:-false}" --argjson published "${PUBLISHED:-[]}" '
124+
($published | map(.name)) as $names
125+
| .[]
126+
| select(.name and (.private | not))
127+
| select($all or (.name as $name | $names | any(. == $name)))
128+
| .path
129+
' > released-dirs.txt
130+
131+
echo "Publishing $(grep -c . released-dirs.txt || true) plugin(s) to the registry."
115132
116133
while read -r dir; do
117134
[ -n "${dir}" ] || continue

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ whatever accumulated. See [RELEASING.md](./RELEASING.md) for the full flow and t
139139
CI. Plugins reach Composer via `dx registry publish`; npm is the secondary channel.
140140
- A publishable plugin needs the `ts-vite-build` tag (`:build`, the npm library) and the `vite` tag
141141
(`:bundle`, the registry artifact), and its `exports`/`imports` maps must point at what `:build`
142-
emits.
142+
emits. It also needs `repository.url` in its `package.json`: npm validates the provenance statement
143+
against that field, and rejects the publish without it. `pnpm check-packages-published` enforces
144+
both this and the `private` rule above.
143145
- PR titles use Conventional Commits: `feat(tictactoe): …`, `fix: …`, `refactor: …`, `docs: …`.
144146
- Before committing, run `git status` and account for every modified/untracked file.

‎RELEASING.md‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ tsconfig.base.json # shared compiler options (each plugin extends it)
1515
.prototools # pinned toolchain (proto/moon/node/pnpm), matching the dxos monorepo
1616
.moon/ # workspace.yml, toolchains.yml, tasks.yml (shared build/typecheck/dev/preview)
1717
.changeset/ # pending changesets
18-
scripts/ # set-sdk.mjs, changeset-all.mjs
18+
scripts/ # set-sdk.mjs, changeset-all.mjs, check-packages-published.mjs
1919
```
2020

2121
Each plugin's `@dxos/*` deps resolve from `catalog:dxos`, so the whole SDK moves as one unit.
@@ -70,6 +70,20 @@ Releases are recorded as git tags rather than a branch — `changeset publish` c
7070
that is not yet ready to publish stays `private: true`; `privatePackages.tag` is enabled so those
7171
are still versioned and tagged.
7272

73+
### Retrying a half-finished release
74+
75+
The two channels can fail independently, and only one of them is replayable by re-running the
76+
workflow. npm is append-only — `changeset publish` skips versions already on the registry — so once
77+
npm has accepted a version, a plain re-run finds nothing to publish, reports nothing released, and
78+
never reaches the registry step. That would leave the registry permanently a version behind with no
79+
way back short of a version bump.
80+
81+
Dispatch **Release** with `registry_only` to republish the current versions to the registry alone,
82+
skipping npm entirely. Use it whenever the registry half fails, or after fixing the plugin's
83+
`dx.config.ts` / bundle. `dx registry publish` rejects an unchanged version, so if a retry reports a
84+
duplicate the release already landed there — confirm with `dx registry records` and, if a record
85+
genuinely needs replacing, remove it first with `dx registry unpublish --key <key>`.
86+
7387
## Keeping up with the SDK
7488

7589
The SDK ships as one unit; bump it via the `dxos` catalog (one place).
@@ -127,12 +141,12 @@ Composer catches up.
127141

128142
## CI
129143

130-
| Workflow | Trigger | Does |
131-
| --------------------- | ------------------ | --------------------------------------------------------------------------------- |
132-
| `check.yml` | PR / push / queue | format, lint, build (npm library), bundle (registry artifact + manifest), test |
133-
| `sdk-nightly.yml` | nightly / dispatch | open/update the SDK upgrade PR from latest pkg.pr.new |
134-
| `sdk-npm-release.yml` | dispatch (version) | pin catalog to npm + release-together changeset → PR |
135-
| `release.yml` | push to `main` | Changesets version PR → tag + `dx registry publish` per released plugin (guarded) |
144+
| Workflow | Trigger | Does |
145+
| --------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
146+
| `check.yml` | PR / push / queue | format, lint, build (npm library), bundle (registry artifact + manifest), test |
147+
| `sdk-nightly.yml` | nightly / dispatch | open/update the SDK upgrade PR from latest pkg.pr.new |
148+
| `sdk-npm-release.yml` | dispatch (version) | pin catalog to npm + release-together changeset → PR |
149+
| `release.yml` | push to `main` / dispatch | Changesets version PR → npm + `dx registry publish` per released plugin (guarded); `registry_only` retries the registry half |
136150

137151
## Secrets / prerequisites
138152

@@ -143,6 +157,12 @@ Composer catches up.
143157
keep it `private: true` until then, or `changeset publish` fails and takes the rest of the release
144158
with it (the registry publish is gated on its output). `pnpm check-packages-published` enforces
145159
this in CI.
160+
- **Every publishable plugin needs `repository.url` in its `package.json`**, pointing at this
161+
repository. Provenance is on (`NPM_CONFIG_PROVENANCE`), and npm validates the signed statement
162+
against that field — a missing one is rejected with `E422 … "repository.url" is ""` _after_ the
163+
signature has been written to the transparency log, so the release is already lost by the time it
164+
surfaces. `pnpm check-packages-published` fails on this too, comparing against
165+
`GITHUB_REPOSITORY`.
146166
- `ATPROTO_HANDLE` + `ATPROTO_APP_PASSWORD` — a verified publisher identity for the release workflow
147167
(or wire `dx account login` for the DPoP path).
148168
- `GH_DXOS_BOT_PAT` — dxos-bot's PAT (`contents: write` + `pull-requests: write`), used by every

‎packages/tictactoe/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
"name": "@dxos/plugin-tictactoe",
33
"version": "0.10.1",
44
"description": "Tic-Tac-Toe plugin for DXOS Composer.",
5+
"repository": {
6+
"type": "git",
7+
"url": "git+https://github.com/dxos/plugins.git",
8+
"directory": "packages/tictactoe"
9+
},
510
"type": "module",
611
"imports": {
712
"#capabilities": {

‎scripts/check-packages-published.mjs‎

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env node
22

33
//
4-
// Fails when a publishable plugin has never been published to npm.
4+
// Fails when a publishable plugin would break `changeset publish` — because npm has never seen it,
5+
// or because its `repository.url` cannot back a provenance statement.
56
//
67
// `changeset publish` publishes every non-private package, and npm rejects a first-ever publish that
78
// has no trusted publisher configured — which fails the release for every other plugin in the same
@@ -13,24 +14,59 @@
1314
// publisher are set up together (see AGENTS.md).
1415
//
1516

16-
import { readFileSync, readdirSync, existsSync } from 'node:fs';
17+
import { execFileSync } from 'node:child_process';
18+
import { readFileSync } from 'node:fs';
19+
import { relative } from 'node:path';
1720

1821
const NPM_REGISTRY = 'https://registry.npmjs.org';
1922
const RETRIES = 2;
2023

21-
const publishable = readdirSync('packages')
22-
.map((dir) => `packages/${dir}/package.json`)
23-
.filter((file) => existsSync(file))
24-
.flatMap((file) => {
25-
let pkg;
26-
try {
27-
pkg = JSON.parse(readFileSync(file, 'utf8'));
28-
} catch {
29-
return [];
30-
}
24+
// pnpm owns which directories are packages (the `packages` glob in pnpm-workspace.yaml) and reports
25+
// the `private` flag, so neither has to be re-derived here — and the workspace root, being private,
26+
// drops out with everything else not meant for npm.
27+
const workspace = JSON.parse(
28+
execFileSync('pnpm', ['list', '--recursive', '--depth=-1', '--json'], { encoding: 'utf8' }),
29+
);
30+
31+
const publishable = workspace
32+
.filter((project) => project.name && !project.private)
33+
.map((project) => ({
34+
name: project.name,
35+
file: `${relative(process.cwd(), project.path)}/package.json`,
36+
repository: JSON.parse(readFileSync(`${project.path}/package.json`, 'utf8')).repository,
37+
}))
38+
.sort((left, right) => left.name.localeCompare(right.name));
39+
40+
// npm verifies a provenance statement against the published `repository.url`, so a package without
41+
// one is rejected at publish time (E422) after the signature has already been logged — and the
42+
// release is over by then. `git+…`/`.git` are the conventional spelling; npm compares the bare URL.
43+
const normalize = (url) =>
44+
typeof url === 'string'
45+
? url
46+
.replace(/^git\+/, '')
47+
.replace(/\.git$/, '')
48+
.replace(/\/$/, '')
49+
: '';
50+
51+
// Set for every GitHub Actions run; locally there is nothing to compare against, so presence alone
52+
// is checked.
53+
const expected = process.env.GITHUB_REPOSITORY ? `https://github.com/${process.env.GITHUB_REPOSITORY}` : undefined;
54+
55+
const misdeclared = publishable.filter(({ repository }) => {
56+
const url = normalize(repository?.url);
57+
return url === '' || (expected !== undefined && url !== expected);
58+
});
3159

32-
return !pkg.name || pkg.private ? [] : [{ file, name: pkg.name }];
33-
});
60+
if (misdeclared.length > 0) {
61+
console.error('ERROR: these plugins are publishable but cannot produce a valid provenance statement.');
62+
console.error(`Set \`repository.url\` in each package.json to ${expected ?? 'this repository'}`);
63+
console.error('(the `git+https://….git` form is fine — npm normalises it). See RELEASING.md.');
64+
console.error('');
65+
for (const { name, file, repository } of misdeclared) {
66+
console.error(` ${name} (${file}) — repository.url is ${JSON.stringify(repository?.url ?? null)}`);
67+
}
68+
process.exit(1);
69+
}
3470

3571
const isPublished = async (name) => {
3672
const url = `${NPM_REGISTRY}/${encodeURIComponent(name)}`;
@@ -65,7 +101,7 @@ if (unpublished.length > 0) {
65101
console.error('Set `"private": true` until the first publish, then configure npm trusted publishing');
66102
console.error('(OIDC) for the package and drop the flag. See AGENTS.md.');
67103
console.error('');
68-
for (const { name, file } of unpublished.sort((a, b) => a.name.localeCompare(b.name))) {
104+
for (const { name, file } of unpublished) {
69105
console.error(` ${name} (${file})`);
70106
}
71107
process.exit(1);

0 commit comments

Comments
 (0)