Files
Grugandgrugbraindev 5b11997e74 Stage npm releases from a release workflow, add CI, bump reallysimpledocs to 1.0.14 (#215)
* Stage npm releases from a release workflow, add CI checks, bump reallysimpledocs to 1.0.14

* Announce releases on Discord only after the site deploys

---------

Co-authored-by: grugbraindev <178141691+grugbraindev@users.noreply.github.com>
2026-10-23 15:03:23 +08:00

278 lines
11 KiB
YAML

name: Release
# Publishing a GitHub release (not a prerelease):
# 1. publish: checks the tag is on main and matches package.json, runs the checks,
# then stages the version on npm with `npm stage publish`, using trusted publishing
# (OIDC, no token). A maintainer then approves it on npmjs.com (Staged Packages tab)
# or with `npm stage approve <stage-id>`, with 2FA. Until then the version is not
# installable.
# 2. deploy-site: waits until the version is live on npm (polls every 30 seconds for
# up to about 5h50m), then points the `release` branch at the tag. Cloudflare
# Workers Builds deploys basecoatui.com from `release`, so the docs never describe
# a version npm does not serve. If you approve later than that, re-run the failed
# jobs once approved (deploy-site, then announce).
# 3. announce: posts the release to Discord (DISCORD_WEBHOOK_URL secret), only after
# deploy-site succeeded: the version is on npm and `release` has moved.
#
# Running this workflow manually never publishes to npm. Pick a mode:
# deploy move `release` to the tag (redeploy or roll back the site),
# only if that version is already on npm. No Discord post.
# announce post the tag's GitHub release to Discord again. No deploy.
# deploy and announce both, announcing only if the deploy succeeded.
#
# Prereleases are ignored by every job.
#
# npm trusted publisher settings (allow `npm stage publish` only): repository
# hunvreus/basecoat, workflow release.yml, no environment. Renaming this file breaks
# publishing.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: Release tag (for example 1.1.0). Never publishes to npm.
required: true
type: string
mode:
description: "deploy: move the site to this tag (version must be on npm). announce: post the release to Discord again. deploy and announce: both."
required: true
type: choice
default: deploy
options:
- deploy
- announce
- deploy and announce
permissions: {}
concurrency:
group: release
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.release.prerelease == false
permissions:
contents: read
id-token: write
env:
TAG: ${{ github.event.release.tag_name }}
steps:
- uses: actions/checkout@v7
with:
ref: refs/tags/${{ github.event.release.tag_name }}
persist-credentials: false
fetch-depth: 0
- name: Check the tag is on main
run: |
git fetch --no-tags origin main
if ! git merge-base --is-ancestor HEAD FETCH_HEAD; then
echo "::error::Tag $TAG is not on main. Release from a commit on main."
exit 1
fi
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
# No dependency cache in a publishing job (cache poisoning).
package-manager-cache: false
- name: Ensure npm supports staged publishing (>= 11.15.0)
run: |
if ! node -e 'const [a,b]=process.argv[1].split(".").map(Number);process.exit(a>11||(a===11&&b>=15)?0:1)' "$(npm --version)"; then
npm install -g npm@^11.15.0
fi
npm --version
- name: Check package.json version matches the tag
id: version
run: |
NAME=$(node -p 'require("./package.json").name')
VERSION=$(node -p 'require("./package.json").version')
if [ "$VERSION" != "${TAG#v}" ]; then
echo "::error::package.json version is $VERSION but the release tag is $TAG. Bump package.json and tag that commit."
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check whether this version is already on npm
id: npm
env:
NAME: ${{ steps.version.outputs.name }}
VERSION: ${{ steps.version.outputs.version }}
run: |
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/$NAME/$VERSION")
case "$STATUS" in
200)
echo "::notice::$NAME@$VERSION is already on npm. Skipping publish."
echo "published=true" >> "$GITHUB_OUTPUT" ;;
404)
echo "published=false" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::Unexpected HTTP $STATUS from the npm registry."
exit 1 ;;
esac
- name: Install dependencies
if: steps.npm.outputs.published == 'false'
run: npm ci
# Builds dist/ and templates/, checks every export exists, and lists the tarball.
# `npm stage publish` builds again through the prepack script.
- name: Run checks
if: steps.npm.outputs.published == 'false'
run: npm run check
# Short-lived OIDC tokens cannot run `npm stage list`, so an already-staged version
# is detected from the registry's conflict error instead: staged and published
# versions share one version index, so re-staging the same version is refused.
- name: Stage on npm
if: steps.npm.outputs.published == 'false'
run: |
set +e
npm stage publish --provenance 2>&1 | tee "$RUNNER_TEMP/stage.log"
STATUS=${PIPESTATUS[0]}
set -e
if [ "$STATUS" -eq 0 ]; then
echo "::notice::Staged. Approve it on npmjs.com (Staged Packages tab) or with npm stage approve, using 2FA. The site deploys once it is live."
elif grep -Eqi 'E409|EPUBLISHCONFLICT|previously published|already (been )?(staged|published|exists)|cannot (publish|stage) over' "$RUNNER_TEMP/stage.log"; then
echo "::notice::This version is already staged or published on npm. Skipping."
else
exit "$STATUS"
fi
deploy-site:
runs-on: ubuntu-latest
needs: publish
if: >-
always() &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped') &&
((github.event_name == 'release' && github.event.release.prerelease == false) ||
(github.event_name == 'workflow_dispatch' && inputs.mode != 'announce'))
# GitHub-hosted jobs stop at 6 hours. The wait below gives up before that.
timeout-minutes: 355
permissions:
contents: write
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Check the tag is on main
id: tag
run: |
SHA=$(git rev-parse --verify "refs/tags/$TAG^{commit}")
if ! git merge-base --is-ancestor "$SHA" origin/main; then
echo "::error::Tag $TAG is not on main. Only tags on main can go live."
exit 1
fi
{
echo "sha=$SHA"
echo "name=$(git show "$SHA:package.json" | jq -r .name)"
echo "version=$(git show "$SHA:package.json" | jq -r .version)"
} >> "$GITHUB_OUTPUT"
- name: Wait until the version is live on npm
env:
NAME: ${{ steps.tag.outputs.name }}
VERSION: ${{ steps.tag.outputs.version }}
# Manual runs do not wait: the version must already be on npm.
MAX_WAIT: ${{ github.event_name == 'release' && '21000' || '0' }}
run: |
while :; do
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/$NAME/$VERSION" || echo 000)
if [ "$STATUS" = "200" ]; then
echo "$NAME@$VERSION is live on npm."
exit 0
fi
if [ "$SECONDS" -ge "$MAX_WAIT" ]; then
if [ "$MAX_WAIT" = "0" ]; then
echo "::error::$NAME@$VERSION is not on npm (HTTP $STATUS). A manual run only deploys versions that are already published."
else
echo "::error::$NAME@$VERSION is still not live on npm after $((MAX_WAIT / 60)) minutes. Approve the staged package on npmjs.com (Staged Packages tab), then re-run this deploy-site job (Re-run failed jobs). If you rejected it, nothing needs to happen: the site was not deployed."
fi
exit 1
fi
echo "Waiting for $NAME@$VERSION on npm (HTTP $STATUS). Approve the staged package on npmjs.com. Checking again in 30 seconds."
sleep 30
done
- name: Point release branch at tag
env:
SHA: ${{ steps.tag.outputs.sha }}
run: git push --force origin "$SHA:refs/heads/release"
# Same message as the old discord-release.yml.
announce:
runs-on: ubuntu-latest
needs: deploy-site
if: >-
always() &&
((needs.deploy-site.result == 'success' &&
((github.event_name == 'release' && github.event.release.prerelease == false) ||
(github.event_name == 'workflow_dispatch' && inputs.mode == 'deploy and announce'))) ||
(github.event_name == 'workflow_dispatch' && inputs.mode == 'announce'))
permissions:
contents: read
steps:
- name: Send Discord announcement
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
RELEASE_TAG_INPUT: ${{ inputs.tag }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
test -n "$DISCORD_WEBHOOK_URL"
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
test -n "$RELEASE_TAG_INPUT"
curl --fail-with-body \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$REPO/releases/tags/$RELEASE_TAG_INPUT" \
> release.json
else
jq '.release' "$GITHUB_EVENT_PATH" > release.json
fi
if [ "$(jq -r '.prerelease' release.json)" = "true" ]; then
echo "Skipping prerelease."
exit 0
fi
jq -n \
--arg repo "$REPO" \
--arg name "$(jq -r '.name // ""' release.json)" \
--arg tag "$(jq -r '.tag_name' release.json)" \
--arg url "$(jq -r '.html_url' release.json)" \
--arg body "$(jq -r '.body // ""' release.json)" \
'($body | if length > 1500 then .[:1500] + "...\n\n[Read more](" + $url + ")" else . end) as $description |
{
username: "Basecoat Releases",
allowed_mentions: { parse: [] },
embeds: [{
title: ($repo + " " + $tag),
url: $url,
description: ((if $name != "" then "**" + $name + "**\n\n" else "" end) + $description),
color: 5793266
}]
}' > payload.json
curl --fail-with-body \
--retry 3 \
--retry-all-errors \
--retry-delay 2 \
-H "Content-Type: application/json" \
-X POST \
-d @payload.json \
"$DISCORD_WEBHOOK_URL"