-
-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add reusable publish-preview workflow #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06b127c
d25f5a4
5a9d9bc
0f2aa18
08df742
c65f89b
fa010ba
fad8410
5fa209e
788fc30
ea06dbd
03bc0ee
d4696d1
9db8b7e
7f99a4e
4ae7a7f
8a99beb
26c411a
71a6577
4c4c413
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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.* | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Monorepo artifact paths hardcode
|
||
|
|
||
| - 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/ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Polyrepo artifact includes config files bypassing registry checkMedium Severity The polyrepo artifact uploads the entire working tree (minus Additional Locations (1)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, we need
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed here 71a6577
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good fix. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Polyrepo artifacts include unsanitized
|
||
|
|
||
| publish-preview: | ||
| name: Publish preview | ||
| needs: build-preview | ||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing
|
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Monorepo sanitization skips root package.json manifestMedium Severity In monorepo mode, the sanitization step only scans
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
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 | ||
|
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 | ||
|
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 }} | ||


Uh oh!
There was an error while loading. Please reload this page.