Skip to content

Commit 2535977

Browse files
feat(actions): Add GitHub Packages publishing support
Introduces two new composite actions for publishing to GitHub Packages (`publish-github-package` for single packages, `publish-github-packages` for monorepos) and wires them into the existing release workflows via a new `publish-github-packages` input flag. Also explicitly passes `--registry https://registry.npmjs.org` to all existing npm publish commands to prevent scope-based registry remapping from redirecting publishes to GitHub Packages unintentionally.
1 parent 418aeb5 commit 2535977

7 files changed

Lines changed: 444 additions & 25 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
name: 'Publish to GitHub Packages'
2+
description: 'Build and publish a single npm package to the GitHub Packages registry'
3+
4+
inputs:
5+
bun-version:
6+
description: '[string] Bun version to use'
7+
required: false
8+
default: 'latest'
9+
node-version:
10+
description: '[string] Node.js version'
11+
required: false
12+
default: '24'
13+
working-directory:
14+
description: '[string] Directory containing package.json'
15+
required: false
16+
default: '.'
17+
build-command:
18+
description: '[string] Build command'
19+
required: false
20+
default: 'bun run build'
21+
tag:
22+
description: "[string] Package tag (e.g., 'latest', 'beta')"
23+
required: false
24+
default: 'latest'
25+
dry-run:
26+
description: '[boolean] Run in dry-run mode (no actual publishing)'
27+
required: false
28+
default: 'false'
29+
publish-command:
30+
description: "[string] Command used to publish (e.g., 'npm publish', 'bunx clean-publish'). The --registry, --tag, and --dry-run flags are appended automatically."
31+
required: false
32+
default: 'npm publish'
33+
github-token:
34+
description: '[secret] GitHub token with packages: write for the repository that owns the package'
35+
required: true
36+
37+
runs:
38+
using: 'composite'
39+
steps:
40+
- uses: oven-sh/setup-bun@v2
41+
with:
42+
bun-version: ${{ inputs.bun-version }}
43+
44+
# Install and build before mapping the repository owner's npm scope to
45+
# GitHub Packages. This keeps ordinary npmjs.org dependencies resolvable
46+
# when a package is being published to both registries for the first time.
47+
- name: Install dependencies
48+
shell: bash
49+
working-directory: ${{ inputs.working-directory }}
50+
run: bun install
51+
52+
- name: Build
53+
shell: bash
54+
working-directory: ${{ inputs.working-directory }}
55+
run: ${{ inputs.build-command }}
56+
57+
- uses: actions/setup-node@v4
58+
with:
59+
node-version: ${{ inputs.node-version }}
60+
registry-url: 'https://npm.pkg.github.com'
61+
scope: ${{ format('@{0}', github.repository_owner) }}
62+
63+
- name: Publish to GitHub Packages
64+
shell: bash
65+
working-directory: ${{ inputs.working-directory }}
66+
env:
67+
NODE_AUTH_TOKEN: ${{ inputs.github-token }}
68+
PACKAGE_OWNER: ${{ github.repository_owner }}
69+
REGISTRY_URL: 'https://npm.pkg.github.com'
70+
TAG: ${{ inputs.tag }}
71+
DRY_RUN: ${{ inputs.dry-run }}
72+
run: |
73+
set -euo pipefail
74+
75+
NAME=$(jq -r .name package.json)
76+
VERSION=$(jq -r .version package.json)
77+
if [ -z "$NAME" ] || [ "$NAME" = "null" ] || [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
78+
echo "❌ Could not read name/version from package.json."
79+
exit 1
80+
fi
81+
82+
NAME_LOWER=$(printf '%s' "$NAME" | tr '[:upper:]' '[:lower:]')
83+
EXPECTED_PREFIX=$(printf '@%s/' "$PACKAGE_OWNER" | tr '[:upper:]' '[:lower:]')
84+
case "$NAME_LOWER" in
85+
"$EXPECTED_PREFIX"*) ;;
86+
*)
87+
echo "❌ GitHub Packages requires the package name to use the repository owner's scope."
88+
echo " Expected @${PACKAGE_OWNER}/<package>, found: $NAME"
89+
exit 1
90+
;;
91+
esac
92+
93+
DRYRUN_FLAG=""
94+
if [ "$DRY_RUN" = "true" ]; then
95+
DRYRUN_FLAG="--dry-run"
96+
echo "🧪 Dry run mode enabled."
97+
elif [ -z "$NODE_AUTH_TOKEN" ]; then
98+
echo "❌ No GitHub token was provided. Grant the caller 'packages: write'."
99+
exit 1
100+
fi
101+
102+
echo "Publishing $NAME@$VERSION to GitHub Packages with tag $TAG..."
103+
LOG=$(mktemp)
104+
trap 'rm -f "$LOG"' EXIT
105+
106+
set +e
107+
${{ inputs.publish-command }} --registry "$REGISTRY_URL" --tag "$TAG" $DRYRUN_FLAG 2>&1 | tee "$LOG"
108+
PUBLISH_EXIT=${PIPESTATUS[0]}
109+
set -e
110+
111+
if [ "$PUBLISH_EXIT" -ne 0 ]; then
112+
echo "❌ GitHub Packages publish exited non-zero ($PUBLISH_EXIT)."
113+
exit "$PUBLISH_EXIT"
114+
fi
115+
116+
if grep -qE '^npm (error|ERR!)' "$LOG"; then
117+
echo "❌ Publisher reported success but npm emitted error lines in its output."
118+
exit 1
119+
fi
120+
121+
if [ "$DRY_RUN" = "true" ]; then
122+
echo "✅ GitHub Packages dry run completed."
123+
exit 0
124+
fi
125+
126+
for attempt in 1 2 3 4 5 6; do
127+
PUBLISHED_VERSION=$(npm view "$NAME@$VERSION" version --registry "$REGISTRY_URL" 2>/dev/null || true)
128+
if [ "$PUBLISHED_VERSION" = "$VERSION" ]; then
129+
echo "✅ $NAME@$VERSION is live on GitHub Packages."
130+
exit 0
131+
fi
132+
echo "Attempt $attempt: package version is not visible yet — retrying in 5s..."
133+
sleep 5
134+
done
135+
136+
echo "❌ Publish verification failed: $NAME@$VERSION is not visible on GitHub Packages."
137+
echo "Confirm the caller grants packages: write and the package scope matches @${PACKAGE_OWNER}."
138+
exit 1
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
name: 'Publish GitHub Packages'
2+
description: 'Build and publish multiple monorepo packages to GitHub Packages in dependency order'
3+
4+
inputs:
5+
changed-packages:
6+
description: '[string] JSON array of packages in topological order (e.g. [{"name":"@owner/pkg","path":"packages/pkg"}])'
7+
required: true
8+
bun-version:
9+
description: '[string] Bun version to use'
10+
required: false
11+
default: 'latest'
12+
node-version:
13+
description: '[string] Node.js version'
14+
required: false
15+
default: '24'
16+
tag:
17+
description: "[string] Package tag (e.g., 'latest', 'beta')"
18+
required: false
19+
default: 'latest'
20+
build-command:
21+
description: '[string] Build command to run in each package directory (ignored if root-build-command is set)'
22+
required: false
23+
default: 'bun run build'
24+
root-build-command:
25+
description: '[string] Build command to run once at repo root (e.g., "turbo build"). Takes precedence over per-package build-command.'
26+
required: false
27+
default: ''
28+
dry-run:
29+
description: "[boolean: 'true'/'false'] Run in dry-run mode (no actual publishing)"
30+
required: false
31+
default: 'false'
32+
github-token:
33+
description: '[secret] GitHub token with packages: write for the repository that owns the packages'
34+
required: true
35+
36+
runs:
37+
using: 'composite'
38+
steps:
39+
- uses: oven-sh/setup-bun@v2
40+
with:
41+
bun-version: ${{ inputs.bun-version }}
42+
43+
# Keep dependency installation on its existing registry configuration. The
44+
# GitHub Packages scope mapping is added only after dependencies are ready.
45+
- name: Install dependencies
46+
shell: bash
47+
run: bun install
48+
49+
- name: Build (root)
50+
if: ${{ inputs.root-build-command != '' }}
51+
shell: bash
52+
run: ${{ inputs.root-build-command }}
53+
54+
- uses: actions/setup-node@v4
55+
with:
56+
node-version: ${{ inputs.node-version }}
57+
registry-url: 'https://npm.pkg.github.com'
58+
scope: ${{ format('@{0}', github.repository_owner) }}
59+
60+
- name: Build and publish packages
61+
shell: bash
62+
env:
63+
CHANGED_JSON: ${{ inputs.changed-packages }}
64+
BUILD_CMD: ${{ inputs.build-command }}
65+
ROOT_BUILD_CMD: ${{ inputs.root-build-command }}
66+
TAG: ${{ inputs.tag }}
67+
DRY_RUN: ${{ inputs.dry-run }}
68+
NODE_AUTH_TOKEN: ${{ inputs.github-token }}
69+
PACKAGE_OWNER: ${{ github.repository_owner }}
70+
REGISTRY_URL: 'https://npm.pkg.github.com'
71+
run: |
72+
set -euo pipefail
73+
74+
if [ "$DRY_RUN" != "true" ] && [ -z "$NODE_AUTH_TOKEN" ]; then
75+
echo "❌ No GitHub token was provided. Grant the caller 'packages: write'."
76+
exit 1
77+
fi
78+
79+
PKG_COUNT=$(echo "$CHANGED_JSON" | jq 'length')
80+
EXPECTED_PREFIX=$(printf '@%s/' "$PACKAGE_OWNER" | tr '[:upper:]' '[:lower:]')
81+
82+
for i in $(seq 0 $(( PKG_COUNT - 1 ))); do
83+
PKG_PATH=$(echo "$CHANGED_JSON" | jq -r ".[$i].path")
84+
NAME=$(jq -r .name "$PKG_PATH/package.json")
85+
VERSION=$(jq -r .version "$PKG_PATH/package.json")
86+
87+
if [ -z "$NAME" ] || [ "$NAME" = "null" ] || [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
88+
echo "❌ Could not read name/version from $PKG_PATH/package.json."
89+
exit 1
90+
fi
91+
92+
NAME_LOWER=$(printf '%s' "$NAME" | tr '[:upper:]' '[:lower:]')
93+
case "$NAME_LOWER" in
94+
"$EXPECTED_PREFIX"*) ;;
95+
*)
96+
echo "❌ GitHub Packages requires every package name to use the repository owner's scope."
97+
echo " Expected @${PACKAGE_OWNER}/<package>, found: $NAME"
98+
exit 1
99+
;;
100+
esac
101+
102+
echo "========================================="
103+
echo "Publishing $NAME@$VERSION ($PKG_PATH) to GitHub Packages"
104+
echo "========================================="
105+
106+
if [ -z "$ROOT_BUILD_CMD" ]; then
107+
echo "Building $NAME..."
108+
(cd "$PKG_PATH" && $BUILD_CMD)
109+
fi
110+
111+
if [ "$DRY_RUN" = "true" ]; then
112+
echo "Dry run: would publish $NAME with tag $TAG"
113+
(cd "$PKG_PATH" && bun publish --registry "$REGISTRY_URL" --tag "$TAG" --dry-run)
114+
continue
115+
fi
116+
117+
echo "Publishing $NAME with tag $TAG"
118+
(cd "$PKG_PATH" && bun publish --registry "$REGISTRY_URL" --tag "$TAG")
119+
120+
PUBLISHED=false
121+
for attempt in 1 2 3 4 5 6; do
122+
PUBLISHED_VERSION=$(npm view "$NAME@$VERSION" version --registry "$REGISTRY_URL" 2>/dev/null || true)
123+
if [ "$PUBLISHED_VERSION" = "$VERSION" ]; then
124+
echo "✅ $NAME@$VERSION is live on GitHub Packages."
125+
PUBLISHED=true
126+
break
127+
fi
128+
echo "Attempt $attempt: package version is not visible yet — retrying in 5s..."
129+
sleep 5
130+
done
131+
132+
if [ "$PUBLISHED" != "true" ]; then
133+
echo "❌ Publish verification failed: $NAME@$VERSION is not visible on GitHub Packages."
134+
exit 1
135+
fi
136+
137+
echo ""
138+
done
139+
140+
echo "All GitHub Packages published successfully"

.github/blocks/publish-npm-packages/action.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ runs:
8383
8484
if [ "$DRY_RUN" = "true" ]; then
8585
echo "Dry run: would publish $NAME with tag $TAG"
86-
(cd "$PKG_PATH" && bun publish --tag "$TAG" --access public --dry-run)
86+
(cd "$PKG_PATH" && bun publish --registry https://registry.npmjs.org --tag "$TAG" --access public --dry-run)
8787
else
8888
echo "Publishing $NAME with tag $TAG"
89-
(cd "$PKG_PATH" && bun publish --tag "$TAG" --access public)
89+
(cd "$PKG_PATH" && bun publish --registry https://registry.npmjs.org --tag "$TAG" --access public)
9090
fi
9191
9292
echo ""

.github/blocks/publish-npm/action.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ inputs:
2727
required: false
2828
default: 'false'
2929
publish-command:
30-
description: "[string] Command used to publish (e.g., 'npm publish', 'bunx clean-publish'). The --tag, --access, and --dry-run flags are appended automatically."
30+
description: "[string] Command used to publish (e.g., 'npm publish', 'bunx clean-publish'). The --registry, --tag, --access, and --dry-run flags are appended automatically."
3131
required: false
3232
default: 'npm publish'
3333
npm-token:
@@ -96,7 +96,7 @@ runs:
9696
# from killing the script before we can branch on it.
9797
LOG="$(mktemp)"
9898
set +e
99-
${{ inputs.publish-command }} --tag ${{ inputs.tag }} --access public --provenance $DRYRUN_FLAG 2>&1 | tee "$LOG"
99+
${{ inputs.publish-command }} --registry https://registry.npmjs.org --tag ${{ inputs.tag }} --access public --provenance $DRYRUN_FLAG 2>&1 | tee "$LOG"
100100
PUBLISH_EXIT=${PIPESTATUS[0]}
101101
set -e
102102
@@ -137,7 +137,7 @@ runs:
137137
# would fail packaging validation for no reason.
138138
if [ "${{ inputs.dry-run }}" == "true" ]; then
139139
echo "Dry run mode - would publish with tag: ${{ inputs.tag }}"
140-
${{ inputs.publish-command }} --tag ${{ inputs.tag }} --access public --dry-run
140+
${{ inputs.publish-command }} --registry https://registry.npmjs.org --tag ${{ inputs.tag }} --access public --dry-run
141141
exit 0
142142
fi
143143
@@ -155,7 +155,7 @@ runs:
155155
# catches the wrapper-swallowed-exit case that broke spectrum-ts.
156156
LOG="$(mktemp)"
157157
set +e
158-
${{ inputs.publish-command }} --tag ${{ inputs.tag }} --access public 2>&1 | tee "$LOG"
158+
${{ inputs.publish-command }} --registry https://registry.npmjs.org --tag ${{ inputs.tag }} --access public 2>&1 | tee "$LOG"
159159
PUBLISH_EXIT=${PIPESTATUS[0]}
160160
set -e
161161

.github/workflows/typescript-monorepo-release.yaml

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ on:
2020
type: string
2121
required: false
2222
default: "latest"
23-
description: "NPM tag to publish with"
23+
description: "Package tag to use for npmjs.org and GitHub Packages"
24+
publish-github-packages:
25+
type: boolean
26+
required: false
27+
default: false
28+
description: "Also publish changed packages to the GitHub Packages npm registry. Package names must use the repository owner's scope; caller must grant packages: write."
2429
build-command:
2530
type: string
2631
required: false
@@ -50,7 +55,7 @@ on:
5055
type: boolean
5156
required: false
5257
default: false
53-
description: "Force release - does both GitHub release and npm publish"
58+
description: "Force release - creates the GitHub Release and runs enabled package publishes"
5459
dry-run:
5560
type: boolean
5661
required: false
@@ -201,3 +206,27 @@ jobs:
201206
root-build-command: ${{ inputs.root-build-command }}
202207
dry-run: ${{ inputs.dry-run }}
203208
npm-token: ${{ secrets.NPM_TOKEN }}
209+
210+
github-packages-publish:
211+
needs: [check-labels, detect-changes, bump-versions]
212+
runs-on: ${{ inputs.use-blacksmith && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
213+
if: inputs.publish-github-packages == true
214+
permissions:
215+
contents: read
216+
packages: write
217+
steps:
218+
- uses: actions/checkout@v5
219+
with:
220+
ref: ${{ github.ref_name }}
221+
fetch-depth: 0
222+
223+
- name: Publish GitHub Packages
224+
uses: photon-hq/buildspace/.github/blocks/publish-github-packages@main
225+
with:
226+
changed-packages: ${{ needs.detect-changes.outputs.changed }}
227+
bun-version: ${{ inputs.bun-version }}
228+
tag: ${{ (inputs.prerelease || fromJSON(needs.check-labels.outputs.labels).prerelease) && 'beta' || inputs.npm-tag }}
229+
build-command: ${{ inputs.build-command }}
230+
root-build-command: ${{ inputs.root-build-command }}
231+
dry-run: ${{ inputs.dry-run }}
232+
github-token: ${{ github.token }}

0 commit comments

Comments
 (0)