diff --git a/.gitverse/workflows/ci.yml b/.gitverse/workflows/ci.yml new file mode 100644 index 00000000..390eab36 --- /dev/null +++ b/.gitverse/workflows/ci.yml @@ -0,0 +1,336 @@ +name: CI (Gitverse mirror) + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + license: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: License validator + uses: ./.github/actions/license + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: ESLint + uses: ./.github/actions/eslint + + - name: Stylelint + uses: ./.github/actions/stylelint + + typescript: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect TypeScript-impacting changes + id: typescript_changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if [ -z "$BASE_SHA" ] || printf '%s\n' "$BASE_SHA" | grep -Eq '^0+$'; then + echo "run_typescript=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed_files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + printf '%s\n' "$changed_files" + + if printf '%s\n' "$changed_files" | grep -Eq '^(src/|package\.json$|pnpm-lock\.yaml$|tsconfig\.json$|vite\.config\.ts$|vitest\.config\.ts$|\.github/actions/typescript/)'; then + echo "run_typescript=true" >> "$GITHUB_OUTPUT" + else + echo "run_typescript=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip TypeScript + if: steps.typescript_changes.outputs.run_typescript != 'true' + run: echo "Skipping TypeScript because this change does not affect TypeScript inputs." + + - name: TypeScript + if: steps.typescript_changes.outputs.run_typescript == 'true' + uses: ./.github/actions/typescript + + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect unit-test-impacting changes + id: unit_changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if [ -z "$BASE_SHA" ] || printf '%s\n' "$BASE_SHA" | grep -Eq '^0+$'; then + echo "run_unit_tests=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed_files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + printf '%s\n' "$changed_files" + + if printf '%s\n' "$changed_files" | grep -Eq '^(src/|tests/|package\.json$|pnpm-lock\.yaml$|tsconfig\.json$|vite\.config\.ts$|vitest\.config\.ts$)'; then + echo "run_unit_tests=true" >> "$GITHUB_OUTPUT" + else + echo "run_unit_tests=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip unit tests + if: steps.unit_changes.outputs.run_unit_tests != 'true' + run: echo "Skipping unit tests because this change does not affect unit-test inputs." + + - name: Setup pnpm + if: steps.unit_changes.outputs.run_unit_tests == 'true' + uses: pnpm/action-setup@v2 + with: + version: 10.10.0 + + - name: Install dependencies + if: steps.unit_changes.outputs.run_unit_tests == 'true' + run: pnpm install --frozen-lockfile + + - name: Run unit tests + if: steps.unit_changes.outputs.run_unit_tests == 'true' + run: pnpm test:unit + + - name: Archive test results + if: always() && steps.unit_changes.outputs.run_unit_tests == 'true' + uses: actions/upload-artifact@v4 + with: + name: test-results + path: reports/unit/ + retention-days: 5 + + build: + needs: [license, lint, typescript, unit-tests] + runs-on: ubuntu-latest + outputs: + head_sha: ${{ steps.set_build_metadata.outputs.head_sha }} + short_sha: ${{ steps.set_build_metadata.outputs.short_sha }} + chrome_artifact: ${{ steps.set_build_metadata.outputs.chrome_artifact }} + firefox_artifact: ${{ steps.set_build_metadata.outputs.firefox_artifact }} + firefox_sources_artifact: ${{ steps.set_build_metadata.outputs.firefox_sources_artifact }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.10.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set build metadata + id: set_build_metadata + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + SHORT_SHA="$(echo "$HEAD_SHA" | cut -c1-7)" + + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "chrome_artifact=cloudhood-chrome-$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "firefox_artifact=cloudhood-firefox-$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "firefox_sources_artifact=cloudhood-firefox-sources-$HEAD_SHA" >> "$GITHUB_OUTPUT" + + - name: Build Chrome extension + run: pnpm build:chromium + + - name: Check Chrome build + run: pnpm check:chrome-build + + - name: Pack Chrome extension + run: | + cd build/chrome + zip -r ../../cloudhood-chrome-${{ steps.set_build_metadata.outputs.short_sha }}.zip . + + - name: Build Firefox extension + env: + FIREFOX_EXTENSION_ID: ${{ vars.FIREFOX_EXTENSION_ID }} + run: pnpm build:firefox + + - name: Check Firefox build + env: + FIREFOX_EXTENSION_ID: ${{ vars.FIREFOX_EXTENSION_ID }} + run: pnpm check:firefox-build + + - name: Pack Firefox extension + run: | + cd build/firefox + zip -r ../../cloudhood-firefox-${{ steps.set_build_metadata.outputs.short_sha }}.zip . + + - name: Build Firefox sources archive + run: pnpm build:firefox-sources + + - name: Rename Firefox sources archive + run: mv cloudhood-firefox-sources.zip cloudhood-firefox-sources-${{ steps.set_build_metadata.outputs.short_sha }}.zip + + - name: Upload Chrome build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.set_build_metadata.outputs.chrome_artifact }} + path: cloudhood-chrome-${{ steps.set_build_metadata.outputs.short_sha }}.zip + retention-days: 14 + + - name: Upload Firefox build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.set_build_metadata.outputs.firefox_artifact }} + path: cloudhood-firefox-${{ steps.set_build_metadata.outputs.short_sha }}.zip + retention-days: 14 + + - name: Upload Firefox sources artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.set_build_metadata.outputs.firefox_sources_artifact }} + path: cloudhood-firefox-sources-${{ steps.set_build_metadata.outputs.short_sha }}.zip + retention-days: 14 + + e2e-test: + runs-on: ubuntu-latest + needs: [build] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect E2E-impacting changes + id: e2e_changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if [ -z "$BASE_SHA" ] || printf '%s\n' "$BASE_SHA" | grep -Eq '^0+$'; then + echo "run_e2e=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed_files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + printf '%s\n' "$changed_files" + + if printf '%s\n' "$changed_files" | grep -Eq '^(src/|tests/e2e/|docker/|docker-compose\.screenshots\.yml$|manifest\.(chromium|firefox)\.json$|package\.json$|pnpm-lock\.yaml$|playwright\.config\.ts$|vite(\.background)?\.config\.ts$|scripts/(firefox-e2e|firefox-screenshots|lib/))'; then + echo "run_e2e=true" >> "$GITHUB_OUTPUT" + else + echo "run_e2e=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip E2E tests + if: steps.e2e_changes.outputs.run_e2e != 'true' + run: echo "Skipping extension E2E tests because this change does not affect extension runtime, E2E, or screenshot inputs." + + - name: Setup pnpm + if: steps.e2e_changes.outputs.run_e2e == 'true' + uses: pnpm/action-setup@v2 + with: + version: 10.10.0 + + - name: Setup Node + if: steps.e2e_changes.outputs.run_e2e == 'true' + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + if: steps.e2e_changes.outputs.run_e2e == 'true' + run: pnpm install --frozen-lockfile + + - name: Download Chrome build artifact + if: steps.e2e_changes.outputs.run_e2e == 'true' + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.chrome_artifact }} + + - name: Extract Chrome extension + if: steps.e2e_changes.outputs.run_e2e == 'true' + run: | + mkdir -p build/chrome + unzip -o cloudhood-chrome-${{ needs.build.outputs.short_sha }}.zip -d build/chrome + + - name: Download Firefox build artifact + if: steps.e2e_changes.outputs.run_e2e == 'true' + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.firefox_artifact }} + + - name: Extract Firefox extension + if: steps.e2e_changes.outputs.run_e2e == 'true' + run: | + mkdir -p build/firefox + unzip -o cloudhood-firefox-${{ needs.build.outputs.short_sha }}.zip -d build/firefox + + - name: Build Playwright screenshot image + if: steps.e2e_changes.outputs.run_e2e == 'true' + run: docker compose -f docker-compose.screenshots.yml build screenshots + + - name: Run functional E2E tests in container + if: steps.e2e_changes.outputs.run_e2e == 'true' + run: docker compose -f docker-compose.screenshots.yml run --rm screenshots sh -lc "pnpm install --frozen-lockfile && pnpm test:e2e:ci" + + - name: Run Firefox functional E2E tests in container + if: steps.e2e_changes.outputs.run_e2e == 'true' + run: docker compose -f docker-compose.screenshots.yml run --rm screenshots sh -lc "pnpm install --frozen-lockfile && pnpm test:e2e:firefox" + + - name: Run screenshot tests in container + if: steps.e2e_changes.outputs.run_e2e == 'true' + run: docker compose -f docker-compose.screenshots.yml run --rm screenshots sh -lc "pnpm install --frozen-lockfile && pnpm build && pnpm test:e2e:screenshots" + + validate-build-artifacts: + needs: [build, e2e-test] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download Chrome build artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.chrome_artifact }} + path: dist/chrome + + - name: Download Firefox build artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.firefox_artifact }} + path: dist/firefox + + - name: Download Firefox sources artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.firefox_sources_artifact }} + path: dist/firefox-sources + + - name: Extract builds for release checks + run: | + mkdir -p build/chrome build/firefox + unzip -o dist/chrome/cloudhood-chrome-${{ needs.build.outputs.short_sha }}.zip -d build/chrome + unzip -o dist/firefox/cloudhood-firefox-${{ needs.build.outputs.short_sha }}.zip -d build/firefox + + - name: Check release builds + env: + FIREFOX_EXTENSION_ID: ${{ vars.FIREFOX_EXTENSION_ID }} + run: node scripts/check-extension-builds.mjs diff --git a/.gitverse/workflows/release.yml b/.gitverse/workflows/release.yml new file mode 100644 index 00000000..f8ca6e66 --- /dev/null +++ b/.gitverse/workflows/release.yml @@ -0,0 +1,173 @@ +# Standby release pipeline for Gitverse. +# Always builds + creates a Gitverse release. Publishing to Chrome Web Store / +# Firefox Add-ons only runs when triggered manually with publish=true — use this +# as a fallback if the GitHub release pipeline is unavailable, not as a +# parallel auto-publisher (avoids double-submitting the same version to stores). + +name: Release (Gitverse standby) + +on: + workflow_dispatch: + inputs: + publish: + description: 'Publish to Chrome Web Store / Firefox Add-ons after build' + type: boolean + required: false + default: false + +concurrency: + group: release-gitverse + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITVERSE_TOKEN }} + + - name: Prepare release version and notes + id: release + run: node scripts/prepare-release.mjs + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.10.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Chrome extension + run: pnpm build:chromium + + - name: Check Chrome build + run: pnpm check:chrome-build + + - name: Pack Chrome extension + run: | + cd build/chrome + zip -r ../../cloudhood-chrome-${{ steps.release.outputs.tag }}.zip . + + - name: Build Firefox extension + env: + FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }} + run: pnpm build:firefox + + - name: Check release builds + env: + FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }} + run: pnpm check:extension-builds + + - name: Pack Firefox extension + run: | + cd build/firefox + zip -r ../../cloudhood-firefox-${{ steps.release.outputs.tag }}.zip . + + - name: Build Firefox sources archive + run: pnpm build:firefox-sources + + - name: Rename Firefox sources archive + run: mv cloudhood-firefox-sources.zip cloudhood-firefox-sources-${{ steps.release.outputs.tag }}.zip + + - name: Commit release version and create tag + env: + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: | + git config --local user.name gitverse-actions + git config --local user.email gitverse-actions@gitverse.ru + git add package.json manifest.chromium.json manifest.firefox.json + git commit -m "ci: bump version to $VERSION [skip ci]" + git push + git tag "$TAG" + git push origin "$TAG" + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: cloudhood-${{ steps.release.outputs.tag }} + path: | + cloudhood-chrome-${{ steps.release.outputs.tag }}.zip + cloudhood-firefox-${{ steps.release.outputs.tag }}.zip + cloudhood-firefox-sources-${{ steps.release.outputs.tag }}.zip + release-notes.md + + gitverse-release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: cloudhood-${{ needs.build.outputs.tag }} + + - name: Create Gitverse release + env: + GITVERSE_TOKEN: ${{ secrets.GITVERSE_TOKEN }} + GITVERSE_REPOSITORY: ${{ vars.GITVERSE_REPOSITORY }} + GITVERSE_API_URL: ${{ vars.GITVERSE_API_URL }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + run: | + node scripts/create-gitverse-release.mjs \ + cloudhood-chrome-${{ needs.build.outputs.tag }}.zip \ + cloudhood-firefox-${{ needs.build.outputs.tag }}.zip \ + cloudhood-firefox-sources-${{ needs.build.outputs.tag }}.zip + + publish-chrome: + if: ${{ inputs.publish == true }} + needs: [build, gitverse-release] + runs-on: ubuntu-latest + steps: + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: cloudhood-${{ needs.build.outputs.tag }} + + - name: Publish to Chrome Web Store + env: + CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }} + CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} + CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} + CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + CHROME_PUBLISH_TARGET: ${{ secrets.CHROME_PUBLISH_TARGET }} + run: | + npx --yes publish-browser-extension@4.0.4 \ + --chrome-zip cloudhood-chrome-${{ needs.build.outputs.tag }}.zip \ + --chrome-extension-id "$CHROME_EXTENSION_ID" \ + --chrome-client-id "$CHROME_CLIENT_ID" \ + --chrome-client-secret "$CHROME_CLIENT_SECRET" \ + --chrome-refresh-token "$CHROME_REFRESH_TOKEN" \ + --chrome-publish-target "$CHROME_PUBLISH_TARGET" + + publish-firefox: + if: ${{ inputs.publish == true }} + needs: [build, gitverse-release] + runs-on: ubuntu-latest + steps: + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: cloudhood-${{ needs.build.outputs.tag }} + + - name: Publish to Firefox Add-ons + env: + FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }} + FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }} + FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }} + run: | + npx --yes publish-browser-extension@4.0.4 \ + --firefox-zip cloudhood-firefox-${{ needs.build.outputs.tag }}.zip \ + --firefox-sources-zip cloudhood-firefox-sources-${{ needs.build.outputs.tag }}.zip \ + --firefox-extension-id "$FIREFOX_EXTENSION_ID" \ + --firefox-jwt-issuer "$FIREFOX_JWT_ISSUER" \ + --firefox-jwt-secret "$FIREFOX_JWT_SECRET" diff --git a/scripts/create-gitverse-release.mjs b/scripts/create-gitverse-release.mjs new file mode 100644 index 00000000..d9bcff1a --- /dev/null +++ b/scripts/create-gitverse-release.mjs @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; + +const { GITVERSE_TOKEN, GITVERSE_REPOSITORY, GITVERSE_API_URL, GITHUB_REPOSITORY, RELEASE_TAG } = process.env; + +const repository = GITVERSE_REPOSITORY || GITHUB_REPOSITORY; +const releaseNotesPath = process.env.RELEASE_NOTES_PATH || 'release-notes.md'; +const assetPaths = process.argv.slice(2); + +if (!GITVERSE_TOKEN) { + throw new Error('GITVERSE_TOKEN is required.'); +} + +if (!repository) { + throw new Error('GITVERSE_REPOSITORY or GITHUB_REPOSITORY is required.'); +} + +if (!RELEASE_TAG) { + throw new Error('RELEASE_TAG is required.'); +} + +if (assetPaths.length === 0) { + throw new Error('At least one release asset path is required.'); +} + +const apiUrl = (GITVERSE_API_URL || 'https://api.gitverse.ru').replace(/\/$/, ''); +const headers = { + Authorization: `Bearer ${GITVERSE_TOKEN}`, + Accept: 'application/vnd.gitverse.object+json;version=1', +}; + +async function request(path, options = {}) { + const response = await fetch(`${apiUrl}${path}`, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`GitVerse API request failed: ${response.status} ${response.statusText}\n${text}`); + } + + return text ? JSON.parse(text) : null; +} + +const body = readFileSync(releaseNotesPath, 'utf8'); + +const release = await request(`/repos/${repository}/releases`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag_name: RELEASE_TAG, + name: `Release ${RELEASE_TAG}`, + target_commitish: process.env.GITHUB_SHA, + body, + draft: false, + prerelease: false, + is_authorized_only: false, + }), +}); + +for (const assetPath of assetPaths) { + const fileName = basename(assetPath); + const form = new FormData(); + form.append('attachment', new Blob([readFileSync(assetPath)]), fileName); + + await request(`/repos/${repository}/releases/${release.id}/assets?name=${encodeURIComponent(fileName)}`, { + method: 'POST', + body: form, + }); +} + +process.stdout.write(`Created GitVerse release ${RELEASE_TAG} with ${assetPaths.length} asset(s).\n`); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 5f5865a5..45ff5eb5 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -80,8 +80,8 @@ function escapeMarkdown(value) { } function buildReleaseNotes(previousTag, version, commits) { - const repository = process.env.GITHUB_REPOSITORY; - const serverUrl = process.env.GITHUB_SERVER_URL ?? 'https://github.com'; + const repository = process.env.GITVERSE_REPOSITORY || process.env.GITHUB_REPOSITORY; + const serverUrl = process.env.GITVERSE_SERVER_URL || process.env.GITHUB_SERVER_URL || 'https://github.com'; const compareLink = repository ? `\n\n**Full changelog:** [${previousTag}...v${version}](${serverUrl}/${repository}/compare/${previousTag}...v${version})` : '';