Skip to content

Instantly share code, notes, and snippets.

@PatrickJS
Last active June 4, 2026 07:47
Show Gist options
  • Select an option

  • Save PatrickJS/3fa2925713fcdf75a27a505ce2cd0d80 to your computer and use it in GitHub Desktop.

Select an option

Save PatrickJS/3fa2925713fcdf75a27a505ce2cd0d80 to your computer and use it in GitHub Desktop.
GitHub-native npm preview package workflows: PR/main snapshots in GitHub Packages, stable releases to npm and GitHub Packages.

GitHub-Native npm Preview Packages

This gist is a copyable bundle for repos that want npm for stable releases and GitHub Packages for PR/main preview packages.

TL;DR

  • This is not a custom registry. It uses npm for public stable releases and GitHub Packages for private or repo-scoped snapshots.
  • Copy the workflow templates into .github/workflows/, add a scoped package name, and configure package auth.
  • Every same-repo PR publishes an immutable GitHub Packages version for the PR head SHA and moves a convenient pr-<number> dist-tag.
  • Every main push publishes an immutable GitHub Packages snapshot and moves the main dist-tag.
  • Stable releases publish the package version from package.json to npm with Trusted Publishing, then mirror the same version to GitHub Packages as latest.
  • Fork PR publishing stays disabled by default, and publish credentials are isolated from PR-controlled build scripts.
  • test-mocked-workflows.sh can run local act + mock failure tests before a real same-repo PR smoke test.

Model

  • Stable releases publish to npm and mirror to GitHub Packages with the same version.
  • PR builds publish only to GitHub Packages as immutable commit versions and a moving pr-<number> tag.
  • Main branch snapshots publish only to GitHub Packages as immutable commit versions and a moving main tag.
  • GitHub Packages latest stays aligned with stable releases. CI snapshots use main and pr-<number>.

High-Level Flow

flowchart LR
    Repo["Package repo"] --> PR["Same-repo pull request"]
    Repo --> Main["Push to main"]
    Repo --> Release["GitHub release or manual dispatch"]
    PR --> GHPR["GitHub Packages: PR tag and exact SHA"]
    Main --> GHMain["GitHub Packages: main tag and exact SHA"]
    Release --> Npm["npm registry: stable version"]
    Release --> GHStable["GitHub Packages: stable mirror"]
Loading

Example Workflows

PR Preview Package

flowchart TD
    PR["PR opened, synchronized, or reopened"] --> SameRepo{"Same repo branch?"}
    SameRepo -->|"No"| Skip["Skip preview publish"]
    SameRepo -->|"Yes"| Checkout["Checkout PR head SHA"]
    Checkout --> Pack["Read-only pack job: install, test, build, npm pack"]
    Pack --> Artifact["Upload package tarball"]
    Artifact --> Publish["Package-write publish job"]
    Publish --> Exact["Publish immutable PR SHA version"]
    Exact --> Tag["Move pr-number dist-tag"]
    Tag --> Comment["Update one PR comment with install commands"]
Loading

Main Snapshot Package

flowchart TD
    Main["Push to main"] --> Checkout["Checkout commit SHA"]
    Checkout --> Build["Install, test, and build"]
    Build --> Publish["Publish immutable main SHA version"]
    Publish --> Tag["Move main dist-tag"]
    Tag --> Install["Developers install the latest main snapshot"]
Loading

Stable Release Package

flowchart TD
    Release["GitHub release or manual dispatch"] --> Pack["Read-only pack job: install, test, build, npm pack"]
    Pack --> Artifact["Upload npm and GitHub package tarballs"]
    Artifact --> Gate["npm-publish environment approval"]
    Gate --> Publish["Publish job with OIDC and package write"]
    Publish --> Npm["npm Trusted Publishing"]
    Publish --> GHStable["GitHub Packages stable mirror"]
    GHStable --> Latest["Move GitHub Packages latest tag"]
Loading

Requirements

  • Package names must be lowercase scoped npm names, for example @your-org/pkg.
  • The package scope should match the GitHub owner that owns the repo, for example @your-org/pkg in your-org/repo.
  • package.json must include a repository field that points to the GitHub repo.
  • The repo must include a supported lockfile: pnpm-lock.yaml, yarn.lock, package-lock.json, or npm-shrinkwrap.json.
  • The repo must allow GitHub Actions to write packages.
  • Stable npm releases use npm Trusted Publishing/OIDC. Configure npm to trust .github/workflows/release-package.yml.
  • Create a GitHub environment named npm-publish and add required reviewers for stable release publishing.
  • Private or internal preview installs require GitHub Packages auth with read:packages and repo/package access.
  • Fork PR publishing is disabled by default. Same-repo PR branches publish previews.

Files To Copy Into A Repo

Copy these gist files into the target repo:

Gist file Target path
repo-npmrc.example .npmrc
pr-preview-package.yml .github/workflows/pr-preview-package.yml
main-snapshot-package.yml .github/workflows/main-snapshot-package.yml
release-package.yml .github/workflows/release-package.yml

Use package-json.example as the package metadata shape. Use developer-github-packages-npmrc.example for local preview install setup.

The preview workflow intentionally separates build/pack from publish. PR-controlled package scripts run in a read-only job, then a separate job with package-write permissions publishes the generated tarball.

Local Mock Testing

Use workflow-testing.md and test-mocked-workflows.sh to test the workflow templates locally with act, actionlint, mocked npm, mocked gh, and a mocked PR comments API.

Security note: local act runs execute workflow commands on your machine. Only run the harness against workflow templates you trust.

The mocked harness checks success paths plus publish failures, stale PR/main refs, dist-tag failures, package-exists paths, and PR comment API failures. It does not perform real npm Trusted Publishing or real GitHub Packages authorization; keep one disposable same-repo GitHub smoke test for those platform integrations.

Preview Install Commands

The PR workflow updates one PR comment with copy/paste commands:

pnpm add @your-org/pkg@pr-123 --registry=https://npm.pkg.github.com
pnpm add @your-org/pkg@0.0.0-pr.123.sha.<full-sha> --registry=https://npm.pkg.github.com

The first command follows the latest successful build for that PR. The second command pins an exact commit build.

Main snapshots are available as:

pnpm add @your-org/pkg@main --registry=https://npm.pkg.github.com

Local GitHub Packages Auth

Developers need GitHub Packages read auth before installing private previews:

npm login --scope=@your-org --auth-type=legacy --registry=https://npm.pkg.github.com

Use a GitHub classic PAT with read:packages. For private repos/packages, the GitHub account also needs repo/package read access.

Do not commit a real token to a repo. Keep the developer auth line in a user-level npm config or another local-only config file.

Notes For Codex

When installing this in a target repo, inspect the repo first. Preserve the repo's package manager and build/test commands where practical. These templates are intentionally root-package templates; adapt with a package matrix for monorepos.

Codex Install Guide

Use this guide when adding the gist bundle to a target repo.

1. Inspect The Target Repo

  • Identify the package root. These templates assume the package is at the repo root.
  • Identify the package manager from lockfiles:
    • pnpm-lock.yaml
    • yarn.lock
    • package-lock.json or npm-shrinkwrap.json
  • Stop if the repo does not have one of those lockfiles. The workflow templates intentionally fail instead of running npm install without a lockfile.
  • Read package.json.
  • Do not read or print token-bearing npm config files or secrets.

2. Verify Package Metadata

The package must have:

{
  "name": "@your-org/pkg",
  "repository": "https://github.com/your-org/repo",
  "publishConfig": {
    "registry": "https://registry.npmjs.org"
  }
}

Rules:

  • name must be lowercase and scoped.
  • The scope should match the npm/GitHub owner. GitHub Packages expects the scope to match the GitHub owner that owns the repo.
  • repository must point to the GitHub repo.
  • Stable release versions come from package.json.

3. Copy Files

Copy these gist files:

repo-npmrc.example -> .npmrc
pr-preview-package.yml -> .github/workflows/pr-preview-package.yml
main-snapshot-package.yml -> .github/workflows/main-snapshot-package.yml
release-package.yml -> .github/workflows/release-package.yml

If the repo already has a .npmrc, merge the stable npm registry line instead of replacing unrelated config.

4. Configure Secrets And Permissions

  • Configure npm Trusted Publishing/OIDC for .github/workflows/release-package.yml.
  • Create a GitHub environment named npm-publish and require reviewer approval for stable release publishing.
  • Ensure Actions can create and write packages.
  • Ensure package permissions are private/internal unless the package should be public.
  • Do not add an NPM_TOKEN; the release workflow is designed to avoid long-lived npm publish tokens.

5. Validate Locally

Run the repo's normal checks. At minimum:

npm test --if-present
npm run build --if-present

If a YAML parser is available, syntax-check the copied workflow files.

Confirm the target repo has a supported lockfile before opening a PR. Without one, the workflows fail with an explicit error and will not publish packages.

Optional but recommended before opening a real test PR:

WORKFLOW_SOURCE_DIR=/path/to/target/repo /path/to/gist/test-mocked-workflows.sh

This requires act, actionlint, Node.js, npm, and a Docker-compatible runtime. See workflow-testing.md.

6. Verify In GitHub

  • Open a same-repo PR.
  • Confirm .github/workflows/pr-preview-package.yml publishes a GitHub Packages version like:
    • 0.0.0-pr.123.sha.<full-sha>
  • Confirm the PR comment includes copy/paste install commands.
  • Push to main and confirm the main dist-tag moves to 0.0.0-main.sha.<sha>.
  • Publish a GitHub release or run the release workflow manually only after npm Trusted Publishing and the npm-publish environment are configured.

7. Developer Preview Install Setup

Developers who install private previews need local GitHub Packages auth:

npm login --scope=@your-org --auth-type=legacy --registry=https://npm.pkg.github.com

Use a GitHub classic PAT with read:packages.

Do not commit a real token to the repo. developer-github-packages-npmrc.example is for user/global npm config only.

@your-org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_CLASSIC_PAT_WITH_READ_PACKAGES
name: Main Snapshot Package
on:
push:
branches: [main]
permissions:
contents: read
packages: write
concurrency:
group: main-snapshot-package-${{ github.sha }}
cancel-in-progress: false
jobs:
publish:
name: Publish main snapshot package
runs-on: ubuntu-latest
env:
GITHUB_REGISTRY: https://npm.pkg.github.com
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
MAIN_BRANCH: main
SNAPSHOT_SHA: ${{ github.sha }}
steps:
- name: Checkout commit
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install and verify
run: |
corepack enable
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
elif [ -f yarn.lock ]; then
yarn install --immutable || yarn install --frozen-lockfile
elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
npm ci
else
echo "::error::No supported lockfile found. Add pnpm-lock.yaml, yarn.lock, package-lock.json, or npm-shrinkwrap.json before publishing main snapshot packages."
exit 1
fi
npm test --if-present
npm run build --if-present
- name: Prepare snapshot metadata
run: |
node <<'NODE'
const fs = require("node:fs");
const pkg = require("./package.json");
const name = pkg.name;
const lower = typeof name === "string" ? name.toLowerCase() : "";
const expectedScope = `@${process.env.REPOSITORY_OWNER.toLowerCase()}`;
const packageNamePattern = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
if (!name || !name.startsWith("@") || !name.includes("/")) {
throw new Error(`GitHub Packages npm packages must be scoped. Found: ${name || "(missing)"}`);
}
if (!packageNamePattern.test(name)) {
throw new Error(`Package name must be a simple lowercase scoped npm name. Found: ${name}`);
}
if (name !== lower) {
throw new Error(`GitHub Packages npm package names/scopes must be lowercase. Found: ${name}`);
}
if (!name.startsWith(`${expectedScope}/`)) {
throw new Error(`GitHub Packages package scope must match the GitHub owner. Expected ${expectedScope}/*, found: ${name}`);
}
if (!pkg.repository) {
throw new Error("package.json must include a repository field so GitHub Packages can associate the package with this repo.");
}
const snapshotVersion = `0.0.0-main.sha.${process.env.SNAPSHOT_SHA}`;
fs.appendFileSync(process.env.GITHUB_ENV, [
`PACKAGE_NAME=${name}`,
`SNAPSHOT_VERSION=${snapshotVersion}`,
"SNAPSHOT_TAG=main",
""
].join("\n"));
NODE
- name: Pack exact main snapshot
run: |
mkdir -p "$RUNNER_TEMP/main-snapshot-package"
npm pkg set "version=$SNAPSHOT_VERSION"
npm pkg set "publishConfig.registry=$GITHUB_REGISTRY"
npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/main-snapshot-package"
- name: Publish exact main snapshot
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tarball="$(find "$RUNNER_TEMP/main-snapshot-package" -type f -name '*.tgz' -print -quit)"
if [ -z "$tarball" ]; then
echo "::error::No main snapshot tarball was created in $RUNNER_TEMP/main-snapshot-package."
exit 1
fi
owner_lc="$(printf '%s' "$REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
npm_config="$RUNNER_TEMP/github-packages.npmrc"
{
echo "@${owner_lc}:registry=${GITHUB_REGISTRY}"
echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}"
} > "$npm_config"
if NPM_CONFIG_USERCONFIG="$npm_config" npm view "${PACKAGE_NAME}@${SNAPSHOT_VERSION}" version --registry "$GITHUB_REGISTRY" >/dev/null 2>&1; then
echo "Snapshot already exists: ${PACKAGE_NAME}@${SNAPSHOT_VERSION}"
else
echo "Publishing ${PACKAGE_NAME}@${SNAPSHOT_VERSION} to GitHub Packages with tag ${SNAPSHOT_TAG}."
if ! NPM_CONFIG_USERCONFIG="$npm_config" npm publish "$tarball" --tag "$SNAPSHOT_TAG" --ignore-scripts --registry "$GITHUB_REGISTRY"; then
echo "::error::Failed to publish ${PACKAGE_NAME}@${SNAPSHOT_VERSION} to GitHub Packages. Check Actions package write permission, package visibility/access, and whether this immutable version already exists."
exit 1
fi
fi
- name: Move main dist-tag if this is still current main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! current_sha="$(gh api "repos/${REPOSITORY}/branches/${MAIN_BRANCH}" --jq '.commit.sha')"; then
echo "::error::Failed to read current ${MAIN_BRANCH} branch SHA for ${REPOSITORY}. Check contents: read permission and GITHUB_TOKEN availability."
exit 1
fi
if [ "$current_sha" = "$SNAPSHOT_SHA" ]; then
owner_lc="$(printf '%s' "$REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
npm_config="$RUNNER_TEMP/github-packages.npmrc"
{
echo "@${owner_lc}:registry=${GITHUB_REGISTRY}"
echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}"
} > "$npm_config"
echo "Moving ${SNAPSHOT_TAG} to ${PACKAGE_NAME}@${SNAPSHOT_VERSION}."
if ! NPM_CONFIG_USERCONFIG="$npm_config" npm dist-tag add "${PACKAGE_NAME}@${SNAPSHOT_VERSION}" "$SNAPSHOT_TAG" --registry "$GITHUB_REGISTRY"; then
echo "::error::Failed to move GitHub Packages dist-tag ${SNAPSHOT_TAG} to ${PACKAGE_NAME}@${SNAPSHOT_VERSION}."
exit 1
fi
else
echo "::notice::Not moving ${SNAPSHOT_TAG}: ${MAIN_BRANCH} moved from ${SNAPSHOT_SHA} to ${current_sha}."
fi

GitHub-Native npm Preview Packages

This file is kept for older links. Use README.md in this gist as the entrypoint.

The gist contains copyable workflow templates for:

  • PR preview packages in GitHub Packages.
  • Main branch snapshots in GitHub Packages.
  • Stable release publishing to npm and GitHub Packages.
{
"name": "@your-org/pkg",
"repository": "https://github.com/your-org/repo",
"publishConfig": {
"registry": "https://registry.npmjs.org"
}
}
name: PR Preview Package
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pr-preview-package-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
cancel-in-progress: false
jobs:
pack:
name: Build and pack PR preview
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
outputs:
artifact_name: ${{ steps.metadata.outputs.artifact_name }}
package_name: ${{ steps.metadata.outputs.package_name }}
preview_version: ${{ steps.metadata.outputs.preview_version }}
preview_tag: ${{ steps.metadata.outputs.preview_tag }}
env:
GITHUB_REGISTRY: https://npm.pkg.github.com
REPOSITORY_OWNER: ${{ github.repository_owner }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
steps:
- name: Checkout PR head
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install and verify
run: |
corepack enable
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
elif [ -f yarn.lock ]; then
yarn install --immutable || yarn install --frozen-lockfile
elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
npm ci
else
echo "::error::No supported lockfile found. Add pnpm-lock.yaml, yarn.lock, package-lock.json, or npm-shrinkwrap.json before publishing preview packages."
exit 1
fi
npm test --if-present
npm run build --if-present
- name: Prepare preview metadata
id: metadata
run: |
node <<'NODE'
const fs = require("node:fs");
const pkg = require("./package.json");
const name = pkg.name;
const lower = typeof name === "string" ? name.toLowerCase() : "";
const expectedScope = `@${process.env.REPOSITORY_OWNER.toLowerCase()}`;
const packageNamePattern = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
if (!name || !name.startsWith("@") || !name.includes("/")) {
throw new Error(`GitHub Packages npm packages must be scoped. Found: ${name || "(missing)"}`);
}
if (!packageNamePattern.test(name)) {
throw new Error(`Package name must be a simple lowercase scoped npm name. Found: ${name}`);
}
if (name !== lower) {
throw new Error(`GitHub Packages npm package names/scopes must be lowercase. Found: ${name}`);
}
if (!name.startsWith(`${expectedScope}/`)) {
throw new Error(`GitHub Packages package scope must match the GitHub owner. Expected ${expectedScope}/*, found: ${name}`);
}
if (!pkg.repository) {
throw new Error("package.json must include a repository field so GitHub Packages can associate the package with this repo.");
}
const sha = process.env.PR_HEAD_SHA;
const previewVersion = `0.0.0-pr.${process.env.PR_NUMBER}.sha.${sha}`;
const previewTag = `pr-${process.env.PR_NUMBER}`;
const artifactName = `pr-preview-package-${process.env.PR_NUMBER}-${sha}`;
fs.appendFileSync(process.env.GITHUB_OUTPUT, [
`artifact_name=${artifactName}`,
`package_name=${name}`,
`preview_version=${previewVersion}`,
`preview_tag=${previewTag}`,
""
].join("\n"));
NODE
- name: Pack exact commit preview
run: |
mkdir -p "$RUNNER_TEMP/preview-package"
npm pkg set "version=${{ steps.metadata.outputs.preview_version }}"
npm pkg set "publishConfig.registry=$GITHUB_REGISTRY"
npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/preview-package"
- name: Upload preview tarball
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.metadata.outputs.artifact_name }}
path: ${{ runner.temp }}/preview-package/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish PR preview package
runs-on: ubuntu-latest
needs: pack
if: needs.pack.result == 'success'
permissions:
contents: read
packages: write
issues: write
pull-requests: read
env:
GITHUB_REGISTRY: https://npm.pkg.github.com
REPOSITORY: ${{ github.repository }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PACKAGE_NAME: ${{ needs.pack.outputs.package_name }}
PREVIEW_VERSION: ${{ needs.pack.outputs.preview_version }}
PREVIEW_TAG: ${{ needs.pack.outputs.preview_tag }}
steps:
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
registry-url: https://npm.pkg.github.com
- name: Download preview tarball
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: ${{ needs.pack.outputs.artifact_name }}
path: preview-package
- name: Publish exact commit preview
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tarball="$(find preview-package -type f -name '*.tgz' -print -quit)"
if [ -z "$tarball" ]; then
echo "::error::No preview tarball was downloaded. Check the pack job artifact upload and the artifact name '${{ needs.pack.outputs.artifact_name }}'."
exit 1
fi
owner_lc="$(printf '%s' "$REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
npm_config="$RUNNER_TEMP/github-packages.npmrc"
{
echo "@${owner_lc}:registry=${GITHUB_REGISTRY}"
echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}"
} > "$npm_config"
if NPM_CONFIG_USERCONFIG="$npm_config" npm view "${PACKAGE_NAME}@${PREVIEW_VERSION}" version --registry "$GITHUB_REGISTRY" >/dev/null 2>&1; then
echo "Preview already exists: ${PACKAGE_NAME}@${PREVIEW_VERSION}"
else
echo "Publishing ${PACKAGE_NAME}@${PREVIEW_VERSION} to GitHub Packages with tag ${PREVIEW_TAG}."
if ! NPM_CONFIG_USERCONFIG="$npm_config" npm publish "$tarball" --tag "$PREVIEW_TAG" --ignore-scripts --registry "$GITHUB_REGISTRY"; then
echo "::error::Failed to publish ${PACKAGE_NAME}@${PREVIEW_VERSION} to GitHub Packages. Check Actions package write permission, package visibility/access, and whether this immutable version already exists."
exit 1
fi
fi
- name: Move PR dist-tag if this is still the current PR head
id: pr_tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! current_sha="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')"; then
echo "::error::Failed to read current PR head SHA for ${REPOSITORY}#${PR_NUMBER}. Check pull-requests: read permission and GITHUB_TOKEN availability."
exit 1
fi
if [ "$current_sha" = "$PR_HEAD_SHA" ]; then
owner_lc="$(printf '%s' "$REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
npm_config="$RUNNER_TEMP/github-packages.npmrc"
{
echo "@${owner_lc}:registry=${GITHUB_REGISTRY}"
echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}"
} > "$npm_config"
echo "Moving ${PREVIEW_TAG} to ${PACKAGE_NAME}@${PREVIEW_VERSION}."
if ! NPM_CONFIG_USERCONFIG="$npm_config" npm dist-tag add "${PACKAGE_NAME}@${PREVIEW_VERSION}" "$PREVIEW_TAG" --registry "$GITHUB_REGISTRY"; then
echo "::error::Failed to move GitHub Packages dist-tag ${PREVIEW_TAG} to ${PACKAGE_NAME}@${PREVIEW_VERSION}."
exit 1
fi
echo "current=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::Not moving ${PREVIEW_TAG}: PR head moved from ${PR_HEAD_SHA} to ${current_sha}."
echo "current=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment install commands
if: steps.pr_tag.outputs.current == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node <<'NODE'
const required = ["GH_TOKEN", "PACKAGE_NAME", "PREVIEW_TAG", "PREVIEW_VERSION", "PR_HEAD_SHA", "PR_NUMBER", "REPOSITORY"];
for (const name of required) {
if (!process.env[name]) {
throw new Error(`Missing required environment variable: ${name}`);
}
}
const marker = "<!-- github-packages-pr-preview -->";
const body = `${marker}
### Preview package
Built from \`${process.env.PR_HEAD_SHA}\`.
Latest successful build for this PR:
\`\`\`sh
pnpm add ${process.env.PACKAGE_NAME}@${process.env.PREVIEW_TAG} --registry=https://npm.pkg.github.com
\`\`\`
Exact commit build:
\`\`\`sh
pnpm add ${process.env.PACKAGE_NAME}@${process.env.PREVIEW_VERSION} --registry=https://npm.pkg.github.com
\`\`\`
`.replace(/^ {10}/gm, "");
const apiBase = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
const api = `${apiBase}/repos/${process.env.REPOSITORY}/issues/${process.env.PR_NUMBER}/comments`;
const headers = {
authorization: `Bearer ${process.env.GH_TOKEN}`,
accept: "application/vnd.github+json",
"content-type": "application/json",
"x-github-api-version": "2022-11-28"
};
async function request(url, options = {}) {
const response = await fetch(url, { headers, ...options });
if (!response.ok) {
const text = await response.text();
const method = options.method || "GET";
throw new Error(`GitHub API ${method} ${url} failed with ${response.status}: ${text.slice(0, 1000)}`);
}
return response.status === 204 ? null : response.json();
}
(async () => {
const comments = await request(`${api}?per_page=100`);
const previous = comments.find((comment) =>
comment.body?.includes(marker) && comment.user?.login === "github-actions[bot]"
);
if (previous) {
if (!Number.isInteger(previous.id)) {
throw new Error("Existing preview comment did not include a numeric id.");
}
await request(`${apiBase}/repos/${process.env.REPOSITORY}/issues/comments/${previous.id}`, {
method: "PATCH",
body: JSON.stringify({ body })
});
} else {
await request(api, {
method: "POST",
body: JSON.stringify({ body })
});
}
})().catch((error) => {
console.error(`::error::Failed to create or update PR preview package comment: ${error.message}`);
process.exit(1);
});
NODE
name: Release Package
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: release-package-${{ github.ref }}
cancel-in-progress: false
jobs:
pack:
name: Build and pack stable package
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
artifact_name: ${{ steps.metadata.outputs.artifact_name }}
package_name: ${{ steps.metadata.outputs.package_name }}
package_version: ${{ steps.metadata.outputs.package_version }}
env:
NPM_REGISTRY: https://registry.npmjs.org
GITHUB_REGISTRY: https://npm.pkg.github.com
REPOSITORY_OWNER: ${{ github.repository_owner }}
steps:
- name: Checkout release ref
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 24
- name: Install and verify
run: |
corepack enable
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
elif [ -f yarn.lock ]; then
yarn install --immutable || yarn install --frozen-lockfile
elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
npm ci
else
echo "::error::No supported lockfile found. Add pnpm-lock.yaml, yarn.lock, package-lock.json, or npm-shrinkwrap.json before publishing stable releases."
exit 1
fi
npm test --if-present
npm run build --if-present
- name: Prepare release metadata
id: metadata
run: |
node <<'NODE'
const fs = require("node:fs");
const pkg = require("./package.json");
const name = pkg.name;
const version = pkg.version;
const lower = typeof name === "string" ? name.toLowerCase() : "";
const expectedScope = `@${process.env.REPOSITORY_OWNER.toLowerCase()}`;
const packageNamePattern = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
const versionPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
if (!name || !name.startsWith("@") || !name.includes("/")) {
throw new Error(`GitHub Packages npm packages must be scoped. Found: ${name || "(missing)"}`);
}
if (!packageNamePattern.test(name)) {
throw new Error(`Package name must be a simple lowercase scoped npm name. Found: ${name}`);
}
if (name !== lower) {
throw new Error(`GitHub Packages npm package names/scopes must be lowercase. Found: ${name}`);
}
if (!name.startsWith(`${expectedScope}/`)) {
throw new Error(`GitHub Packages package scope must match the GitHub owner. Expected ${expectedScope}/*, found: ${name}`);
}
if (!version) {
throw new Error("package.json must include a version for stable publishing.");
}
if (!versionPattern.test(version)) {
throw new Error(`package.json version must be a simple semver version. Found: ${version}`);
}
if (!pkg.repository) {
throw new Error("package.json must include a repository field so GitHub Packages can associate the package with this repo.");
}
const artifactName = `release-package-${version}`;
fs.appendFileSync(process.env.GITHUB_OUTPUT, [
`artifact_name=${artifactName}`,
`package_name=${name}`,
`package_version=${version}`,
""
].join("\n"));
NODE
- name: Pack release tarballs
run: |
mkdir -p "$RUNNER_TEMP/release-package/npm" "$RUNNER_TEMP/release-package/github"
npm pkg set "publishConfig.registry=$NPM_REGISTRY"
npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/release-package/npm"
npm pkg set "publishConfig.registry=$GITHUB_REGISTRY"
npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/release-package/github"
- name: Upload release tarballs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.metadata.outputs.artifact_name }}
path: ${{ runner.temp }}/release-package/**/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish stable package
runs-on: ubuntu-latest
needs: pack
if: needs.pack.result == 'success'
environment: npm-publish
permissions:
contents: read
packages: write
id-token: write
env:
NPM_REGISTRY: https://registry.npmjs.org
GITHUB_REGISTRY: https://npm.pkg.github.com
REPOSITORY_OWNER: ${{ github.repository_owner }}
NPM_PUBLISH_ACCESS: public
PACKAGE_NAME: ${{ needs.pack.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.pack.outputs.package_version }}
steps:
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 24
- name: Download release tarballs
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: ${{ needs.pack.outputs.artifact_name }}
path: release-package
- name: Publish to npm with trusted publishing
run: |
tarball="$(find release-package/npm -type f -name '*.tgz' -print -quit)"
if [ -z "$tarball" ]; then
echo "::error::No npm release tarball was downloaded. Check the pack job artifact upload and artifact name '${{ needs.pack.outputs.artifact_name }}'."
exit 1
fi
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry "$NPM_REGISTRY" >/dev/null 2>&1; then
echo "npm version already exists: ${PACKAGE_NAME}@${PACKAGE_VERSION}"
else
echo "Publishing ${PACKAGE_NAME}@${PACKAGE_VERSION} to npm with Trusted Publishing."
if ! npm publish "$tarball" --access "$NPM_PUBLISH_ACCESS" --tag latest --provenance --ignore-scripts --registry "$NPM_REGISTRY"; then
echo "::error::Failed to publish ${PACKAGE_NAME}@${PACKAGE_VERSION} to npm. Check npm Trusted Publishing for .github/workflows/release-package.yml, the npm-publish environment approval, and whether this version already exists."
exit 1
fi
fi
- name: Publish to GitHub Packages
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tarball="$(find release-package/github -type f -name '*.tgz' -print -quit)"
if [ -z "$tarball" ]; then
echo "::error::No GitHub Packages release tarball was downloaded. Check the pack job artifact upload and artifact name '${{ needs.pack.outputs.artifact_name }}'."
exit 1
fi
owner_lc="$(printf '%s' "$REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
npm_config="$RUNNER_TEMP/github-packages.npmrc"
{
echo "@${owner_lc}:registry=${GITHUB_REGISTRY}"
echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}"
} > "$npm_config"
if NPM_CONFIG_USERCONFIG="$npm_config" npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry "$GITHUB_REGISTRY" >/dev/null 2>&1; then
echo "GitHub Packages version already exists: ${PACKAGE_NAME}@${PACKAGE_VERSION}"
else
echo "Publishing ${PACKAGE_NAME}@${PACKAGE_VERSION} to GitHub Packages with tag latest."
if ! NPM_CONFIG_USERCONFIG="$npm_config" npm publish "$tarball" --tag latest --ignore-scripts --registry "$GITHUB_REGISTRY"; then
echo "::error::Failed to publish ${PACKAGE_NAME}@${PACKAGE_VERSION} to GitHub Packages. Check Actions package write permission, package visibility/access, and whether this version already exists."
exit 1
fi
fi
echo "Moving GitHub Packages latest to ${PACKAGE_NAME}@${PACKAGE_VERSION}."
if ! NPM_CONFIG_USERCONFIG="$npm_config" npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" latest --registry "$GITHUB_REGISTRY"; then
echo "::error::Failed to move GitHub Packages latest tag to ${PACKAGE_NAME}@${PACKAGE_VERSION}."
exit 1
fi
@your-org:registry=https://registry.npmjs.org
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR="${WORKFLOW_SOURCE_DIR:-$ROOT_DIR}"
if [ -f "$SOURCE_DIR/pr-preview-package.yml" ]; then
PR_WORKFLOW="$SOURCE_DIR/pr-preview-package.yml"
MAIN_WORKFLOW="$SOURCE_DIR/main-snapshot-package.yml"
RELEASE_WORKFLOW="$SOURCE_DIR/release-package.yml"
elif [ -f "$SOURCE_DIR/.github/workflows/pr-preview-package.yml" ]; then
PR_WORKFLOW="$SOURCE_DIR/.github/workflows/pr-preview-package.yml"
MAIN_WORKFLOW="$SOURCE_DIR/.github/workflows/main-snapshot-package.yml"
RELEASE_WORKFLOW="$SOURCE_DIR/.github/workflows/release-package.yml"
else
echo "Could not find workflow templates in $SOURCE_DIR." >&2
echo "Run from the gist clone, or set WORKFLOW_SOURCE_DIR to a repo/workflow directory." >&2
exit 1
fi
ACT_BIN="${ACT_BIN:-act}"
ACTIONLINT_BIN="${ACTIONLINT_BIN:-actionlint}"
ACT_IMAGE="${ACT_IMAGE:-ghcr.io/catthehacker/ubuntu:act-22.04}"
ACT_ARCH="${ACT_ARCH:-linux/arm64}"
TEST_ROOT="${TEST_ROOT:-$ROOT_DIR/.pkg-preview-workflow-test}"
KEEP_TEST_ROOT="${KEEP_TEST_ROOT:-0}"
MOCK_GITHUB_API_PORT="${MOCK_GITHUB_API_PORT:-18080}"
require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required tool: $1" >&2
exit 1
fi
}
require_tool git
require_tool node
require_tool npm
require_tool "$ACT_BIN"
require_tool "$ACTIONLINT_BIN"
echo "Security note: this harness runs workflow commands locally through act --bind. Only run it against workflow templates you trust."
cleanup() {
if [ "$KEEP_TEST_ROOT" != "1" ]; then
rm -rf "$TEST_ROOT"
else
echo "Kept test root: $TEST_ROOT"
fi
}
trap cleanup EXIT
rm -rf "$TEST_ROOT"
mkdir -p "$TEST_ROOT/fixture/.github/workflows" "$TEST_ROOT/fixture/.github/events" "$TEST_ROOT/fixture/mock-bin" "$TEST_ROOT/logs" "$TEST_ROOT/artifacts"
cp "$PR_WORKFLOW" "$TEST_ROOT/fixture/.github/workflows/pr-preview-package.yml"
cp "$MAIN_WORKFLOW" "$TEST_ROOT/fixture/.github/workflows/main-snapshot-package.yml"
cp "$RELEASE_WORKFLOW" "$TEST_ROOT/fixture/.github/workflows/release-package.yml"
cat > "$TEST_ROOT/fixture/package.json" <<'JSON'
{
"name": "@patrickjs/pkg-preview-fixture",
"version": "1.2.3",
"repository": "https://github.com/PatrickJS/pkg-preview-fixture",
"scripts": {
"build": "node -e \"console.log('build ok')\"",
"test": "node -e \"console.log('test ok')\""
},
"publishConfig": {
"registry": "https://registry.npmjs.org"
}
}
JSON
cat > "$TEST_ROOT/fixture/package-lock.json" <<'JSON'
{
"name": "@patrickjs/pkg-preview-fixture",
"version": "1.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@patrickjs/pkg-preview-fixture",
"version": "1.2.3",
"license": "UNLICENSED"
}
}
}
JSON
cat > "$TEST_ROOT/fixture/.github/events/pr.json" <<'JSON'
{
"pull_request": {
"number": 123,
"head": {
"sha": "0123456789abcdef0123456789abcdef01234567",
"repo": {
"full_name": "PatrickJS/pkg-preview-fixture"
}
}
},
"repository": {
"full_name": "PatrickJS/pkg-preview-fixture",
"owner": {
"login": "PatrickJS"
}
}
}
JSON
cat > "$TEST_ROOT/fixture/.github/events/push.json" <<'JSON'
{
"ref": "refs/heads/main",
"after": "fedcba9876543210fedcba9876543210fedcba98",
"repository": {
"full_name": "PatrickJS/pkg-preview-fixture",
"owner": {
"login": "PatrickJS"
}
}
}
JSON
cat > "$TEST_ROOT/fixture/.github/events/release.json" <<'JSON'
{
"release": {
"tag_name": "v1.2.3",
"target_commitish": "main"
},
"repository": {
"full_name": "PatrickJS/pkg-preview-fixture",
"owner": {
"login": "PatrickJS"
}
}
}
JSON
cat > "$TEST_ROOT/fixture/mock-bin/npm" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
mock_dir="$(cd "$(dirname "$0")" && pwd)"
search_path=":$PATH:"
search_path="${search_path//:$mock_dir:/:}"
search_path="${search_path#:}"
search_path="${search_path%:}"
real_npm="$(PATH="$search_path" command -v npm || true)"
log_line() {
if [ -n "${MOCK_NPM_LOG:-}" ]; then
printf 'npm' >> "$MOCK_NPM_LOG"
printf ' %q' "$@" >> "$MOCK_NPM_LOG"
printf '\n' >> "$MOCK_NPM_LOG"
fi
}
contains_arg() {
local needle="$1"
shift
local arg
for arg in "$@"; do
if [ "$arg" = "$needle" ]; then
return 0
fi
done
return 1
}
uses_registry() {
local registry="$1"
shift
local arg
for arg in "$@"; do
if [ "$arg" = "$registry" ]; then
return 0
fi
done
return 1
}
command="${1:-}"
log_line "$@"
case "$command" in
view)
if [ "${MOCK_NPM_VIEW_FAIL:-0}" = "1" ]; then
echo "npm ERR! mock npm view failure" >&2
exit 1
fi
if [ "${MOCK_NPM_VIEW_EXISTS:-0}" = "1" ]; then
echo "${MOCK_NPM_VIEW_VERSION:-1.2.3}"
exit 0
fi
echo "npm ERR! code E404" >&2
echo "npm ERR! 404 mock package version not found" >&2
exit 1
;;
publish)
if [ "${MOCK_NPM_PUBLISH_FAIL:-0}" = "1" ]; then
echo "npm ERR! mock npm publish failure" >&2
exit 1
fi
if uses_registry "https://registry.npmjs.org" "$@" && [ "${MOCK_NPM_PUBLISH_FAIL_NPM:-0}" = "1" ]; then
echo "npm ERR! mock npm registry publish failure" >&2
exit 1
fi
if uses_registry "https://npm.pkg.github.com" "$@" && [ "${MOCK_NPM_PUBLISH_FAIL_GITHUB:-0}" = "1" ]; then
echo "npm ERR! mock GitHub Packages publish failure" >&2
exit 1
fi
echo "mock npm publish $*"
exit 0
;;
dist-tag)
if [ "${2:-}" = "add" ]; then
if [ "${MOCK_NPM_DIST_TAG_FAIL:-0}" = "1" ]; then
echo "npm ERR! mock npm dist-tag add failure" >&2
exit 1
fi
echo "mock npm dist-tag $*"
exit 0
fi
;;
esac
if [ -z "$real_npm" ]; then
echo "mock npm could not find the real npm for delegated command: $*" >&2
exit 1
fi
exec "$real_npm" "$@"
SH
cat > "$TEST_ROOT/fixture/mock-bin/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [ "${1:-}" != "api" ]; then
echo "mock gh only supports: gh api" >&2
exit 1
fi
if [ "${MOCK_GH_API_FAIL:-0}" = "1" ]; then
echo "mock gh api failure" >&2
exit 1
fi
endpoint="${2:-}"
case "$endpoint" in
repos/*/pulls/*)
if [ "${MOCK_PR_HEAD_STALE:-0}" = "1" ]; then
echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
else
echo "${PR_HEAD_SHA:-0123456789abcdef0123456789abcdef01234567}"
fi
;;
repos/*/branches/*)
if [ "${MOCK_MAIN_HEAD_STALE:-0}" = "1" ]; then
echo "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
else
echo "${SNAPSHOT_SHA:-${GITHUB_SHA:-fedcba9876543210fedcba9876543210fedcba98}}"
fi
;;
*)
echo "mock gh api does not recognize endpoint: $endpoint" >&2
exit 1
;;
esac
SH
chmod +x "$TEST_ROOT/fixture/mock-bin/npm" "$TEST_ROOT/fixture/mock-bin/gh"
cat > "$TEST_ROOT/fixture/mock-github-api.js" <<'JS'
const http = require("node:http");
const port = Number(process.env.MOCK_GITHUB_API_PORT || 18080);
const marker = "<!-- github-packages-pr-preview -->";
let comments = process.env.MOCK_EXISTING_COMMENT === "1"
? [{ id: 1, body: `${marker}\nold body`, user: { login: "github-actions[bot]" }, url: `http://127.0.0.1:${port}/repos/PatrickJS/pkg-preview-fixture/issues/comments/1` }]
: [];
function readBody(request) {
return new Promise((resolve, reject) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
body += chunk;
});
request.on("end", () => resolve(body));
request.on("error", reject);
});
}
function send(response, status, value) {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(value));
}
function commentIdFromPatchPath(url) {
if (url.startsWith("/comments/")) {
return Number(url.split("/").pop());
}
const match = url.match(/^\/repos\/[^/]+\/[^/]+\/issues\/comments\/([0-9]+)$/);
return match ? Number(match[1]) : NaN;
}
const server = http.createServer(async (request, response) => {
if (process.env.MOCK_GITHUB_API_FAIL === "1") {
send(response, 500, { message: "mock GitHub comments API failure" });
return;
}
if (request.method === "GET" && request.url === "/health") {
send(response, 200, { ok: true });
return;
}
if (request.method === "GET" && request.url.startsWith("/repos/") && request.url.includes("/issues/123/comments")) {
send(response, 200, comments);
return;
}
if (request.method === "POST" && request.url.startsWith("/repos/") && request.url.includes("/issues/123/comments")) {
const body = JSON.parse(await readBody(request) || "{}");
const id = comments.length + 1;
const comment = { id, body: body.body || "", user: { login: "github-actions[bot]" }, url: `http://127.0.0.1:${port}/repos/PatrickJS/pkg-preview-fixture/issues/comments/${id}` };
comments.push(comment);
send(response, 201, comment);
return;
}
if (request.method === "PATCH") {
const id = commentIdFromPatchPath(request.url);
if (!Number.isFinite(id)) {
send(response, 404, { message: `mock route not found: ${request.method} ${request.url}` });
return;
}
const body = JSON.parse(await readBody(request) || "{}");
const comment = comments.find((item) => item.id === id);
if (!comment) {
send(response, 404, { message: "comment not found" });
return;
}
comment.body = body.body || "";
send(response, 200, comment);
return;
}
send(response, 404, { message: `mock route not found: ${request.method} ${request.url}` });
});
server.listen(port, "127.0.0.1", () => {
console.log(`mock GitHub API listening on ${port}`);
});
JS
node - "$TEST_ROOT/fixture/.github/workflows" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const workflowDir = process.argv[2];
function read(name) {
return fs.readFileSync(path.join(workflowDir, name), "utf8");
}
function write(name, content) {
fs.writeFileSync(path.join(workflowDir, name), content);
}
function replaceCheckout(content, stepName) {
const pattern = new RegExp(` - name: ${stepName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n uses: actions/checkout@[^\\n]+\\n with:\\n(?: [^\\n]+\\n)+`);
const replacement = ` - name: ${stepName}\n run: echo "checkout skipped for local act fixture"\n`;
if (!pattern.test(content)) {
throw new Error(`Could not patch checkout step: ${stepName}`);
}
return content.replace(pattern, replacement);
}
function insertBefore(content, marker, block) {
if (!content.includes(marker)) {
throw new Error(`Could not find insertion marker: ${marker}`);
}
return content.replace(marker, `${block}\n${marker}`);
}
function prependRun(content, stepName, scriptLine) {
const step = ` - name: ${stepName}\n`;
const stepIndex = content.indexOf(step);
if (stepIndex === -1) {
throw new Error(`Could not find step: ${stepName}`);
}
const runMarker = " run: |\n";
const runIndex = content.indexOf(runMarker, stepIndex);
if (runIndex === -1) {
throw new Error(`Could not find run block for step: ${stepName}`);
}
const insertIndex = runIndex + runMarker.length;
return `${content.slice(0, insertIndex)} ${scriptLine}\n${content.slice(insertIndex)}`;
}
let pr = read("pr-preview-package.yml");
pr = pr.replace(/\$\{\{ secrets\.GITHUB_TOKEN \}\}/g, "mock-token");
pr = replaceCheckout(pr, "Checkout PR head");
pr = insertBefore(pr, " - name: Download preview tarball\n", ` - name: Add mock command shims\n run: echo "$MOCK_BIN" >> "$GITHUB_PATH"\n`);
pr = insertBefore(pr, " - name: Comment install commands\n", ` - name: Start mock GitHub comments API\n if: steps.pr_tag.outputs.current == 'true'\n run: |\n node mock-github-api.js > "$RUNNER_TEMP/mock-github-api.log" 2>&1 &\n for i in {1..50}; do\n if node -e "fetch('http://127.0.0.1:\${MOCK_GITHUB_API_PORT:-18080}/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then\n break\n fi\n sleep 0.1\n done\n echo "GITHUB_API_URL=http://127.0.0.1:\${MOCK_GITHUB_API_PORT:-18080}" >> "$GITHUB_ENV"\n`);
pr = pr.replace('process.env.GITHUB_API_URL || "https://api.github.com"', '`http://127.0.0.1:${process.env.MOCK_GITHUB_API_PORT || 18080}`');
pr = prependRun(pr, "Publish exact commit preview", 'export PATH="$MOCK_BIN:$PATH"');
pr = prependRun(pr, "Move PR dist-tag if this is still the current PR head", 'export PATH="$MOCK_BIN:$PATH"');
write("pr-preview-package.yml", pr);
let main = read("main-snapshot-package.yml");
main = main.replace(/\$\{\{ secrets\.GITHUB_TOKEN \}\}/g, "mock-token");
main = replaceCheckout(main, "Checkout commit");
main = insertBefore(main, " - name: Publish exact main snapshot\n", ` - name: Add mock command shims\n run: echo "$MOCK_BIN" >> "$GITHUB_PATH"\n`);
main = prependRun(main, "Publish exact main snapshot", 'export PATH="$MOCK_BIN:$PATH"');
main = prependRun(main, "Move main dist-tag if this is still current main", 'export PATH="$MOCK_BIN:$PATH"');
write("main-snapshot-package.yml", main);
let release = read("release-package.yml");
release = release.replace(/\$\{\{ secrets\.GITHUB_TOKEN \}\}/g, "mock-token");
release = replaceCheckout(release, "Checkout release ref");
release = insertBefore(release, " - name: Download release tarballs\n", ` - name: Add mock command shims\n run: echo "$MOCK_BIN" >> "$GITHUB_PATH"\n`);
release = prependRun(release, "Publish to npm with trusted publishing", 'export PATH="$MOCK_BIN:$PATH"');
release = prependRun(release, "Publish to GitHub Packages", 'export PATH="$MOCK_BIN:$PATH"');
write("release-package.yml", release);
NODE
(
cd "$TEST_ROOT/fixture"
git init >/dev/null
git add package.json package-lock.json mock-github-api.js .github
git commit -m "fixture" >/dev/null
git remote add origin https://github.com/PatrickJS/pkg-preview-fixture.git
)
echo "Linting source workflows..."
"$ACTIONLINT_BIN" -color=false "$PR_WORKFLOW" "$MAIN_WORKFLOW" "$RELEASE_WORKFLOW"
echo "Linting local patched workflows..."
"$ACTIONLINT_BIN" -color=false "$TEST_ROOT/fixture/.github/workflows/pr-preview-package.yml" "$TEST_ROOT/fixture/.github/workflows/main-snapshot-package.yml" "$TEST_ROOT/fixture/.github/workflows/release-package.yml"
act_common=(
--defaultbranch main
-P "ubuntu-latest=$ACT_IMAGE"
--container-architecture "$ACT_ARCH"
--bind
--container-daemon-socket -
--env "MOCK_BIN=$TEST_ROOT/fixture/mock-bin"
--env "MOCK_NPM_LOG=$TEST_ROOT/fixture/mock-npm.log"
--env "MOCK_GITHUB_API_PORT=$MOCK_GITHUB_API_PORT"
)
run_case() {
local name="$1"
local expected="$2"
local event="$3"
local event_file="$4"
local workflow="$5"
local expected_text="$6"
shift 6
local log="$TEST_ROOT/logs/${name}.log"
local artifacts="$TEST_ROOT/artifacts/${name}"
rm -rf "$artifacts"
mkdir -p "$artifacts"
git -C "$TEST_ROOT/fixture" restore package.json
local cmd=(
"$ACT_BIN" "$event"
--eventpath ".github/events/$event_file"
--workflows ".github/workflows/$workflow"
--artifact-server-path "$artifacts"
"${act_common[@]}"
)
local pair
for pair in "$@"; do
cmd+=(--env "$pair")
done
set +e
(
cd "$TEST_ROOT/fixture"
"${cmd[@]}"
) > "$log" 2>&1
local status=$?
set -e
if [ "$expected" = "pass" ] && [ "$status" -ne 0 ]; then
echo "FAIL $name: expected pass, got exit $status" >&2
sed -n '1,220p' "$log" >&2
exit 1
fi
if [ "$expected" = "fail" ] && [ "$status" -eq 0 ]; then
echo "FAIL $name: expected failure, got exit 0" >&2
sed -n '1,220p' "$log" >&2
exit 1
fi
if [ -n "$expected_text" ] && ! grep -Fq "$expected_text" "$log"; then
echo "FAIL $name: expected log text not found: $expected_text" >&2
sed -n '1,260p' "$log" >&2
exit 1
fi
echo "PASS $name"
}
echo "Validating workflow graphs with act dry-run..."
(
cd "$TEST_ROOT/fixture"
"$ACT_BIN" pull_request --dryrun --validate --eventpath .github/events/pr.json --workflows .github/workflows/pr-preview-package.yml "${act_common[@]}" >/dev/null
"$ACT_BIN" push --dryrun --validate --eventpath .github/events/push.json --workflows .github/workflows/main-snapshot-package.yml "${act_common[@]}" >/dev/null
"$ACT_BIN" release --dryrun --validate --eventpath .github/events/release.json --workflows .github/workflows/release-package.yml "${act_common[@]}" >/dev/null
)
echo "Running mocked workflow cases..."
run_case "pr-happy" pass pull_request pr.json pr-preview-package.yml "Success - Main Comment install commands"
run_case "pr-existing-version" pass pull_request pr.json pr-preview-package.yml "Preview already exists" MOCK_NPM_VIEW_EXISTS=1
run_case "pr-publish-fails" fail pull_request pr.json pr-preview-package.yml "Failed to publish @patrickjs/pkg-preview-fixture@0.0.0-pr.123.sha.0123456789abcdef0123456789abcdef01234567 to GitHub Packages" MOCK_NPM_PUBLISH_FAIL=1
run_case "pr-stale-head" pass pull_request pr.json pr-preview-package.yml "Not moving pr-123" MOCK_PR_HEAD_STALE=1
run_case "pr-dist-tag-fails" fail pull_request pr.json pr-preview-package.yml "Failed to move GitHub Packages dist-tag pr-123" MOCK_NPM_DIST_TAG_FAIL=1
run_case "pr-comment-fails" fail pull_request pr.json pr-preview-package.yml "Failed to create or update PR preview package comment" MOCK_GITHUB_API_FAIL=1
run_case "main-happy" pass push push.json main-snapshot-package.yml "Moving main to @patrickjs/pkg-preview-fixture@0.0.0-main.sha."
run_case "main-publish-fails" fail push push.json main-snapshot-package.yml "Failed to publish @patrickjs/pkg-preview-fixture@0.0.0-main.sha." MOCK_NPM_PUBLISH_FAIL=1
run_case "main-stale-head" pass push push.json main-snapshot-package.yml "Not moving main" MOCK_MAIN_HEAD_STALE=1
run_case "main-dist-tag-fails" fail push push.json main-snapshot-package.yml "Failed to move GitHub Packages dist-tag main" MOCK_NPM_DIST_TAG_FAIL=1
run_case "release-happy" pass release release.json release-package.yml "Moving GitHub Packages latest to @patrickjs/pkg-preview-fixture@1.2.3"
run_case "release-existing-version" pass release release.json release-package.yml "npm version already exists" MOCK_NPM_VIEW_EXISTS=1
run_case "release-npm-publish-fails" fail release release.json release-package.yml "Failed to publish @patrickjs/pkg-preview-fixture@1.2.3 to npm" MOCK_NPM_PUBLISH_FAIL_NPM=1
run_case "release-github-publish-fails" fail release release.json release-package.yml "Failed to publish @patrickjs/pkg-preview-fixture@1.2.3 to GitHub Packages" MOCK_NPM_PUBLISH_FAIL_GITHUB=1
run_case "release-github-dist-tag-fails" fail release release.json release-package.yml "Failed to move GitHub Packages latest tag" MOCK_NPM_DIST_TAG_FAIL=1
echo "All mocked workflow tests passed."

Mocked Workflow Testing

Use test-mocked-workflows.sh to test the workflow templates locally with mocked publish and GitHub API boundaries.

The script creates a throwaway fixture repo, patches only local copies of the workflows for act, and runs both success and failure cases. It does not publish to npm or GitHub Packages.

Security note: act executes workflow commands locally. Only run this harness against workflow templates you trust. The harness uses fake tokens, a throwaway fixture, mocked package publishing, and --container-daemon-socket -, but copied workflow steps still execute in local containers with bind-mounted files.

Requirements

  • Docker-compatible runtime, for example Colima or Docker Desktop.
  • act.
  • actionlint.
  • Node.js and npm.

Example local tool install:

mkdir -p work/bin
GOBIN="$PWD/work/bin" go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
GOBIN="$PWD/work/bin" go install github.com/nektos/act@v0.2.89
PATH="$PWD/work/bin:$PATH"

Run From This Gist Clone

./test-mocked-workflows.sh

Run Against Installed Repo Workflows

WORKFLOW_SOURCE_DIR=/path/to/repo ./test-mocked-workflows.sh

The script accepts:

ACT_BIN=/path/to/act
ACTIONLINT_BIN=/path/to/actionlint
ACT_IMAGE=ghcr.io/catthehacker/ubuntu:act-22.04
ACT_ARCH=linux/arm64
KEEP_TEST_ROOT=1

Use ACT_ARCH=linux/amd64 on Intel or amd64-only Docker setups.

Mocked Cases

The harness verifies these outcomes:

  • PR preview happy path.
  • PR package version already exists.
  • PR package publish fails.
  • PR head becomes stale before moving pr-<number>.
  • PR dist-tag move fails.
  • PR comment API fails.
  • Main snapshot happy path.
  • Main snapshot publish fails.
  • Main branch becomes stale before moving main.
  • Main dist-tag move fails.
  • Stable release happy path.
  • Stable release package already exists.
  • npm stable publish fails.
  • GitHub Packages stable publish fails.
  • GitHub Packages latest dist-tag move fails.

The failure cases assert that the workflow emits the clearer ::error:: messages from the templates.

What This Cannot Mock

The local harness cannot prove real npm Trusted Publishing/OIDC or real GitHub Packages authorization. Keep one disposable same-repo GitHub PR/release smoke test for those platform integrations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment