node: opt-in publishing on release tags (Gitea npm registry, Gitea release) #6

Merged
james.maes merged 1 commits from ci/node-publish into main 2026-11-08 13:26:05 +00:00
2 changed files with 203 additions and 6 deletions

No files matched your search

+148 -2
View File
@@ -1,5 +1,11 @@
# Shared Node.js CI: install the locked dependencies and run the repository's own gates, in order,
# in the official node:<version>-bookworm image. Verify only; nothing is published.
# in the official node:<version>-bookworm image.
#
# Verify only, unless the caller sets `publish: true`. Then a release tag (vX.Y.Z or vX.Y.Z-<pre>, e.g.
# v1.2.0-RC.1) whose version equals `version-file` also publishes, after every command passed:
# npm-publish-dirs / publish-commands -> the owner's Gitea npm registry (prerelease -> dist-tag next)
# release-files -> a Gitea release with SHA256SUMS and the CHANGELOG section as notes
# Branches and other tags never publish.
name: node
on:
@@ -28,9 +34,33 @@ on:
description: Directory the install and commands run in
type: string
default: '.'
publish:
description: On a vX.Y.Z[-pre] tag, publish (npm and/or a Gitea release) after the commands pass
type: boolean
default: false
version-file:
description: package.json whose version the release tag must equal (v<version>)
type: string
default: package.json
npm-publish-dirs:
description: Package directories to npm publish, one per line, in order; empty = no npm publish
type: string
default: '.'
publish-commands:
description: Shell commands that publish instead of the npm-publish-dirs loop (registry and dist-tag preconfigured)
type: string
default: ''
release-files:
description: Files or globs, one per line, attached to a Gitea release with SHA256SUMS; empty = no release
type: string
default: ''
secrets:
PACKAGES_TOKEN:
description: Package-write token for npm publishing (callers pass secrets inherit)
required: false
permissions:
contents: read
contents: write
env:
UV_VERSION: 0.13.6
@@ -57,6 +87,32 @@ jobs:
git config --global --add safe.directory "$GITHUB_WORKSPACE"
echo "node $(node --version), npm $(npm --version)"
- name: Decide verify or release
id: plan
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
PUBLISH: ${{ inputs.publish }}
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
VERSION_FILE: ${{ inputs.version-file }}
run: |
set -euo pipefail
if [[ $PUBLISH != true || $REF_TYPE != tag ]]; then
echo "release=false" >> "$GITHUB_OUTPUT"; echo "Build kind: verify ($REF_TYPE $REF_NAME)"; exit 0
fi
if [[ ! $REF_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "release=false" >> "$GITHUB_OUTPUT"; echo "Build kind: verify (tag $REF_NAME is not a release tag)"; exit 0
fi
version=$(node -p 'require(require("path").resolve(process.argv[1])).version' "$VERSION_FILE")
if [[ "v$version" != "$REF_NAME" ]]; then
echo "Tag $REF_NAME does not match $VERSION_FILE version $version"; exit 1
fi
dist_tag=latest
if [[ $version == *-* ]]; then dist_tag=next; fi
{ echo "release=true"; echo "version=$version"; echo "dist_tag=$dist_tag"; } >> "$GITHUB_OUTPUT"
echo "Build kind: release $version (npm dist-tag $dist_tag)"
- name: Install uv
if: inputs.uv
shell: bash
@@ -83,3 +139,93 @@ jobs:
run: |
printf '%s\n' "$COMMANDS" > /tmp/commands.sh
bash -exo pipefail /tmp/commands.sh
- name: Publish to the Gitea npm registry
if: steps.plan.outputs.release == 'true' && (inputs.npm-publish-dirs != '' || inputs.publish-commands != '')
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
OWNER: ${{ github.repository_owner }}
DIRS: ${{ inputs.npm-publish-dirs }}
PUBLISH_COMMANDS: ${{ inputs.publish-commands }}
DIST_TAG: ${{ steps.plan.outputs.dist_tag }}
run: |
set -euo pipefail
if [[ -z ${PACKAGES_TOKEN:-} ]]; then echo "PACKAGES_TOKEN is empty: the caller needs secrets: inherit"; exit 1; fi
registry="$GITHUB_SERVER_URL/api/packages/$OWNER/npm/"
# A throwaway user config; the token stays in the environment, npm expands it.
export NPM_CONFIG_USERCONFIG=/tmp/publish.npmrc
printf '//%s:_authToken=${PACKAGES_TOKEN}\n' "${registry#*://}" > "$NPM_CONFIG_USERCONFIG"
export NPM_CONFIG_REGISTRY="$registry" NPM_CONFIG_TAG="$DIST_TAG"
if [[ -n $PUBLISH_COMMANDS ]]; then
printf '%s\n' "$PUBLISH_COMMANDS" > /tmp/publish.sh
bash -exo pipefail /tmp/publish.sh
exit 0
fi
while read -r dir; do
[[ -z $dir ]] && continue
(
cd "$dir"
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
# A publishConfig.registry (npmjs, GitHub) would override the registry; it is not committed back.
npm pkg delete publishConfig.registry
scope=()
if [[ $name == @*/* ]]; then scope=("--${name%%/*}:registry=$registry"); fi
echo "Publishing $name@$version to $registry (dist-tag $DIST_TAG)"
npm publish --registry "$registry" "${scope[@]}" --tag "$DIST_TAG"
echo "Published: $(npm view "$name@$version" version --registry "$registry" "${scope[@]}")"
)
done <<< "$DIRS"
- name: Gitea release
if: steps.plan.outputs.release == 'true' && inputs.release-files != ''
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
TOKEN: ${{ github.token }}
FILES: ${{ inputs.release-files }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.plan.outputs.version }}
run: |
set -euo pipefail
out=/tmp/release && rm -rf "$out" && mkdir -p "$out"
while read -r pattern; do
[[ -z $pattern ]] && continue
matches=($pattern)
[[ -e ${matches[0]} ]] || { echo "No file matches $pattern"; exit 1; }
for f in "${matches[@]}"; do
[[ -f $f ]] || continue
base=$(basename "$f")
if [[ $base == SHA256SUMS ]]; then (cd "$(dirname "$f")" && sha256sum --check --strict SHA256SUMS); continue; fi
[[ ! -e $out/$base ]] || { echo "Two release files are named $base"; exit 1; }
cp "$f" "$out/"
done
done <<< "$FILES"
(cd "$out" && files=(*) && sha256sum "${files[@]}" > SHA256SUMS && cat SHA256SUMS)
# Notes: the CHANGELOG.md section whose "## " heading names this version.
notes=$(awk -v v="$VERSION" '
/^## / { if (found) exit; h = $0; gsub(/[][]/, " ", h); n = split(h, w, /[ \t]+/)
for (i = 1; i <= n; i++) if (w[i] == v || w[i] == "v" v) { found = 1; next } }
found { print }' CHANGELOG.md 2>/dev/null || true)
[[ -n ${notes//[[:space:]]/} ]] || notes="Release $TAG."
prerelease=false
if [[ $VERSION == *-* ]]; then prerelease=true; fi
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth="Authorization: token $TOKEN"
if curl -fsS -o /dev/null -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null; then
echo "A release for $TAG already exists; a version is released once"; exit 1
fi
body=$(NOTES="$notes" PRE="$prerelease" node -e 'process.stdout.write(JSON.stringify({
tag_name: process.env.TAG, name: process.env.TAG, body: process.env.NOTES,
draft: false, prerelease: process.env.PRE === "true" }))')
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" |
node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(0, "utf8")).id))')
for f in "$out"/*; do
curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$(basename "$f")"
echo "Attached $(basename "$f")"
done
echo "Released: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG"
+55 -4
View File
@@ -66,9 +66,59 @@ jobs:
| `uv` | `false` | Install a pinned, sha256-verified `uv` for Python script checks |
| `working-directory` | `.` | Where the install and commands run |
| `runner` | `container-build` | Runner label |
| `publish` | `false` | On a release tag, publish after every command passed (below) |
| `version-file` | `package.json` | The release tag must be `v<version>` of this file, or the build fails |
| `npm-publish-dirs` | `.` | Package directories to `npm publish`, one per line, in order; empty = none |
| `publish-commands` | empty | Commands that publish instead of the `npm-publish-dirs` loop |
| `release-files` | empty | Files or globs, one per line, for a Gitea release; empty = no release |
Verify only: it publishes nothing. npm, binary and release publishing are a
separate, later workflow.
### Publishing
Without `publish: true` the workflow only verifies. With it, branches still only
verify; a tag `vX.Y.Z` or `vX.Y.Z-<pre>` (for example `v1.2.0-RC.1`) publishes:
1. The tag must equal `v` + the `version-file` version. Set the version in
`package.json` (`npm version 1.2.0-RC.1 --no-git-tag-version`), merge, then tag
the merge commit. A mismatch fails the build before anything is installed.
2. Install and `commands` run as on any push; a failure stops the release.
3. Each `npm-publish-dirs` package goes to the owner's Gitea npm registry,
`https://git.kof22.com/api/packages/<owner>/npm/`, with the `PACKAGES_TOKEN`
org secret (`secrets: inherit`). A prerelease version gets dist-tag `next`,
a final version `latest`. The package keeps its name and scope; a
`publishConfig.registry` (npmjs, GitHub) is ignored for the publish. A
version that already exists fails: publish a new version instead.
4. `release-files` become a Gitea release for the tag (a prerelease for
`-<pre>` versions) with a fresh `SHA256SUMS`, and the `CHANGELOG.md` section
whose `## ` heading names the version as its notes. A `SHA256SUMS` among the
files is checked, then replaced. An existing release for the tag fails.
```yaml
jobs:
node:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
secrets: inherit
with:
commands: |
npm test
npm run build
publish: true
# npm-publish-dirs: | # workspaces, in dependency order
# packages/client
# packages/cli
# version-file: packages/cli/package.json
# release-files: dist/* # with npm-publish-dirs: '' for a release only
```
To consume a package:
```ini
# .npmrc
@scope:registry=https://git.kof22.com/api/packages/QRun-IO/npm/
//git.kof22.com/api/packages/QRun-IO/npm/:_authToken=${GITEA_TOKEN}
```
Binaries for macOS or Windows need native runners; repositories that ship
them (for example `QRun-IO/qqq-cli`) keep their own release workflow.
## Releasing
@@ -101,7 +151,8 @@ gets `v2` and a rollout, not a moved `v1`.
is never published twice.
- The `release:rc` PR label flow is gone. Cut a release candidate by pushing
`release/X.Y`.
- RC and hotfix publishes create a `v<version>` git tag. No GitHub or Gitea
release objects are created; tags appear on the repository's Releases page.
- RC and hotfix publishes create a `v<version>` git tag. Maven builds create no
GitHub or Gitea release objects; tags appear on the repository's Releases page.
Node builds create a Gitea release only when `release-files` is set.
- Not ported yet: security scans (gitleaks, Semgrep, OWASP, CycloneDX),
SpotBugs/PMD, the QQQ middleware API-version check and frontend packaging.