diff --git a/.github/actions/prepare-preview-builds/action.yml b/.github/actions/prepare-preview-builds/action.yml new file mode 100644 index 00000000..58c3bdfa --- /dev/null +++ b/.github/actions/prepare-preview-builds/action.yml @@ -0,0 +1,56 @@ +name: Prepare Preview Builds + +description: > + Renames workspace packages to a preview NPM scope and adds a prerelease + version tag so they can be published as preview builds. + +inputs: + npm-scope: + description: 'Target NPM scope for preview packages (e.g., @metamask-previews)' + required: true + commit-hash: + description: 'Short commit hash used as the prerelease version tag' + required: true + source-scope: + description: 'Source NPM scope to replace (e.g., @metamask/)' + required: false + default: '@metamask/' + working-directory: + description: 'Path to the monorepo root' + required: false + default: '.' + github-tools-repository: + description: 'The GitHub repository containing the GitHub tools. Defaults to the GitHub tools action repository, and usually does not need to be changed.' + required: false + default: ${{ github.action_repository }} + github-tools-ref: + description: 'The SHA of the action to use. Defaults to the current action ref, and usually does not need to be changed.' + required: false + default: ${{ github.action_ref }} + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + run: | + if [[ -z "${{ inputs.npm-scope }}" ]]; then + echo "::error::npm-scope input is required" + exit 1 + fi + if [[ -z "${{ inputs.commit-hash }}" ]]; then + echo "::error::commit-hash input is required" + exit 1 + fi + + - name: Checkout GitHub tools repository + uses: actions/checkout@v6 + with: + repository: ${{ inputs.github-tools-repository }} + ref: ${{ inputs.github-tools-ref }} + path: ./.github-tools + + - name: Prepare preview builds + shell: bash + working-directory: ${{ inputs.working-directory }} + run: bash "$GITHUB_WORKSPACE/.github-tools/.github/scripts/prepare-preview-builds.sh" "${{ inputs.npm-scope }}" "${{ inputs.commit-hash }}" "${{ inputs.source-scope }}" diff --git a/.github/scripts/prepare-preview-builds.sh b/.github/scripts/prepare-preview-builds.sh new file mode 100755 index 00000000..6b54623c --- /dev/null +++ b/.github/scripts/prepare-preview-builds.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This script prepares workspace packages to be published as preview builds. +# It renames packages to a preview NPM scope, sets prerelease versions, and +# reinstalls dependencies so that internal references resolve correctly. +# +# Usage: prepare-preview-builds.sh [source_scope] +# npm_scope - Target NPM scope (e.g., @metamask-previews) +# commit_hash - Short commit hash for the prerelease tag +# source_scope - Source scope to replace (default: @metamask/) + +if [[ $# -lt 2 ]]; then + echo "::error::Usage: prepare-preview-builds.sh [source_scope]" + exit 1 +fi + +npm_scope="$1" +commit_hash="$2" +source_scope="${3:-@metamask/}" + +prepare-preview-manifest() { + local manifest_file="$1" + + # Inline jq filter: rename scope and set prerelease version. + # jq does not support in-place modification, so a temporary file is used. + jq --raw-output \ + --arg npm_scope "$npm_scope" \ + --arg hash "$commit_hash" \ + --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" +} + +# Add resolutions to the root manifest so that imports under the source scope +# continue to resolve from the local workspace after packages are renamed to +# the preview scope. Without this, yarn resolves them from the npm registry, +# which causes build failures when workspace packages contain changes not yet +# published. +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-preview-manifest "$location/package.json" +done < <(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map([.location, .name]) | map(@tsv) | .[]') + +echo "Installing dependencies..." +yarn install --no-immutable diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml new file mode 100644 index 00000000..865eb61a --- /dev/null +++ b/.github/workflows/publish-preview.yml @@ -0,0 +1,286 @@ +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 (monorepo) + if: ${{ inputs.is-monorepo }} + uses: MetaMask/github-tools/.github/actions/prepare-preview-builds@prepare-preview-builds-action + with: + npm-scope: ${{ inputs.npm-scope }} + commit-hash: ${{ steps.commit-sha.outputs.COMMIT_SHA }} + source-scope: ${{ inputs.source-scope }} + + - name: Prepare preview builds (polyrepo) + if: ${{ !inputs.is-monorepo }} + run: | + jq \ + --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)") + ' \ + package.json > temp.json + mv temp.json package.json + yarn install --no-immutable + env: + NPM_SCOPE: ${{ inputs.npm-scope }} + COMMIT_SHA: ${{ steps.commit-sha.outputs.COMMIT_SHA }} + SOURCE_SCOPE: ${{ inputs.source-scope }} + + - 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.* + + - 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/ + + publish-preview: + name: Publish preview + needs: build-preview + permissions: + pull-requests: write + 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) + 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 + # 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 }} + + - 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 }} + + - 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="[See these instructions](${DOCS_URL}) for more information about preview builds." + 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 + if [[ -n "$docs_link" ]]; then + echo -n " ${docs_link}" >> preview-build-message.txt + fi + cat <<-MSGEOF >> preview-build-message.txt + +
+ Expand for full list of packages and versions. + + \`\`\` + ${packages} + \`\`\` + +
+ 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 + 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 }}