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
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@openchoreo/backstage-portal-backend"
]
],
"access": "restricted",
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["app", "backend"]
Expand Down
5 changes: 5 additions & 0 deletions .changeset/publish-to-public-npm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openchoreo/backstage-plugin': patch
---

Publish `@openchoreo/*` to the public npm registry instead of GitHub Packages. Installing the plugins no longer requires a GitHub personal access token or any registry configuration. Releases are published from CI via npm trusted publishing (OIDC), so every version from this release onward carries a signed provenance attestation.
240 changes: 200 additions & 40 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,49 +13,77 @@ env:
IMAGE_NAME: openchoreo/openchoreo-ui

jobs:
release:
name: Release
# Build and publish are deliberately separate jobs.
#
# `yarn install` executes lifecycle scripts from the full transitive
# dependency tree (this repo needs them — see enableScripts in .yarnrc.yml).
# If that ran in the job holding `id-token: write`, any compromised
# dependency could mint an npm publish credential from the OIDC endpoint and
# ship arbitrary code as @openchoreo. The `npm-publish` environment approval
# does not help: it gates job start, so scripts still run after approval.
#
# So: `build` installs and compiles with no OIDC access and emits tarballs;
# `publish-npm` holds the OIDC token but never installs dependencies.
build:
name: Build packages
runs-on: ubuntu-24.04
permissions:
packages: write
contents: read
outputs:
release-tag: ${{ steps.release-vars.outputs.release-tag }}
git-sha-short: ${{ steps.release-vars.outputs.git-sha-short }}
is-prerelease: ${{ steps.release-vars.outputs.is-prerelease }}
latest: ${{ steps.release-vars.outputs.latest }}
dist-tag: ${{ steps.release-vars.outputs.dist-tag }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set commit SHA and release tag
- name: Resolve release variables
id: release-vars
run: |
echo "GIT_SHA_SHORT=$(git rev-parse --short=8 HEAD)" >> $GITHUB_ENV
git fetch --tags --force
GIT_SHA_SHORT=$(git rev-parse --short=8 HEAD)
RELEASE_TAG=${GITHUB_REF##*/}
echo "RELEASE_TAG=${RELEASE_TAG}" >> $GITHUB_ENV

# Detect prerelease versions (SemVer tags containing a hyphen, e.g., v1.0.0-rc.1)
if [[ "${RELEASE_TAG}" == *-* ]]; then
echo "IS_PRERELEASE=true" >> $GITHUB_ENV
IS_PRERELEASE=true
else
echo "IS_PRERELEASE=false" >> $GITHUB_ENV
IS_PRERELEASE=false
fi

- name: Set latest flag
run: |
git fetch --tags --force
latest_tag=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1)
if [ "$latest_tag" == "${RELEASE_TAG}" ]; then
echo "LATEST=true" >> $GITHUB_ENV
LATEST=true
else
echo "LATEST=false" >> $GITHUB_ENV
LATEST=false
fi

# npm publish runs before any Docker retag so a publish failure aborts
# the release before GHCR is mutated. The reverse ordering would leave
# the image retagged to vX.Y.Z (and possibly `latest`) without the
# corresponding @openchoreo/* packages on the npm registry.
# Dist-tag scheme: `latest` only for the highest stable version,
# `next` for prereleases, `release-X.Y` for back-line patches so a
# re-release of an older line never steals `latest` from a newer one.
DIST_TAG=latest
if [ "${IS_PRERELEASE}" = "true" ]; then
DIST_TAG=next
elif [ "${LATEST}" != "true" ]; then
VERSION=${RELEASE_TAG#v}
DIST_TAG="release-${VERSION%.*}"
fi

{
echo "release-tag=${RELEASE_TAG}"
echo "git-sha-short=${GIT_SHA_SHORT}"
echo "is-prerelease=${IS_PRERELEASE}"
echo "latest=${LATEST}"
echo "dist-tag=${DIST_TAG}"
} >> $GITHUB_OUTPUT

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: 'yarn'
# Intentionally no `registry-url:` — Yarn Berry ignores the .npmrc
# this would write. Auth is wired via YARN_NPM_AUTH_TOKEN below.

- name: Install dependencies
run: yarn install --immutable
Expand All @@ -68,32 +96,164 @@ jobs:
run: yarn tsc

- name: Build all workspaces
# `yarn npm publish` runs each workspace's `prepack` (which invokes
# `backstage-cli package prepack` to populate `dist/` entry points)
# and rewrites `workspace:^` deps to concrete versions at pack time.
# `npm publish` (and `changeset publish`, which shells out to it for
# non-pnpm repos) leaks `workspace:^` strings into the tarball, so we
# use Yarn Berry's native publish instead. Build first so `dist/`
# exists before prepack reads it.
run: yarn build:all

- name: Publish to GitHub Packages
# Dist-tag scheme: `latest` only for the highest stable version,
# `next` for prereleases, `release-X.Y` for back-line patches so a
# re-release of an older line never steals `latest` from a newer one.
env:
YARN_NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Record topological publish order
# Dependencies must be published before dependents so a consumer
# installing mid-release never sees a package whose dependency is not
# on the registry yet. `yarn pack` loses that ordering, so capture it
# while the workspace graph is still available.
run: |
TAG=latest
if [ "${IS_PRERELEASE}" = "true" ]; then
TAG=next
elif [ "${LATEST}" != "true" ]; then
VERSION=${RELEASE_TAG#v}
TAG="release-${VERSION%.*}"
: > publish-order.txt
yarn workspaces foreach --all --no-private --topological exec \
bash -c 'printf "%s\n" "${PWD#"$GITHUB_WORKSPACE"/}" >> "$GITHUB_WORKSPACE/publish-order.txt"'
echo "Publish order:"
cat publish-order.txt

- name: Pack workspaces
# `yarn pack` runs each workspace's `prepack` and rewrites `workspace:^`
# deps to concrete versions. `npm pack` does not, and would leak
# `workspace:^` strings into the tarballs and break installs for
# external consumers. Packing here (not publishing) is what lets the
# publish job skip `yarn install` entirely.
run: yarn workspaces foreach --all --no-private --topological --verbose pack

- name: 'Verify no workspace: leaks in tarballs'
# A `workspace:` specifier reaching the registry is unrecoverable
# without a version bump, so fail the release before anything ships.
run: |
status=0
while read -r loc; do
leaks=$(tar -xzOf "${loc}/package.tgz" package/package.json |
node -e '
let s = "";
process.stdin.on("data", d => (s += d)).on("end", () => {
const m = JSON.parse(s);
const bad = [];
for (const field of ["dependencies", "peerDependencies", "optionalDependencies"]) {
for (const [k, v] of Object.entries(m[field] || {})) {
if (String(v).startsWith("workspace:")) bad.push(`${field}.${k}=${v}`);
}
}
if (bad.length) console.log(bad.join(" "));
});
')
if [ -n "$leaks" ]; then
echo "::error::${loc} leaks workspace: specifiers -> ${leaks}"
status=1
fi
done < publish-order.txt
[ "$status" -eq 0 ] && echo "No workspace: leaks found."
exit $status

- name: Upload package tarballs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-tarballs
path: |
publish-order.txt
packages/*/package.tgz
plugins/*/package.tgz
retention-days: 7
if-no-files-found: error

publish-npm:
name: Publish to npm
needs: build
runs-on: ubuntu-24.04
# Bound to a protected environment with required reviewers. npm matches the
# OIDC claim against repository + workflow filename + THIS environment name,
# so renaming it here also requires updating every package's trusted
# publisher (`npm trust github @openchoreo/<pkg> --environment ...`).
environment: npm-publish
permissions:
# Deliberately minimal. No `contents: read` — this job does not check out
# the repository; it only consumes the tarballs built above.
id-token: write
steps:
- name: Download package tarballs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-tarballs

- name: Setup Node.js
# Node 24 (LTS) bundles npm 11.19.0. Node 22 bundles npm 10.x, which
# predates OIDC support — on npm < 11.5.1 the registry treats the
# publish as anonymous and returns a misleading 404 rather than an auth
# error. Using a Node line that already ships a capable npm avoids
# installing any registry-resolved tooling inside this job: nothing
# executable is fetched from the npm registry while the OIDC token is
# reachable. Node itself comes from nodejs.org via setup-node.
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24

- name: Verify npm supports trusted publishing
# Fail loudly here rather than as a confusing 404 at publish time if a
# future Node 24.x ever ships an npm below the OIDC floor.
run: |
MIN=11.5.1
NPM_VERSION=$(npm --version)
echo "npm ${NPM_VERSION} (minimum ${MIN})"
if [ "$(printf '%s\n' "$MIN" "$NPM_VERSION" | sort -V | head -n1)" != "$MIN" ]; then
echo "::error::npm ${NPM_VERSION} is older than ${MIN}; trusted publishing would fail as a 404."
exit 1
fi
yarn workspaces foreach --all --no-private --topological --verbose \
npm publish --tolerate-republish --access public --tag "${TAG}"

- name: Publish to npm
# Auth is npm trusted publishing (OIDC) — there is deliberately no
# NPM_TOKEN here. npm exchanges the workflow's id-token for a
# short-lived registry credential and attaches a provenance attestation.
#
# No `yarn install` runs in this job, so no dependency lifecycle script
# ever executes while the OIDC endpoint is reachable.
env:
DIST_TAG: ${{ needs.build.outputs.dist-tag }}
run: |
published=0
skipped=0
while read -r loc; do
tgz="${loc}/package.tgz"
spec=$(tar -xzOf "${tgz}" package/package.json |
node -e '
let s = "";
process.stdin.on("data", d => (s += d)).on("end", () => {
const m = JSON.parse(s);
process.stdout.write(`${m.name}@${m.version}`);
});
')

# Replaces yarn's --tolerate-republish: re-running a tag after a
# partial failure must skip what already landed rather than abort.
if npm view "${spec}" version --registry https://registry.npmjs.org >/dev/null 2>&1; then
echo "skip ${spec} (already on registry)"
skipped=$((skipped + 1))
continue
fi

echo "publish ${spec} --tag ${DIST_TAG}"
npm publish "${tgz}" \
--access public \
--tag "${DIST_TAG}" \
--provenance \
--registry https://registry.npmjs.org
published=$((published + 1))
done < publish-order.txt

echo "Published ${published}, skipped ${skipped}."

retag-image:
name: Retag release image
needs: [build, publish-npm]
runs-on: ubuntu-24.04
permissions:
packages: write
env:
RELEASE_TAG: ${{ needs.build.outputs.release-tag }}
GIT_SHA_SHORT: ${{ needs.build.outputs.git-sha-short }}
IS_PRERELEASE: ${{ needs.build.outputs.is-prerelease }}
LATEST: ${{ needs.build.outputs.latest }}
steps:
- name: Login to GitHub container registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
Expand Down
2 changes: 0 additions & 2 deletions .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,3 @@
# Only enable scripts for trusted, vetted packages when absolutely necessary
# See: https://docs.npmjs.com/cli/v10/using-npm/scripts#life-cycle-scripts
ignore-scripts=true
//npm.pkg.github.com/:_authToken=${YARN_NPM_AUTH_TOKEN}
@openchoreo:registry=https://npm.pkg.github.com
Loading
Loading