Skip to content
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
06b127c
feat: add prepare-preview-builds composite action
cryptodev-2s Feb 26, 2026
d25f5a4
feat: add reusable publish-preview workflow
cryptodev-2s Feb 26, 2026
5a9d9bc
fix: use absolute script path in prepare-preview-builds action
cryptodev-2s Feb 26, 2026
0f2aa18
test: point prepare-preview-builds action at branch ref
cryptodev-2s Feb 26, 2026
08df742
fix: resolve github-tools ref from github.workflow_ref
cryptodev-2s Feb 26, 2026
c65f89b
fix: run yarn install after polyrepo package rename
cryptodev-2s Mar 4, 2026
fa010ba
fix: strip lifecycle scripts from artifacts instead of failing
cryptodev-2s Mar 4, 2026
fad8410
fix: also strip prepare lifecycle script from artifacts
cryptodev-2s Mar 5, 2026
5fa209e
refactor: pass monorepo/polyrepo flag explicitly to message script
cryptodev-2s Mar 5, 2026
788fc30
refactor: address review feedback
cryptodev-2s Mar 9, 2026
ea06dbd
feat: add dry-run input to skip NPM publish
cryptodev-2s Mar 9, 2026
03bc0ee
fix: monorepo docs link appearing on wrong line
cryptodev-2s Mar 9, 2026
d4696d1
fix: inline message generation, remove github-tools checkout
cryptodev-2s Mar 9, 2026
9db8b7e
chore: remove unused generate-preview-build-message.sh
cryptodev-2s Mar 9, 2026
7f99a4e
refactor: inline prepare-preview-builds into workflow
cryptodev-2s Mar 16, 2026
4ae7a7f
fix: improve preview builds docs link wording
cryptodev-2s Mar 17, 2026
8a99beb
Merge branch 'main' into prepare-preview-builds-action
cryptodev-2s Mar 17, 2026
26c411a
fix: move period outside link text in docs link
cryptodev-2s Mar 17, 2026
71a6577
fix: pin npm registry in publish steps to prevent .yarnrc.yml override
cryptodev-2s Mar 17, 2026
4c4c413
fix: add contents:read permission to publish-preview job
cryptodev-2s Mar 17, 2026
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
304 changes: 304 additions & 0 deletions .github/workflows/publish-preview.yml
Comment thread
mcmire marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
name: Publish Preview Builds

on:
workflow_call:
inputs:
npm-scope:
description: 'Target NPM scope for preview packages'
type: string
required: false
default: '@metamask-previews'
source-scope:
description: 'Source NPM scope to replace'
type: string
required: false
default: '@metamask/'
build-command:
description: 'Command to build the project'
type: string
required: false
default: 'yarn build'
is-monorepo:
description: 'Whether the consumer is a monorepo (workspace-aware prepare/publish/message)'
type: boolean
required: false
default: true
environment:
description: 'GitHub environment for the publish job (e.g., default-branch). Empty = no gate.'
type: string
required: false
default: ''
artifact-retention-days:
description: 'Days to retain build artifacts'
type: number
required: false
default: 4
docs-url:
description: 'URL to preview builds documentation (included in PR comment)'
type: string
required: false
default: ''
dry-run:
description: 'Skip actual NPM publish (for testing)'
type: boolean
required: false
default: false
secrets:
PUBLISH_PREVIEW_NPM_TOKEN:
required: true

jobs:
is-fork-pull-request:
name: Determine whether this PR is from a fork
runs-on: ubuntu-latest
outputs:
IS_FORK: ${{ steps.is-fork.outputs.IS_FORK }}
steps:
- uses: actions/checkout@v5
- name: Determine whether this PR is from a fork
id: is-fork
run: echo "IS_FORK=$(gh pr view --json isCrossRepository --jq '.isCrossRepository' "${PR_NUMBER}" )" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}

react-to-comment:
name: React to comment
needs: is-fork-pull-request
if: ${{ needs.is-fork-pull-request.outputs.IS_FORK == 'false' }}
runs-on: ubuntu-latest
steps:
- name: Add reaction to trigger comment
run: |
gh api \
--method POST \
"repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \
-f content='+1'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_ID: ${{ github.event.comment.id }}

build-preview:
name: Build preview
needs: react-to-comment
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Check out pull request
run: gh pr checkout "${PR_NUMBER}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}

- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v2
with:
is-high-risk-environment: true

- name: Get commit SHA
id: commit-sha
run: echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

- name: Prepare preview builds
env:
NPM_SCOPE: ${{ inputs.npm-scope }}
COMMIT_SHA: ${{ steps.commit-sha.outputs.COMMIT_SHA }}
SOURCE_SCOPE: ${{ inputs.source-scope }}
IS_MONOREPO: ${{ inputs.is-monorepo }}
run: |
prepare_manifest() {
Copy link
Copy Markdown
Member

@Mrtenz Mrtenz Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the scripts in this workflow could probably be made easier to read/maintain by using actions/github-script.

local manifest_file="$1"
jq --raw-output \
--arg npm_scope "$NPM_SCOPE" \
--arg hash "$COMMIT_SHA" \
--arg source_scope "$SOURCE_SCOPE" \
'
.name |= sub($source_scope; "\($npm_scope)/") |
.version |= (split("-")[0] + "-preview-\($hash)")
' \
"$manifest_file" > temp.json
mv temp.json "$manifest_file"
}

if [[ "$IS_MONOREPO" == "true" ]]; then
# Add resolutions so renamed packages still resolve from local workspace
echo "Adding workspace resolutions to root manifest..."
resolutions="$(yarn workspaces list --no-private --json \
| jq --slurp 'reduce .[] as $pkg ({}; .[$pkg.name] = "portal:./" + $pkg.location)')"
jq --argjson resolutions "$resolutions" '.resolutions = ((.resolutions // {}) + $resolutions)' package.json > temp.json
mv temp.json package.json

echo "Preparing manifests..."
while IFS=$'\t' read -r location name; do
echo "- $name"
prepare_manifest "$location/package.json"
done < <(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map([.location, .name]) | map(@tsv) | .[]')
else
echo "Preparing manifest..."
prepare_manifest package.json
fi

echo "Installing dependencies..."
yarn install --no-immutable

- name: Build
run: ${{ inputs.build-command }}

- name: Upload build artifacts (monorepo)
if: ${{ inputs.is-monorepo }}
uses: actions/upload-artifact@v6
with:
name: preview-build-artifacts
include-hidden-files: true
retention-days: ${{ inputs.artifact-retention-days }}
path: |
./yarn.lock
./package.json
./packages/*/
!./packages/*/node_modules/
!./packages/*/src/
!./packages/*/tests/
!./packages/**/*.test.*
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Monorepo artifact paths hardcode packages/ directory structure

Medium Severity

The monorepo upload glob ./packages/*/ and the sanitization find packages both hardcode the packages/ directory. However, prepare-preview-builds.sh dynamically discovers workspaces via yarn workspaces list, which works for any directory layout. Monorepos with workspaces outside packages/ (e.g., libs/, modules/) would have their manifests prepared but the built output wouldn't be uploaded or security-validated before publishing.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

@cryptodev-2s cryptodev-2s Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All MetaMask monorepos use packages/, this matches the existing core workflow convention. If a repo ever uses a different layout, we can add a workspace-glob input then.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could generate this glob automatically by looping through the workspaces field in package.json. But yes I agree that probably don't need to worry about this for v1.


- name: Upload build artifacts (polyrepo)
if: ${{ !inputs.is-monorepo }}
uses: actions/upload-artifact@v6
with:
name: preview-build-artifacts
include-hidden-files: true
retention-days: ${{ inputs.artifact-retention-days }}
path: |
.
!./node_modules/
!./.git/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Polyrepo artifact includes config files bypassing registry check

Medium Severity

The polyrepo artifact uploads the entire working tree (minus node_modules and .git), which includes .yarnrc.yml. The sanitization step only validates publishConfig.registry in package.json files. A malicious PR could set npmRegistryServer or npmScopes in .yarnrc.yml to redirect yarn npm publish to an attacker-controlled server, exfiltrating the PUBLISH_PREVIEW_NPM_TOKEN. The monorepo path is not affected since its artifact only includes specific paths.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

@mcmire mcmire Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we need .yarnrc.yml to run yarn. I guess this is true though. Do we need to perform validation on .yarnrc.yml too? This is getting a bit out of hand 🫤

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed here 71a6577

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good fix.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Polyrepo artifacts include unsanitized .yarnrc.yml config

Medium Severity

The polyrepo artifact upload includes the entire workspace (excluding only node_modules/ and .git/), which means .yarnrc.yml from the PR branch is included. The sanitize step only validates package.json files for unexpected registries but does not inspect .yarnrc.yml. A malicious PR could add npmScopes.metamask-previews.npmRegistryServer pointing to an attacker-controlled URL, causing yarn npm publish to send the YARN_NPM_AUTH_TOKEN to that server. YARN_NPM_REGISTRY_SERVER only overrides the global npmRegistryServer, not scoped registries.

Additional Locations (1)
Fix in Cursor Fix in Web


publish-preview:
name: Publish preview
needs: build-preview
permissions:
contents: read
pull-requests: write
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing contents: read permission breaks checkout step

High Severity

The publish-preview job sets permissions: pull-requests: write without including contents: read. When explicit permissions are set, all unspecified scopes default to none. The MetaMask/action-checkout-and-setup@v2 action performs a git checkout internally, which requires contents: read. Comparing with create-release-pr.yml in this same repo, which correctly specifies both contents: write and pull-requests: write when using the same action, confirms this is an oversight. This will cause the checkout to fail, at minimum for private repos.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed here 4c4c413

environment: ${{ inputs.environment }}
runs-on: ubuntu-latest
steps:
- name: Checkout and setup environment
uses: MetaMask/action-checkout-and-setup@v2
with:
is-high-risk-environment: true

- name: Restore build artifacts
uses: actions/download-artifact@v7
with:
name: preview-build-artifacts

# The artifact package.json files come from the PR branch.
# A malicious PR could inject lifecycle scripts (prepack/postpack) that
# execute during `yarn npm publish` with the NPM token in the environment
# (enableScripts: false does NOT prevent pack/publish lifecycle scripts).
# It could also override publishConfig.registry to exfiltrate the token.
# We strip dangerous lifecycle scripts (they already ran during build)
# and block unexpected registries outright.
- name: Sanitize and validate artifact manifests
env:
IS_MONOREPO: ${{ inputs.is-monorepo }}
run: |
bad=0
if [[ "$IS_MONOREPO" == "true" ]]; then
mapfile -t manifests < <(find packages -name package.json -not -path '*/node_modules/*')
else
manifests=(package.json)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Monorepo sanitization skips root package.json manifest

Medium Severity

In monorepo mode, the sanitization step only scans packages/ for package.json files but the root package.json is also included in the uploaded artifacts (line 142). A malicious PR could inject lifecycle scripts (e.g., postinstall) into the root package.json, which would execute during the yarn install --no-immutable step in the publish job. While the NPM token isn't in the environment at that point, the GITHUB_TOKEN with pull-requests: write permission is accessible to the running process.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow-scripts already takes care of this, so I think we are okay?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes not a real concern

fi
if [[ ${#manifests[@]} -eq 0 ]]; then
echo "::error::No package.json files found to validate"
exit 1
fi
for f in "${manifests[@]}"; do
# Strip lifecycle scripts that run during pack/publish
if jq -e '.scripts // {} | keys[] | select(test("^(pre|post)?(pack|publish|prepare)$"))' "$f" > /dev/null 2>&1; then
echo "Stripping lifecycle scripts from $f"
jq 'if .scripts then .scripts |= with_entries(select(.key | test("^(pre|post)?(pack|publish|prepare)$") | not)) else . end' "$f" > "${f}.tmp"
mv "${f}.tmp" "$f"
fi
Comment thread
cursor[bot] marked this conversation as resolved.
# Block unexpected registries
reg=$(jq -r '.publishConfig.registry // ""' "$f")
if [[ -n "$reg" && "$reg" != "https://registry.npmjs.org/" ]]; then
echo "::error::Unexpected registry in $f: $reg"
bad=1
fi
done
exit "$bad"

- name: Reconcile workspace state
run: yarn install --no-immutable

- name: Publish preview builds (monorepo)
if: ${{ inputs.is-monorepo && !inputs.dry-run }}
run: yarn workspaces foreach --no-private --all exec yarn npm publish --tag preview
env:
YARN_NPM_AUTH_TOKEN: ${{ secrets.PUBLISH_PREVIEW_NPM_TOKEN }}
YARN_NPM_REGISTRY_SERVER: 'https://registry.npmjs.org'

- name: Publish preview builds (polyrepo)
if: ${{ !inputs.is-monorepo && !inputs.dry-run }}
run: yarn npm publish --tag preview
env:
YARN_NPM_AUTH_TOKEN: ${{ secrets.PUBLISH_PREVIEW_NPM_TOKEN }}
YARN_NPM_REGISTRY_SERVER: 'https://registry.npmjs.org'

- name: Dry run notice
if: ${{ inputs.dry-run }}
run: echo "Dry run — skipping publish"

- name: Generate preview build message
env:
IS_MONOREPO: ${{ inputs.is-monorepo }}
DOCS_URL: ${{ inputs.docs-url }}
run: |
docs_link=""
if [[ -n "$DOCS_URL" ]]; then
docs_link="[Learn how to use preview builds in other projects](${DOCS_URL})."
fi

if [[ "$IS_MONOREPO" == "true" ]]; then
packages="$(
yarn workspaces list --no-private --json \
| jq --raw-output '.location' \
| xargs -I{} cat '{}/package.json' \
| jq --raw-output '"\(.name)@\(.version)"'
)"
echo -n "Preview builds have been published." > preview-build-message.txt
Comment thread
cryptodev-2s marked this conversation as resolved.
if [[ -n "$docs_link" ]]; then
echo -n " ${docs_link}" >> preview-build-message.txt
fi
cat <<-MSGEOF >> preview-build-message.txt

<details>
<summary>Expand for full list of packages and versions.</summary>

\`\`\`
${packages}
\`\`\`

</details>
MSGEOF
else
name="$(jq -r '.name' package.json)"
version="$(jq -r '.version' package.json)"
cat <<-MSGEOF > preview-build-message.txt
The following preview build has been published:

\`\`\`
${name}@${version}
\`\`\`
MSGEOF
if [[ -n "$docs_link" ]]; then
printf '\n%s\n' "$docs_link" >> preview-build-message.txt
Comment thread
cryptodev-2s marked this conversation as resolved.
fi
fi

- name: Post build preview in comment
run: gh pr comment "${PR_NUMBER}" --body-file preview-build-message.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number }}
Loading