diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml new file mode 100644 index 00000000..60236360 --- /dev/null +++ b/.github/workflows/publish-preview.yml @@ -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() { + 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.* + + - 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: + contents: read + 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 }} + 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 + 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 }}