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
32 changes: 16 additions & 16 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,19 @@ jobs:
vars.OIDC_RELEASE_ENABLED == 'true'
# npm provenance requires a GitHub-hosted runner.
runs-on: macos-15
timeout-minutes: 45
# Most of this job is waiting for the registries to serve what it uploaded:
# up to the budget below per crate and per layer of npm packages. A job
# that times out stops behind another approval, like any other failure.
timeout-minutes: 120
environment: release
env:
RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }}
# How long each publisher waits for an upload to become resolvable before
# failing the release. npm took up to 4 minutes 9 seconds to serve a
# 0.12.0 package, when the publishers still allowed about two and a half.
# A retry tag runs this file with the release tag's scripts, so this is
# where a retry would wait longer.
REGISTRY_VISIBILITY_TIMEOUT_MINUTES: 20
permissions:
contents: read
id-token: write
Expand Down Expand Up @@ -299,22 +308,13 @@ jobs:
CARGO_REGISTRY_TOKEN: ${{ steps.auth-truffle.outputs.token }}
run: scripts/publish-crate-if-missing.sh ghosttea-truffle

# The daemon platform packages publish before everything else and the
# resolver publishes last: each publish waits until the registry can
# resolve it, so `@vibecook/ghosttead` can never exist at a version
# whose optional dependencies do not.
# Every package the manifests publish, each once npm resolves the
# workspace packages it depends on at this version, so
# `@vibecook/ghosttead` can never exist at a version whose optional
# dependencies do not. The order comes from the manifests; `--plan`
# prints it.
- name: Publish npm packages
run: |
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttead-darwin-arm64
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttead-win32-x64
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-native-tabs
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-protocol
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-frame
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-client
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-electron
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-react
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttead
run: node scripts/publish-npm-packages.mjs

# Creating the release used to be a manual step after this workflow finished,
# and 0.4.0 shipped to both registries while the release page kept showing
Expand Down
66 changes: 39 additions & 27 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,23 @@ Publish Rust crates in dependency order:
6. `ghosttea`
7. `ghosttea-truffle`

Publish npm packages in dependency order. The binary packages go first and
the resolver goes last: every publish waits until the registry can resolve
it, so `@vibecook/ghosttead` never exists at a version whose optional
dependencies do not.

1. `@vibecook/ghosttead-darwin-arm64`
2. `@vibecook/ghosttead-win32-x64`
3. `@vibecook/ghosttea-native-tabs`
4. `@vibecook/ghosttea-protocol`
5. `@vibecook/ghosttea-frame`
6. `@vibecook/ghosttea`
7. `@vibecook/ghosttea-client`
8. `@vibecook/ghosttea-electron`
9. `@vibecook/ghosttea-react`
10. `@vibecook/ghosttead`
Publish npm packages in dependency order. `scripts/publish-npm-packages.mjs`
derives it from the manifests: each package publishes once npm resolves every
workspace package it depends on at the release version, so
`@vibecook/ghosttead` never exists at a version whose optional dependencies do
not, and no package names a dependency npm cannot install. A package waits
only for its own dependencies, so the release waits for npm once per layer of
the graph rather than once per package. `node scripts/publish-npm-packages.mjs
--plan` prints the order without publishing anything. It is currently:

1. `@vibecook/ghosttea-frame`, `@vibecook/ghosttea-native-tabs`,
`@vibecook/ghosttea-protocol`, `@vibecook/ghosttead-darwin-arm64`, and
`@vibecook/ghosttead-win32-x64`
2. `@vibecook/ghosttea` and `@vibecook/ghosttea-client`, after
`@vibecook/ghosttea-protocol`; `@vibecook/ghosttead`, after both binary
packages
3. `@vibecook/ghosttea-electron` and `@vibecook/ghosttea-react`, after the
packages they are built on

## Binary staging

Expand Down Expand Up @@ -263,22 +265,16 @@ cargo publish --locked --package ghosttea-truffle
```

The npm manifests enable provenance for trusted CI publishing. Disable it only
for the first local publish, which has no CI identity:
for the first local publish, which has no CI identity. With no arguments the
publisher uploads every package npm does not already hold at this version, in
dependency order. Naming packages uploads only those, and whatever they depend
on must already resolve:

```sh
export NPM_CONFIG_PROVENANCE=false
export npm_config_cache=/private/tmp/ghosttea-npm-release-cache

scripts/publish-npm-package-if-missing.sh @vibecook/ghosttead-darwin-arm64
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttead-win32-x64
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-native-tabs
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-protocol
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-frame
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-client
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-electron
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttea-react
scripts/publish-npm-package-if-missing.sh @vibecook/ghosttead
node scripts/publish-npm-packages.mjs

unset NPM_CONFIG_PROVENANCE npm_config_cache
```
Expand Down Expand Up @@ -313,6 +309,12 @@ Every publish step skips artifacts a registry already holds, so only the
missing remainder ships. If the retry itself exposes another workflow defect,
fix it and increment the retry number; never move either tag.

Only the workflow file travels with a retry. The scripts it runs come from the
release tag's tree like everything else, so a fix to a publisher reaches the
next release, not this one. What the workflow passes to them does travel:
`REGISTRY_VISIBILITY_TIMEOUT_MINUTES` is set there so that a retry can wait
longer on a slow registry.

## The GitHub release

The workflow's `github-release` job creates it, after publishing, from the
Expand Down Expand Up @@ -392,4 +394,14 @@ The publish helpers safely skip an exact version that already exists, making a
workflow rerun resumable after partial registry success. They never overwrite
or replace a published artifact. After an upload succeeds, they wait for the
exact version to become publicly resolvable so ordinary registry propagation
does not produce a false release failure.
does not produce a false release failure: for up to
`REGISTRY_VISIBILITY_TIMEOUT_MINUTES`, which the workflow sets to 20.

That margin is deliberate. npm accepts an upload well before it records the
version: during 0.12.0 each package's `time[version]` trailed the accepted
upload by 56 seconds to 4 minutes 9 seconds. The helpers then allowed about two
and a half minutes, so four of the ten packages failed after publishing
successfully, and each failure cost a rerun and another approval of the
`release` environment. A publish npm refuses because it already holds the
version — an earlier attempt's upload, accepted but not yet recorded — is
waited for the same way instead of failing.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@
"check:build-script-inputs": "node scripts/check-build-script-inputs.mjs",
"check:first-publishes": "node scripts/check-first-publishes.mjs",
"test:release-notes": "node --test tests/release-notes.test.mjs",
"test:publish-npm-packages": "node --test tests/publish-npm-packages.test.mjs",
"check:desktop": "npm run format:check && npm run lint && npm run check:workspace-names && npm run check:build-script-inputs && npm run build:sdk && npm run check --workspaces --if-present && cargo check --workspace --all-targets --all-features --locked && cargo clippy --workspace --all-targets --all-features --locked -- -D warnings",
"test:desktop": "npm run build:sdk && npm run test --workspaces --if-present && cargo test --workspace --locked && npm run test:ghosttea-core:ffi && npm run test:release-notes && npm run test:integration:built && npm run test:bench:lib && npm run test:bench:render && npm run test:bench:truffle",
"test:desktop": "npm run build:sdk && npm run test --workspaces --if-present && cargo test --workspace --locked && npm run test:ghosttea-core:ffi && npm run test:release-notes && npm run test:publish-npm-packages && npm run test:integration:built && npm run test:bench:lib && npm run test:bench:render && npm run test:bench:truffle",
"test:lifecycle:soak": "cargo build --release --package ghosttead --locked && node tests/integration/ghosttead-lifecycle-soak.mjs",
"ci:desktop": "npm run check:desktop && npm run test:desktop && npm run package:check:built && npm run test:lifecycle:soak",
"check": "npm run format:check && npm run lint && npm run check:workspace-names && npm run check:build-script-inputs && npm run check:ghostty-upgrade && npm run check:bundled-fonts && npm run check:swiftpm:manifests && npm run check:ios-release-bom && npm run check:ios-release-resources && npm run check:ios-app-store && npm run check:ios-beta-matrix && npm run test:ios:beta-matrix && npm run check:ios-instruments && npm run test:ios:instruments-evidence && npm run check:ios-diagnostics && npm run build:sdk && npm run check --workspaces --if-present && cargo check --workspace && cargo clippy --workspace --all-targets --all-features -- -D warnings",
Expand Down
11 changes: 10 additions & 1 deletion scripts/check-first-publishes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,16 @@ async function packageExists(name) {
const crates = publishedCrates();
const packages = publishedPackages();
requireSameArtifacts("crates", crates, workflowPublishes("publish-crate-if-missing.sh"));
requireSameArtifacts("npm packages", packages, workflowPublishes("publish-npm-package-if-missing.sh"));
// The npm packages have no second list to reconcile: the workflow runs one
// publisher without arguments, and it publishes `publishedPackages()`, the
// manifests' own answer. What can still drift is the workflow calling it.
if (!/^\s*run: node scripts\/publish-npm-packages\.mjs\s*$/m.test(workflow)) {
console.error(
"publish-release.yml does not run `node scripts/publish-npm-packages.mjs` without arguments, " +
"so it would not publish every npm package the manifests declare.",
);
process.exit(1);
}

// Sequentially, and deliberately: crates.io rate-limits its API, and a gate
// that trips that limit reports an outage instead of an answer.
Expand Down
26 changes: 18 additions & 8 deletions scripts/publish-crate-if-missing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,33 @@ version="$(node -p "require('./package.json').version")"
registry_url="https://crates.io/api/v1/crates/${crate}/${version}"
registry_user_agent="ghosttea-release/${version} (https://github.com/vibecook-dev/ghosttea)"

# A registry accepts an upload before it serves it: npm took as long as 4m 09s
# to serve a 0.12.0 package (see scripts/publish-npm-packages.mjs). crates.io
# has been quicker, but a timeout here fails the release behind another
# approval, so both publishers share one generous budget.
visibility_timeout_minutes="${REGISTRY_VISIBILITY_TIMEOUT_MINUTES:-20}"
if [[ ! "$visibility_timeout_minutes" =~ ^[1-9][0-9]*$ ]]; then
echo "REGISTRY_VISIBILITY_TIMEOUT_MINUTES must be a whole number of minutes, not '${visibility_timeout_minutes}'" >&2
exit 1
fi

wait_until_resolvable() {
for _ in {1..60}; do
if curl --fail --silent --show-error --user-agent "$registry_user_agent" "$registry_url" >/dev/null 2>&1 &&
cargo info --registry crates-io "${crate}@${version}" >/dev/null 2>&1; then
return 0
local deadline=$((SECONDS + visibility_timeout_minutes * 60))
until curl --fail --silent --show-error --user-agent "$registry_user_agent" "$registry_url" >/dev/null 2>&1 &&
cargo info --registry crates-io "${crate}@${version}" >/dev/null 2>&1; do
if ((SECONDS >= deadline)); then
return 1
fi
sleep 2
sleep 5
done
return 1
}

if curl --fail --silent --show-error --user-agent "$registry_user_agent" "$registry_url" >/dev/null 2>&1; then
if wait_until_resolvable; then
echo "${crate}@${version} is already published and resolvable; skipping"
exit 0
fi
echo "timed out waiting for ${crate}@${version} to become resolvable" >&2
echo "timed out after ${visibility_timeout_minutes} minutes waiting for ${crate}@${version} to become resolvable" >&2
exit 1
fi

Expand All @@ -33,5 +43,5 @@ if wait_until_resolvable; then
exit 0
fi

echo "timed out waiting for ${crate}@${version} to become resolvable" >&2
echo "timed out after ${visibility_timeout_minutes} minutes waiting for ${crate}@${version} to become resolvable" >&2
exit 1
36 changes: 0 additions & 36 deletions scripts/publish-npm-package-if-missing.sh

This file was deleted.

Loading