Skip to content

Commit 2e2bc43

Browse files
authored
Merge branch 'main' into harry/MMQA-ff-drift-and-create-pr
2 parents f5da207 + 28b7b9f commit 2e2bc43

3 files changed

Lines changed: 321 additions & 2 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
name: Publish Preview Builds
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
npm-scope:
7+
description: 'Target NPM scope for preview packages'
8+
type: string
9+
required: false
10+
default: '@metamask-previews'
11+
source-scope:
12+
description: 'Source NPM scope to replace'
13+
type: string
14+
required: false
15+
default: '@metamask/'
16+
build-command:
17+
description: 'Command to build the project'
18+
type: string
19+
required: false
20+
default: 'yarn build'
21+
is-monorepo:
22+
description: 'Whether the consumer is a monorepo (workspace-aware prepare/publish/message)'
23+
type: boolean
24+
required: false
25+
default: true
26+
environment:
27+
description: 'GitHub environment for the publish job (e.g., default-branch). Empty = no gate.'
28+
type: string
29+
required: false
30+
default: ''
31+
artifact-retention-days:
32+
description: 'Days to retain build artifacts'
33+
type: number
34+
required: false
35+
default: 4
36+
docs-url:
37+
description: 'URL to preview builds documentation (included in PR comment)'
38+
type: string
39+
required: false
40+
default: ''
41+
dry-run:
42+
description: 'Skip actual NPM publish (for testing)'
43+
type: boolean
44+
required: false
45+
default: false
46+
secrets:
47+
PUBLISH_PREVIEW_NPM_TOKEN:
48+
required: true
49+
50+
jobs:
51+
is-fork-pull-request:
52+
name: Determine whether this PR is from a fork
53+
runs-on: ubuntu-latest
54+
outputs:
55+
IS_FORK: ${{ steps.is-fork.outputs.IS_FORK }}
56+
steps:
57+
- uses: actions/checkout@v5
58+
- name: Determine whether this PR is from a fork
59+
id: is-fork
60+
run: echo "IS_FORK=$(gh pr view --json isCrossRepository --jq '.isCrossRepository' "${PR_NUMBER}" )" >> "$GITHUB_OUTPUT"
61+
env:
62+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63+
PR_NUMBER: ${{ github.event.issue.number }}
64+
65+
react-to-comment:
66+
name: React to comment
67+
needs: is-fork-pull-request
68+
if: ${{ needs.is-fork-pull-request.outputs.IS_FORK == 'false' }}
69+
runs-on: ubuntu-latest
70+
steps:
71+
- name: Add reaction to trigger comment
72+
run: |
73+
gh api \
74+
--method POST \
75+
"repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \
76+
-f content='+1'
77+
env:
78+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79+
COMMENT_ID: ${{ github.event.comment.id }}
80+
81+
build-preview:
82+
name: Build preview
83+
needs: react-to-comment
84+
runs-on: ubuntu-latest
85+
steps:
86+
- uses: actions/checkout@v5
87+
88+
- name: Check out pull request
89+
run: gh pr checkout "${PR_NUMBER}"
90+
env:
91+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
92+
PR_NUMBER: ${{ github.event.issue.number }}
93+
94+
- name: Checkout and setup environment
95+
uses: MetaMask/action-checkout-and-setup@v2
96+
with:
97+
is-high-risk-environment: true
98+
99+
- name: Get commit SHA
100+
id: commit-sha
101+
run: echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
102+
103+
- name: Prepare preview builds
104+
env:
105+
NPM_SCOPE: ${{ inputs.npm-scope }}
106+
COMMIT_SHA: ${{ steps.commit-sha.outputs.COMMIT_SHA }}
107+
SOURCE_SCOPE: ${{ inputs.source-scope }}
108+
IS_MONOREPO: ${{ inputs.is-monorepo }}
109+
run: |
110+
prepare_manifest() {
111+
local manifest_file="$1"
112+
jq --raw-output \
113+
--arg npm_scope "$NPM_SCOPE" \
114+
--arg hash "$COMMIT_SHA" \
115+
--arg source_scope "$SOURCE_SCOPE" \
116+
'
117+
.name |= sub($source_scope; "\($npm_scope)/") |
118+
.version |= (split("-")[0] + "-preview-\($hash)")
119+
' \
120+
"$manifest_file" > temp.json
121+
mv temp.json "$manifest_file"
122+
}
123+
124+
if [[ "$IS_MONOREPO" == "true" ]]; then
125+
# Add resolutions so renamed packages still resolve from local workspace
126+
echo "Adding workspace resolutions to root manifest..."
127+
resolutions="$(yarn workspaces list --no-private --json \
128+
| jq --slurp 'reduce .[] as $pkg ({}; .[$pkg.name] = "portal:./" + $pkg.location)')"
129+
jq --argjson resolutions "$resolutions" '.resolutions = ((.resolutions // {}) + $resolutions)' package.json > temp.json
130+
mv temp.json package.json
131+
132+
echo "Preparing manifests..."
133+
while IFS=$'\t' read -r location name; do
134+
echo "- $name"
135+
prepare_manifest "$location/package.json"
136+
done < <(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map([.location, .name]) | map(@tsv) | .[]')
137+
else
138+
echo "Preparing manifest..."
139+
prepare_manifest package.json
140+
fi
141+
142+
echo "Installing dependencies..."
143+
yarn install --no-immutable
144+
145+
- name: Build
146+
run: ${{ inputs.build-command }}
147+
148+
- name: Upload build artifacts (monorepo)
149+
if: ${{ inputs.is-monorepo }}
150+
uses: actions/upload-artifact@v6
151+
with:
152+
name: preview-build-artifacts
153+
include-hidden-files: true
154+
retention-days: ${{ inputs.artifact-retention-days }}
155+
path: |
156+
./yarn.lock
157+
./package.json
158+
./packages/*/
159+
!./packages/*/node_modules/
160+
!./packages/*/src/
161+
!./packages/*/tests/
162+
!./packages/**/*.test.*
163+
164+
- name: Upload build artifacts (polyrepo)
165+
if: ${{ !inputs.is-monorepo }}
166+
uses: actions/upload-artifact@v6
167+
with:
168+
name: preview-build-artifacts
169+
include-hidden-files: true
170+
retention-days: ${{ inputs.artifact-retention-days }}
171+
path: |
172+
.
173+
!./node_modules/
174+
!./.git/
175+
176+
publish-preview:
177+
name: Publish preview
178+
needs: build-preview
179+
permissions:
180+
contents: read
181+
pull-requests: write
182+
environment: ${{ inputs.environment }}
183+
runs-on: ubuntu-latest
184+
steps:
185+
- name: Checkout and setup environment
186+
uses: MetaMask/action-checkout-and-setup@v2
187+
with:
188+
is-high-risk-environment: true
189+
190+
- name: Restore build artifacts
191+
uses: actions/download-artifact@v7
192+
with:
193+
name: preview-build-artifacts
194+
195+
# The artifact package.json files come from the PR branch.
196+
# A malicious PR could inject lifecycle scripts (prepack/postpack) that
197+
# execute during `yarn npm publish` with the NPM token in the environment
198+
# (enableScripts: false does NOT prevent pack/publish lifecycle scripts).
199+
# It could also override publishConfig.registry to exfiltrate the token.
200+
# We strip dangerous lifecycle scripts (they already ran during build)
201+
# and block unexpected registries outright.
202+
- name: Sanitize and validate artifact manifests
203+
env:
204+
IS_MONOREPO: ${{ inputs.is-monorepo }}
205+
run: |
206+
bad=0
207+
if [[ "$IS_MONOREPO" == "true" ]]; then
208+
mapfile -t manifests < <(find packages -name package.json -not -path '*/node_modules/*')
209+
else
210+
manifests=(package.json)
211+
fi
212+
if [[ ${#manifests[@]} -eq 0 ]]; then
213+
echo "::error::No package.json files found to validate"
214+
exit 1
215+
fi
216+
# Strip registry overrides from .yarnrc.yml to prevent registry
217+
# redirects that could exfiltrate the NPM token. npmPublishRegistry
218+
# takes precedence over npmRegistryServer for yarn npm publish, and
219+
# npmScopes can override per-scope. YARN_NPM_REGISTRY_SERVER env var
220+
# only overrides npmRegistryServer, not the others.
221+
if [[ -f .yarnrc.yml ]]; then
222+
echo "Stripping registry config from .yarnrc.yml"
223+
yq -i 'del(.npmRegistryServer) | del(.npmPublishRegistry) | del(.npmScopes)' .yarnrc.yml
224+
fi
225+
for f in "${manifests[@]}"; do
226+
# Strip lifecycle scripts that run during pack/publish
227+
if jq -e '.scripts // {} | keys[] | select(test("^(pre|post)?(pack|publish|prepare)$"))' "$f" > /dev/null 2>&1; then
228+
echo "Stripping lifecycle scripts from $f"
229+
jq 'if .scripts then .scripts |= with_entries(select(.key | test("^(pre|post)?(pack|publish|prepare)$") | not)) else . end' "$f" > "${f}.tmp"
230+
mv "${f}.tmp" "$f"
231+
fi
232+
# Block unexpected registries
233+
reg=$(jq -r '.publishConfig.registry // ""' "$f")
234+
if [[ -n "$reg" && "$reg" != "https://registry.npmjs.org/" ]]; then
235+
echo "::error::Unexpected registry in $f: $reg"
236+
bad=1
237+
fi
238+
done
239+
exit "$bad"
240+
241+
- name: Reconcile workspace state
242+
run: yarn install --no-immutable
243+
244+
- name: Publish preview builds (monorepo)
245+
if: ${{ inputs.is-monorepo && !inputs.dry-run }}
246+
run: yarn workspaces foreach --no-private --all exec yarn npm publish --tag preview
247+
env:
248+
YARN_NPM_AUTH_TOKEN: ${{ secrets.PUBLISH_PREVIEW_NPM_TOKEN }}
249+
YARN_NPM_REGISTRY_SERVER: 'https://registry.npmjs.org'
250+
251+
- name: Publish preview builds (polyrepo)
252+
if: ${{ !inputs.is-monorepo && !inputs.dry-run }}
253+
run: yarn npm publish --tag preview
254+
env:
255+
YARN_NPM_AUTH_TOKEN: ${{ secrets.PUBLISH_PREVIEW_NPM_TOKEN }}
256+
YARN_NPM_REGISTRY_SERVER: 'https://registry.npmjs.org'
257+
258+
- name: Dry run notice
259+
if: ${{ inputs.dry-run }}
260+
run: echo "Dry run — skipping publish"
261+
262+
- name: Generate preview build message
263+
env:
264+
IS_MONOREPO: ${{ inputs.is-monorepo }}
265+
DOCS_URL: ${{ inputs.docs-url }}
266+
run: |
267+
docs_link=""
268+
if [[ -n "$DOCS_URL" ]]; then
269+
docs_link="[Learn how to use preview builds in other projects](${DOCS_URL})."
270+
fi
271+
272+
if [[ "$IS_MONOREPO" == "true" ]]; then
273+
packages="$(
274+
yarn workspaces list --no-private --json \
275+
| jq --raw-output '.location' \
276+
| xargs -I{} cat '{}/package.json' \
277+
| jq --raw-output '"\(.name)@\(.version)"'
278+
)"
279+
echo -n "Preview builds have been published." > preview-build-message.txt
280+
if [[ -n "$docs_link" ]]; then
281+
echo -n " ${docs_link}" >> preview-build-message.txt
282+
fi
283+
cat <<-MSGEOF >> preview-build-message.txt
284+
285+
<details>
286+
<summary>Expand for full list of packages and versions.</summary>
287+
288+
\`\`\`
289+
${packages}
290+
\`\`\`
291+
292+
</details>
293+
MSGEOF
294+
else
295+
name="$(jq -r '.name' package.json)"
296+
version="$(jq -r '.version' package.json)"
297+
cat <<-MSGEOF > preview-build-message.txt
298+
The following preview build has been published:
299+
300+
\`\`\`
301+
${name}@${version}
302+
\`\`\`
303+
MSGEOF
304+
if [[ -n "$docs_link" ]]; then
305+
printf '\n%s\n' "$docs_link" >> preview-build-message.txt
306+
fi
307+
fi
308+
309+
- name: Post build preview in comment
310+
run: gh pr comment "${PR_NUMBER}" --body-file preview-build-message.txt
311+
env:
312+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
313+
PR_NUMBER: ${{ github.event.issue.number }}

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Add `feature-flag-drift-report` action to notify Slack when feature flag drift is detected
1313
- Add `check-feature-flag-registry-drift` reusable workflow to create PRs for feature flag registry updates
14+
## [1.8.0]
15+
16+
### Added
17+
18+
- Add reusable `publish-preview` workflow for publishing preview builds ([#223](https://github.com/MetaMask/github-tools/pull/223), [#227](https://github.com/MetaMask/github-tools/pull/227))
1419

1520
## [1.7.1]
1621

@@ -143,7 +148,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
143148
- Some inputs were renamed for consistency across actions.
144149
- Bump `actions/checkout` and `actions/setup-node` to `v6` ([#173](https://github.com/MetaMask/github-tools/pull/173))
145150

146-
[Unreleased]: https://github.com/MetaMask/github-tools/compare/v1.7.1...HEAD
151+
[Unreleased]: https://github.com/MetaMask/github-tools/compare/v1.8.0...HEAD
152+
[1.8.0]: https://github.com/MetaMask/github-tools/compare/v1.7.1...v1.8.0
147153
[1.7.1]: https://github.com/MetaMask/github-tools/compare/v1.7.0...v1.7.1
148154
[1.7.0]: https://github.com/MetaMask/github-tools/compare/v1.6.0...v1.7.0
149155
[1.6.0]: https://github.com/MetaMask/github-tools/compare/v1.5.0...v1.6.0

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metamask/github-tools",
3-
"version": "1.7.1",
3+
"version": "1.8.0",
44
"private": true,
55
"description": "Tools for interacting with the GitHub API to do metrics gathering",
66
"repository": {

0 commit comments

Comments
 (0)