diff --git a/.github/workflows/check-cdn-types.yml b/.github/workflows/check-cdn-types.yml new file mode 100644 index 0000000..4a81db9 --- /dev/null +++ b/.github/workflows/check-cdn-types.yml @@ -0,0 +1,61 @@ +name: Check CDN Types + +on: + schedule: + - cron: '0 10 * * *' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + check: + name: Check for CDN type updates + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repo + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + + - name: Setup Node.js + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 + with: + node-version-file: .nvmrc + + - name: Check for CDN updates + id: check + run: node packages/app-bridge-types/scripts/check-cdn-updates.mjs + + - name: Create PR + if: steps.check.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BODY_FILE: ${{ steps.check.outputs.pr_body_file }} + run: | + BRANCH="automated/update-app-bridge-types" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git checkout -B "$BRANCH" + git add .changeset/automated-cdn-types-update.md + git commit -m "chore: update app-bridge-types CDN types" + git push --force origin "$BRANCH" + + EXISTING_PR=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number') + + if [ -n "$EXISTING_PR" ]; then + echo "Updating existing PR #$EXISTING_PR" + gh pr edit "$EXISTING_PR" --body-file "$PR_BODY_FILE" + else + echo "Creating new PR" + gh pr create \ + --title "Update app-bridge-types CDN types" \ + --body-file "$PR_BODY_FILE" \ + --base main \ + --head "$BRANCH" + fi diff --git a/packages/app-bridge-types/scripts/check-cdn-updates.mjs b/packages/app-bridge-types/scripts/check-cdn-updates.mjs new file mode 100644 index 0000000..74165bf --- /dev/null +++ b/packages/app-bridge-types/scripts/check-cdn-updates.mjs @@ -0,0 +1,186 @@ +import {writeFileSync, readFileSync, mkdirSync, mkdtempSync} from 'node:fs'; +import {execSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {resolve, dirname, join} from 'node:path'; +import {tmpdir} from 'node:os'; + +const CDN_URL = 'https://cdn.shopify.com/shopifycloud/app-bridge.d.ts'; +const NPM_PACKAGE = '@shopify/app-bridge-types'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..', '..'); + +/** + * Set a GitHub Actions output variable. No-op outside of Actions. + */ +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + writeFileSync(outputFile, `${name}=${value}\n`, {flag: 'a'}); + } +} + +/** + * Normalize whitespace so trivial formatting differences don't trigger a diff. + */ +function normalize(text) { + return text.replace(/\r\n/g, '\n').trimEnd() + '\n'; +} + +/** + * Fetch text from a URL. Returns null if the request fails. + */ +async function fetchText(url) { + try { + const response = await fetch(url); + if (!response.ok) { + console.log(`${url} returned ${response.status}`); + return null; + } + return await response.text(); + } catch (error) { + console.log(`Failed to fetch ${url}: ${error.message}`); + return null; + } +} + +/** + * Get the published index.d.ts from npm via `npm pack`. + * Returns the file contents as a string, or null if the package isn't published. + */ +function getPublishedTypes(workDir) { + try { + execSync(`npm pack ${NPM_PACKAGE} --pack-destination "${workDir}"`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + console.log( + `npm pack failed (package may not be published yet): ${error.message}`, + ); + return null; + } + + try { + execSync( + `tar xzf "${workDir}"/*.tgz -C "${workDir}" package/dist/index.d.ts`, + {encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']}, + ); + return readFileSync( + join(workDir, 'package', 'dist', 'index.d.ts'), + 'utf-8', + ); + } catch (error) { + console.log(`Failed to extract types from tarball: ${error.message}`); + return null; + } +} + +// --- Main --- + +const cdnText = await fetchText(CDN_URL); + +if (cdnText == null) { + console.log('CDN is unavailable. Exiting without changes.'); + setOutput('has_changes', 'false'); + process.exit(0); +} + +// If npm package isn't published yet, treat published types as empty +const npmWorkDir = mkdtempSync(join(tmpdir(), 'npm-pack-')); +const npmText = getPublishedTypes(npmWorkDir) ?? ''; + +const normalizedCdn = normalize(cdnText); +const normalizedNpm = normalize(npmText); + +if (normalizedCdn === normalizedNpm) { + console.log('CDN types match published npm types. No changes needed.'); + setOutput('has_changes', 'false'); + process.exit(0); +} + +console.log('CDN types differ from published npm types. Generating changeset.'); + +// Write both versions to temp files for diffing +const tempDir = mkdtempSync(join(tmpdir(), 'cdn-types-')); +const npmFile = join(tempDir, 'npm.d.ts'); +const cdnFile = join(tempDir, 'cdn.d.ts'); +writeFileSync(npmFile, normalizedNpm); +writeFileSync(cdnFile, normalizedCdn); + +// Generate unified diff +let diff; +try { + // diff exits with 1 when files differ, which is expected + diff = execSync(`diff -u "${npmFile}" "${cdnFile}"`, { + encoding: 'utf-8', + }); +} catch (error) { + // diff returns exit code 1 when files differ — that's the expected path + diff = error.stdout ?? ''; +} + +// Replace temp file paths with readable labels in the diff header +diff = diff + .replace( + new RegExp(npmFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + 'npm (published)', + ) + .replace( + new RegExp(cdnFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + 'cdn (latest)', + ); + +// Count diff stats +const additions = (diff.match(/^\+[^+]/gm) || []).length; +const deletions = (diff.match(/^-[^-]/gm) || []).length; + +// Write changeset +const changesetDir = resolve(repoRoot, '.changeset'); +mkdirSync(changesetDir, {recursive: true}); + +const changesetFile = resolve(changesetDir, 'automated-cdn-types-update.md'); +const changesetContent = `--- +'@shopify/app-bridge-types': patch +--- + +Automated update of CDN types (+${additions} -${deletions} lines) + +See the [App Bridge changelog](https://shopify.dev/changelog?filter=api&api_type=app-bridge) for details. +`; +writeFileSync(changesetFile, changesetContent); +console.log(`Wrote changeset to ${changesetFile}`); + +// Write PR body to temp file (truncate large diffs) +const MAX_DIFF_LENGTH = 60_000; +const truncatedDiff = + diff.length > MAX_DIFF_LENGTH + ? diff.slice(0, MAX_DIFF_LENGTH) + '\n\n... (diff truncated)' + : diff; + +const prBody = `## Automated CDN Types Update + +The types at \`${CDN_URL}\` have changed compared to the published \`@shopify/app-bridge-types\` package. + +**Stats:** +${additions} -${deletions} lines + +
+Full diff + +\`\`\`diff +${truncatedDiff} +\`\`\` + +
+ +Merging this PR will trigger a patch release of \`@shopify/app-bridge-types\`. +`; + +const prBodyFile = join(tempDir, 'pr-body.md'); +writeFileSync(prBodyFile, prBody); + +setOutput('has_changes', 'true'); +setOutput('pr_body_file', prBodyFile); + +console.log(`PR body written to ${prBodyFile}`); +console.log('Done.');