diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index f0ec6c9712627cce88d4f190669c6769544fb883..0000000000000000000000000000000000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,77 +0,0 @@
-version: 2
-
-services_steps: &services_steps
-  steps:
-    - checkout
-
-    - run:
-        name: Install dependencies
-        command: |
-          npm ci
-        environment:
-          CYPRESS_INSTALL_BINARY: 0
-
-    - run:
-        name: Identify services tagged in the PR title
-        command: npm run test:services:pr:prepare
-
-    - run:
-        name: Run tests for tagged services
-        environment:
-          mocha_reporter: mocha-junit-reporter
-          MOCHA_FILE: junit/services/results.xml
-        command: RETRY_COUNT=3 npm run test:services:pr:run
-
-    - store_test_results:
-        path: junit
-
-jobs:
-  services:
-    docker:
-      - image: cimg/node:16.15
-
-    <<: *services_steps
-
-  services@node-17:
-    docker:
-      - image: cimg/node:17.9
-    environment:
-      NPM_CONFIG_ENGINE_STRICT: 'false'
-
-    <<: *services_steps
-
-workflows:
-  version: 2
-
-  on-commit:
-    jobs:
-      - services:
-          filters:
-            branches:
-              ignore:
-                - master
-                - gh-pages
-      - services@node-17:
-          filters:
-            branches:
-              ignore:
-                - master
-                - gh-pages
-  # on-commit-with-cache:
-  #   jobs:
-  #     - npm-install:
-  #         filters:
-  #           branches:
-  #             ignore: gh-pages
-  #     - services:
-  #         requires:
-  #           - npm-install
-  #         filters:
-  #           branches:
-  #             ignore: master
-  #     - services@node-latest:
-  #         requires:
-  #           - npm-install
-  #         filters:
-  #           branches:
-  #             ignore: master
diff --git a/.github/actions/service-tests/action.yml b/.github/actions/service-tests/action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d6aa6008f9a48b6172fa536ba4293a25bcc7243b
--- /dev/null
+++ b/.github/actions/service-tests/action.yml
@@ -0,0 +1,86 @@
+name: 'Service tests'
+description: 'Run tests for selected services'
+inputs:
+  github-token:
+    description: 'The GITHUB_TOKEN secret'
+    required: true
+  librariesio-tokens:
+    description: 'The SERVICETESTS_LIBRARIESIO_TOKENS secret'
+    required: false
+    default: ''
+  obs-user:
+    description: 'The SERVICETESTS_OBS_USER secret'
+    required: false
+    default: ''
+  obs-pass:
+    description: 'The SERVICETESTS_OBS_PASS secret'
+    required: false
+    default: ''
+  sl-insight-user-uuid:
+    description: 'The SERVICETESTS_SL_INSIGHT_USER_UUID secret'
+    required: false
+    default: ''
+  sl-insight-api-token:
+    description: 'The SERVICETESTS_SL_INSIGHT_API_TOKEN secret'
+    required: false
+    default: ''
+  twitch-client-id:
+    description: 'The SERVICETESTS_TWITCH_CLIENT_ID secret'
+    required: false
+    default: ''
+  twitch-client-secret:
+    description: 'The SERVICETESTS_TWITCH_CLIENT_SECRET secret'
+    required: false
+    default: ''
+  wheelmap-token:
+    description: 'The SERVICETESTS_WHEELMAP_TOKEN secret'
+    required: false
+    default: ''
+  youtube-api-key:
+    description: 'The SERVICETESTS_YOUTUBE_API_KEY secret'
+    required: false
+    default: ''
+
+runs:
+  using: 'composite'
+  steps:
+    - name: Derive list of service tests to run
+      # Note: In this step we are using an intermediate env var instead of
+      # passing github.event.pull_request.title as an argument
+      # to prevent a shell injection attack. Further reading:
+      # https://securitylab.github.com/research/github-actions-untrusted-input/#exploitability-and-impact
+      # https://securitylab.github.com/research/github-actions-untrusted-input/#remediation
+      if: always()
+      env:
+        TITLE: ${{ github.event.pull_request.title }}
+      run: npm run test:services:pr:prepare "$TITLE"
+      shell: bash
+
+    - name: Run service tests
+      if: always()
+      run: npm run test:services:pr:run -- --reporter json --reporter-option 'output=reports/service-tests.json'
+      shell: bash
+      env:
+        RETRY_COUNT: 3
+        GH_TOKEN: '${{ inputs.github-token }}'
+        LIBRARIESIO_TOKENS: '${{ inputs.librariesio-tokens }}'
+        OBS_USER: '${{ inputs.obs-user }}'
+        OBS_PASS: '${{ inputs.obs-pass }}'
+        SL_INSIGHT_USER_UUID: '${{ inputs.sl-insight-user-uuid }}'
+        SL_INSIGHT_API_TOKEN: '${{ inputs.sl-insight-api-token }}'
+        TWITCH_CLIENT_ID: '${{ inputs.twitch-client-id }}'
+        TWITCH_CLIENT_SECRET: '${{ inputs.twitch-client-secret }}'
+        WHEELMAP_TOKEN: '${{ inputs.wheelmap-token }}'
+        YOUTUBE_API_KEY: '${{ inputs.youtube-api-key }}'
+
+    - name: Write Markdown Summary
+      if: always()
+      run: |
+        if test -f 'reports/service-tests.json'; then
+          echo '# Services' >> $GITHUB_STEP_SUMMARY
+          sed -e 's/^/- /' pull-request-services.log >> $GITHUB_STEP_SUMMARY
+          node scripts/mocha2md.js Report reports/service-tests.json >> $GITHUB_STEP_SUMMARY
+        else
+          echo 'No services found. Nothing to do.' >> $GITHUB_STEP_SUMMARY
+        fi
+      shell: bash
diff --git a/.github/workflows/test-services-17.yml b/.github/workflows/test-services-17.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0482a49a5cfdb2585f4ea199c7da81e107e71c92
--- /dev/null
+++ b/.github/workflows/test-services-17.yml
@@ -0,0 +1,40 @@
+name: Services@node 17
+on:
+  pull_request:
+    types: [opened, edited, reopened, synchronize]
+
+jobs:
+  test-services-17:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Setup
+        uses: ./.github/actions/setup
+        with:
+          node-version: 17
+        env:
+          NPM_CONFIG_ENGINE_STRICT: 'false'
+
+      - name: Service tests (triggered from local branch)
+        if: github.event.pull_request.head.repo.full_name == github.repository
+        uses: ./.github/actions/service-tests
+        with:
+          github-token: '${{ secrets.GH_PAT }}'
+          librariesio-tokens: '${{ secrets.SERVICETESTS_LIBRARIESIO_TOKENS }}'
+          obs-user: '${{ secrets.SERVICETESTS_OBS_USER }}'
+          obs-pass: '${{ secrets.SERVICETESTS_OBS_PASS }}'
+          sl-insight-user-uuid: '${{ secrets.SERVICETESTS_SL_INSIGHT_USER_UUID }}'
+          sl-insight-api-token: '${{ secrets.SERVICETESTS_SL_INSIGHT_API_TOKEN }}'
+          twitch-client-id: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_ID }}'
+          twitch-client-secret: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_SECRET }}'
+          wheelmap-token: '${{ secrets.SERVICETESTS_WHEELMAP_TOKEN }}'
+          youtube-api-key: '${{ secrets.SERVICETESTS_YOUTUBE_API_KEY }}'
+
+      - name: Service tests (triggered from fork)
+        if: github.event.pull_request.head.repo.full_name != github.repository
+        uses: ./.github/actions/service-tests
+        with:
+          github-token: '${{ secrets.GITHUB_TOKEN }}'
diff --git a/.github/workflows/test-services.yml b/.github/workflows/test-services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f2a6cc8ff6e7dd6ce97a37178c6db347ae42de0e
--- /dev/null
+++ b/.github/workflows/test-services.yml
@@ -0,0 +1,38 @@
+name: Services
+on:
+  pull_request:
+    types: [opened, edited, reopened, synchronize]
+
+jobs:
+  test-services:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Setup
+        uses: ./.github/actions/setup
+        with:
+          node-version: 16
+
+      - name: Service tests (triggered from local branch)
+        if: github.event.pull_request.head.repo.full_name == github.repository
+        uses: ./.github/actions/service-tests
+        with:
+          github-token: '${{ secrets.GH_PAT }}'
+          librariesio-tokens: '${{ secrets.SERVICETESTS_LIBRARIESIO_TOKENS }}'
+          obs-user: '${{ secrets.SERVICETESTS_OBS_USER }}'
+          obs-pass: '${{ secrets.SERVICETESTS_OBS_PASS }}'
+          sl-insight-user-uuid: '${{ secrets.SERVICETESTS_SL_INSIGHT_USER_UUID }}'
+          sl-insight-api-token: '${{ secrets.SERVICETESTS_SL_INSIGHT_API_TOKEN }}'
+          twitch-client-id: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_ID }}'
+          twitch-client-secret: '${{ secrets.SERVICETESTS_TWITCH_CLIENT_SECRET }}'
+          wheelmap-token: '${{ secrets.SERVICETESTS_WHEELMAP_TOKEN }}'
+          youtube-api-key: '${{ secrets.SERVICETESTS_YOUTUBE_API_KEY }}'
+
+      - name: Service tests (triggered from fork)
+        if: github.event.pull_request.head.repo.full_name != github.repository
+        uses: ./.github/actions/service-tests
+        with:
+          github-token: '${{ secrets.GITHUB_TOKEN }}'
diff --git a/core/service-test-runner/infer-pull-request.js b/core/service-test-runner/infer-pull-request.js
deleted file mode 100644
index 699abb5bf5fb4bad402c6cf3f4bb27d0422fbd45..0000000000000000000000000000000000000000
--- a/core/service-test-runner/infer-pull-request.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @module
- */
-
-import { URL, format as urlFormat } from 'url'
-
-function formatSlug(owner, repo, pullRequest) {
-  return `${owner}/${repo}#${pullRequest}`
-}
-
-function parseGithubPullRequestUrl(url, options = {}) {
-  const { verifyBaseUrl } = options
-
-  const parsed = new URL(url)
-  const components = parsed.pathname.substr(1).split('/')
-  if (components[2] !== 'pull' || components.length !== 4) {
-    throw Error(`Invalid GitHub pull request URL: ${url}`)
-  }
-  const [owner, repo, , pullRequest] = components
-
-  parsed.pathname = ''
-  const baseUrl = urlFormat(parsed, {
-    auth: false,
-    fragment: false,
-    search: false,
-  }).replace(/\/$/, '')
-
-  if (verifyBaseUrl && baseUrl !== verifyBaseUrl) {
-    throw Error(`Expected base URL to be ${verifyBaseUrl} but got ${baseUrl}`)
-  }
-
-  return {
-    baseUrl,
-    owner,
-    repo,
-    pullRequest: +pullRequest,
-    slug: formatSlug(owner, repo, pullRequest),
-  }
-}
-
-function parseGithubRepoSlug(slug) {
-  const components = slug.split('/')
-  if (components.length !== 2) {
-    throw Error(`Invalid GitHub repo slug: ${slug}`)
-  }
-  const [owner, repo] = components
-  return { owner, repo }
-}
-
-function _inferPullRequestFromTravisEnv(env) {
-  const { owner, repo } = parseGithubRepoSlug(env.TRAVIS_REPO_SLUG)
-  const pullRequest = +env.TRAVIS_PULL_REQUEST
-  return {
-    owner,
-    repo,
-    pullRequest,
-    slug: formatSlug(owner, repo, pullRequest),
-  }
-}
-
-function _inferPullRequestFromCircleEnv(env) {
-  return parseGithubPullRequestUrl(
-    env.CI_PULL_REQUEST || env.CIRCLE_PULL_REQUEST
-  )
-}
-
-/**
- * When called inside a CI build, infer the details
- * of a pull request from the environment variables.
- *
- * @param {object} [env=process.env] Environment variables
- * @returns {module:core/service-test-runner/infer-pull-request~PullRequest}
- *    Pull Request
- */
-function inferPullRequest(env = process.env) {
-  if (env.TRAVIS) {
-    return _inferPullRequestFromTravisEnv(env)
-  } else if (env.CIRCLECI) {
-    return _inferPullRequestFromCircleEnv(env)
-  } else if (env.CI) {
-    throw Error(
-      'Unsupported CI system. Unable to obtain pull request information from the environment.'
-    )
-  } else {
-    throw Error(
-      'Unable to obtain pull request information from the environment. Is this running in CI?'
-    )
-  }
-}
-
-/**
- * Pull Request
- *
- * @typedef PullRequest
- * @property {string} pr.baseUrl (returned for travis CI only)
- * @property {string} owner
- * @property {string} repo
- * @property {string} pullRequest PR/issue number
- * @property {string} slug owner/repo/#pullRequest
- */
-
-export { parseGithubPullRequestUrl, parseGithubRepoSlug, inferPullRequest }
diff --git a/core/service-test-runner/infer-pull-request.spec.js b/core/service-test-runner/infer-pull-request.spec.js
deleted file mode 100644
index c2050d533e4062ddc23fe811e9b0da718fe37be7..0000000000000000000000000000000000000000
--- a/core/service-test-runner/infer-pull-request.spec.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import { test, given, forCases } from 'sazerac'
-import {
-  parseGithubPullRequestUrl,
-  inferPullRequest,
-} from './infer-pull-request.js'
-
-describe('Pull request inference', function () {
-  test(parseGithubPullRequestUrl, () => {
-    forCases([
-      given('https://github.com/badges/shields/pull/1234'),
-      given('https://github.com/badges/shields/pull/1234', {
-        verifyBaseUrl: 'https://github.com',
-      }),
-    ]).expect({
-      baseUrl: 'https://github.com',
-      owner: 'badges',
-      repo: 'shields',
-      pullRequest: 1234,
-      slug: 'badges/shields#1234',
-    })
-
-    given('https://github.com/badges/shields/pull/1234', {
-      verifyBaseUrl: 'https://example.com',
-    }).expectError(
-      'Expected base URL to be https://example.com but got https://github.com'
-    )
-  })
-
-  test(inferPullRequest, () => {
-    const expected = {
-      owner: 'badges',
-      repo: 'shields',
-      pullRequest: 1234,
-      slug: 'badges/shields#1234',
-    }
-
-    given({
-      CIRCLECI: '1',
-      CI_PULL_REQUEST: 'https://github.com/badges/shields/pull/1234',
-    }).expect(Object.assign({ baseUrl: 'https://github.com' }, expected))
-
-    given({
-      TRAVIS: '1',
-      TRAVIS_REPO_SLUG: 'badges/shields',
-      TRAVIS_PULL_REQUEST: '1234',
-    }).expect(expected)
-  })
-})
diff --git a/core/service-test-runner/pull-request-services-cli.js b/core/service-test-runner/pull-request-services-cli.js
index 34d8d20993c784515a8dee0cc58ce515b11bd4ef..a0c307c20fd5b4b921149ac4f38482405c7068f0 100644
--- a/core/service-test-runner/pull-request-services-cli.js
+++ b/core/service-test-runner/pull-request-services-cli.js
@@ -1,5 +1,5 @@
-// Infer the current PR from the Travis environment, and look for bracketed,
-// space-separated service names in the pull request title.
+// Derive a list of service tests to run based on
+// space-separated service names in the PR title.
 //
 // Output the list of services.
 //
@@ -8,54 +8,26 @@
 // Output:
 // travis
 // sonar
-//
-// Example:
-//
-// TRAVIS=1 TRAVIS_REPO_SLUG=badges/shields TRAVIS_PULL_REQUEST=1108 npm run test:services:pr:prepare
 
-import got from 'got'
-import { inferPullRequest } from './infer-pull-request.js'
 import servicesForTitle from './services-for-title.js'
 
-async function getTitle(owner, repo, pullRequest) {
-  const {
-    body: { title },
-  } = await got(
-    `https://api.github.com/repos/${owner}/${repo}/pulls/${pullRequest}`,
-    {
-      headers: {
-        'User-Agent': 'badges/shields',
-        Authorization: `token ${process.env.GITHUB_TOKEN}`,
-      },
-      responseType: 'json',
-    }
-  )
-  return title
-}
-
-async function main() {
-  const { owner, repo, pullRequest, slug } = inferPullRequest()
-  console.error(`PR: ${slug}`)
+let title
 
-  const title = await getTitle(owner, repo, pullRequest)
-
-  console.error(`Title: ${title}\n`)
-  const services = servicesForTitle(title)
-  if (services.length === 0) {
-    console.error('No services found. Nothing to do.')
-  } else {
-    console.error(
-      `Services: (${services.length} found) ${services.join(', ')}\n`
-    )
-    console.log(services.join('\n'))
+try {
+  if (process.argv.length < 3) {
+    throw new Error()
   }
+  title = process.argv[2]
+} catch (e) {
+  console.error('Error processing arguments')
+  process.exit(1)
 }
 
-;(async () => {
-  try {
-    await main()
-  } catch (e) {
-    console.error(e)
-    process.exit(1)
-  }
-})()
+console.error(`Title: ${title}\n`)
+const services = servicesForTitle(title)
+if (services.length === 0) {
+  console.error('No services found. Nothing to do.')
+} else {
+  console.error(`Services: (${services.length} found) ${services.join(', ')}\n`)
+  console.log(services.join('\n'))
+}
diff --git a/scripts/mocha2md.js b/scripts/mocha2md.js
index 6c9f4a009a6441347623767baa3dcf374bcc4dda..e8b2455d3a1bc233881f4655ca7536b60e3d9cff 100644
--- a/scripts/mocha2md.js
+++ b/scripts/mocha2md.js
@@ -22,6 +22,10 @@ if (data.stats.passes > 0) {
 if (data.stats.failures > 0) {
   process.stdout.write(`✖ ${data.stats.failures} failed\n\n`)
 }
+if (data.stats.pending > 0) {
+  process.stdout.write(`● ${data.stats.pending} pending\n\n`)
+  process.exit(2)
+}
 
 if (data.stats.failures > 0) {
   for (const test of data.tests) {
diff --git a/services/github/gist/github-gist-last-commit-redirect.tester.js b/services/github/gist/github-gist-last-commit-redirect.tester.js
index b99524249bff95ea678e870f8e1c055db3952df4..a200cf0e8ac4e02410b77b63e9cfd40c6da4b506 100644
--- a/services/github/gist/github-gist-last-commit-redirect.tester.js
+++ b/services/github/gist/github-gist-last-commit-redirect.tester.js
@@ -1,6 +1,7 @@
 import { ServiceTester } from '../../tester.js'
+
 export const t = new ServiceTester({
-  id: 'GithubGistLastCommitRedirect',
+  id: 'GistLastCommitRedirect',
   title: 'Github Gist Last Commit Redirect',
   pathPrefix: '/github-gist',
 })
diff --git a/services/github/gist/github-gist-last-commit.service.js b/services/github/gist/github-gist-last-commit.service.js
index 5e68852521b9038ebe9f9f81380de2aa6f42a0dc..cc148a8550f168b0d7036b207f86b9925dddb0e9 100644
--- a/services/github/gist/github-gist-last-commit.service.js
+++ b/services/github/gist/github-gist-last-commit.service.js
@@ -8,7 +8,7 @@ const schema = Joi.object({
   updated_at: Joi.string().required(),
 }).required()
 
-export default class GithubGistLastCommit extends GithubAuthV3Service {
+export default class GistLastCommit extends GithubAuthV3Service {
   static category = 'activity'
   static route = { base: 'github/gist/last-commit', pattern: ':gistId' }
   static examples = [
diff --git a/services/github/gist/github-gist-stars-redirect.tester.js b/services/github/gist/github-gist-stars-redirect.tester.js
index b28becd8fde242f10a502566e388bfa60b88cbea..8317855a6adf8151e12d7d6970a8df23b2fd3d33 100644
--- a/services/github/gist/github-gist-stars-redirect.tester.js
+++ b/services/github/gist/github-gist-stars-redirect.tester.js
@@ -1,6 +1,6 @@
 import { ServiceTester } from '../../tester.js'
 export const t = new ServiceTester({
-  id: 'GithubGistStarsRedirect',
+  id: 'GistStarsRedirect',
   title: 'Github Gist Stars Redirect',
   pathPrefix: '/github',
 })
diff --git a/services/github/gist/github-gist-stars.service.js b/services/github/gist/github-gist-stars.service.js
index 44c13b2db749b690348085e7fdc9f463d1b766a4..3166fed8338eb5c05440a8505119d3bf20712496 100644
--- a/services/github/gist/github-gist-stars.service.js
+++ b/services/github/gist/github-gist-stars.service.js
@@ -24,7 +24,7 @@ const documentation = `${commonDocumentation}
 <p>This badge shows the number of stargazers for a gist. Gist id is accepted as input and 'gist not found' is returned if the gist is not found for the given gist id.
 </p>`
 
-export default class GithubGistStars extends GithubAuthV4Service {
+export default class GistStars extends GithubAuthV4Service {
   static category = 'social'
 
   static route = {