From 3487605540b15851a1eccc2878d2b6f9c200da7e Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Wed, 19 Aug 2026 12:36:56 -0700 Subject: [PATCH 1/3] ci: replace GitLab CI pipeline with GitHub Actions --- .github/workflows/ci.yml | 110 ++++++++++++++++++++++++++++++++++++ .github/workflows/pages.yml | 73 ++++++++++++++++++++++++ .gitlab-ci.yml | 75 ------------------------ 3 files changed, 183 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pages.yml delete mode 100644 .gitlab-ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56c551a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +# GitLab ran test/lint on every merge-request event AND on every branch push +# (rules: `if: $CI_PIPELINE_SOURCE == "merge_request_event"` OR +# `if: $CI_COMMIT_BRANCH`), with no top-level `workflow:` block to dedupe +# them, so a push to a branch backing an open merge request produced two +# GitLab pipelines for the same commit. Scoping `push:` to main avoids +# reproducing that duplication: a feature branch is covered by `pull_request` +# alone, and main is covered by `push`. +concurrency: + group: ci-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # GitLab's test and lint were already two separate jobs (both stage: test), + # each running the same before_script bundle install independently against + # the shared vendor/bundle cache. Kept as two GitHub jobs for the same + # reason: lint is allowed to fail (see allow_failure below) without + # affecting the test job's status. + test: + runs-on: ubuntu-latest + # Same image GitLab's `default: image: ruby:3.4` pulled, pinned to the + # exact patch in .ruby-version. A container image, not ruby/setup-ruby, + # because this repo's Actions policy is allowed_actions: "selected" and + # ruby/setup-ruby isn't on the allow-list (verified via + # actions/permissions/selected-actions) -- pulling a plain image needs no + # action allow-listing at all, and it mirrors GitLab's own `image:` + # mechanism exactly. + container: + image: ruby:3.4.5 + steps: + - uses: actions/checkout@v4 + + # Reproduces GitLab's `default.cache: paths: [vendor/bundle]`. + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-${{ hashFiles('Gemfile.lock') }} + restore-keys: bundle- + + # GitLab's default.before_script, run identically in every job. + - name: bundle install + run: | + ruby -v + gem install bundler --no-document + bundle config set --local path 'vendor/bundle' + bundle install --jobs "$(nproc)" + + - name: rspec + run: bundle exec rspec --format documentation --format RspecJunitFormatter --out rspec.xml + + # GitHub Actions has no native equivalent of GitLab's + # artifacts.reports.junit UI ingestion, so the JUnit file is uploaded as + # a plain artifact instead. `always()` matches GitLab's `when: always`. + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: rspec-report + path: rspec.xml + retention-days: 7 + if-no-files-found: warn + + lint: + runs-on: ubuntu-latest + container: + image: ruby:3.4.5 + steps: + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-${{ hashFiles('Gemfile.lock') }} + restore-keys: bundle- + + - name: bundle install + run: | + ruby -v + gem install bundler --no-document + bundle config set --local path 'vendor/bundle' + bundle install --jobs "$(nproc)" + + # allow_failure: true in GitLab. Verified locally: with the repo's + # empty .rubocop.yml (full default cop set against code that predates + # RuboCop being turned on), rubocop currently reports 166 offenses + # across 69 files and exits 1, so this must not block merges. + # continue-on-error keeps the job green while the Checks UI still shows + # the step itself as failed. + - name: rubocop + continue-on-error: true + run: bundle exec rubocop --format progress --format junit --out rubocop.xml + + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v4 + with: + name: rubocop-report + path: rubocop.xml + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..b00785c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,73 @@ +name: Pages + +# GitLab's `pages: true` job built YARD docs on every push to the default +# branch and GitLab Pages picked up the `public/` artifact automatically. +# GitHub Pages instead needs an explicit build (this job) plus a separate +# deploy job that calls actions/deploy-pages. +# +# NOT YET RUNNABLE AS-IS: this repo has never had GitHub Pages enabled +# (confirmed: has_pages: false, GET /repos/ScoreVision/pug-client-ruby/pages +# -> 404). actions/deploy-pages calls the Pages Deployments API, which +# requires a Pages site already provisioned with build_type=workflow; it does +# not create one. A repo admin has to do that once, out of band (Settings -> +# Pages -> Build and deployment -> Source: GitHub Actions), before the deploy +# job below will succeed. This workflow documents that requirement rather +# than provisioning it. +on: + push: + branches: + - main + +# Matches GitHub's own generated Pages-deploy template: one deployment at a +# time, queued rather than cancelled, so an in-flight deploy-pages call is +# never killed mid-upload and left in an inconsistent state. +concurrency: + group: pages + cancel-in-progress: false + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + # Same rationale as ci.yml: a container image sidesteps the + # allowed_actions: "selected" restriction that blocks ruby/setup-ruby. + container: + image: ruby:3.4.5 + steps: + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: bundle-${{ hashFiles('Gemfile.lock') }} + restore-keys: bundle- + + - name: bundle install + run: | + gem install bundler --no-document + bundle config set --local path 'vendor/bundle' + bundle install --jobs "$(nproc)" + + - name: Generate YARD docs + run: bundle exec yard doc + + # No `mv doc public` step here: GitLab Pages hard-requires the artifact + # directory to be named public/, but upload-pages-artifact accepts any + # path, so doc/ (what .yardopts already writes) is uploaded directly. + - uses: actions/upload-pages-artifact@v3 + with: + path: doc + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 9c7a4af..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,75 +0,0 @@ -# GitLab CI/CD Pipeline for pug-client-ruby -# Runs tests and generates YARD documentation for GitLab Pages - -# Default configuration applied to all jobs -default: - image: ruby:3.4 - # Cache gems between builds for faster pipeline execution - cache: - paths: - - vendor/bundle - # Common setup for all jobs - before_script: - - ruby -v - - gem install bundler --no-document - - bundle config set --local path 'vendor/bundle' - - bundle install --jobs $(nproc) - -# Define pipeline stages -stages: - - test - - deploy - -# Run RSpec tests -test: - stage: test - script: - # Run RSpec with multiple formatters: documentation for console, JUnit for GitLab - - bundle exec rspec --format documentation --format RspecJunitFormatter --out rspec.xml - artifacts: - reports: - junit: rspec.xml - paths: - - rspec.xml - expire_in: 7 days - when: always - # Run tests on all branches and merge requests - rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - - if: $CI_COMMIT_BRANCH - -# Optional: Run RuboCop linter -lint: - stage: test - script: - # Run RuboCop with JUnit formatter for GitLab integration - - bundle exec rubocop --format progress --format junit --out rubocop.xml - artifacts: - reports: - junit: rubocop.xml - paths: - - rubocop.xml - expire_in: 7 days - when: always - allow_failure: true - rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - - if: $CI_COMMIT_BRANCH - -# Generate YARD documentation and deploy to GitLab Pages -# This job MUST be named "pages" for GitLab Pages to work -pages: - stage: deploy - # Mark this as a Pages deployment job - pages: true - script: - # Generate YARD documentation to doc/ directory - - bundle exec yard doc - # GitLab Pages requires artifacts in a "public/" directory - - mv doc public - artifacts: - paths: - - public - # Only deploy to Pages from the main branch - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH From 1293dfef918e1bb29192ef195ec300d89117da88 Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Wed, 19 Aug 2026 13:03:18 -0700 Subject: [PATCH 2/3] fix: gate Pages build on CI success and scope its deploy permissions I trigger pages.yml off workflow_run of the CI workflow so build only runs once the same commit's CI has completed and succeeded, matching GitLab's stage: deploy ordering after stage: test. I also move pages:write and id-token:write to a job-level block on deploy only, so build's bundle install never runs with those scopes ambient. I also correct the ci.yml comment that claimed push-before-PR commits get full CI coverage, which they don't. --- .github/workflows/ci.yml | 8 ++++++-- .github/workflows/pages.yml | 30 +++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56c551a..936313b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,12 @@ on: # `if: $CI_COMMIT_BRANCH`), with no top-level `workflow:` block to dedupe # them, so a push to a branch backing an open merge request produced two # GitLab pipelines for the same commit. Scoping `push:` to main avoids -# reproducing that duplication: a feature branch is covered by `pull_request` -# alone, and main is covered by `push`. +# reproducing that duplication, at the cost of a gap GitLab didn't have: a +# commit pushed to a branch before any pull request is opened for it matches +# neither `pull_request` (opened/synchronize/reopened only) nor `push` +# (main only), so it runs no CI at all until a PR exists. Accepted here +# because it matches the same trigger shape already established in +# sv-cloud's ci.yml. concurrency: group: ci-${{ github.head_ref || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b00785c..827e2b3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -13,10 +13,18 @@ name: Pages # Pages -> Build and deployment -> Source: GitHub Actions), before the deploy # job below will succeed. This workflow documents that requirement rather # than provisioning it. +# +# GitLab's `pages` job was `stage: deploy`, which by default only starts +# once every stage: test job (i.e. `test`) has succeeded; `lint` has +# allow_failure: true so it never blocked that gate. Triggering on +# `workflow_run` of the CI workflow, rather than independently on `push`, +# reproduces that gate: build only runs once CI has completed for the same +# commit, and the conclusion check below reproduces the "must succeed" half. on: - push: - branches: - - main + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] # Matches GitHub's own generated Pages-deploy template: one deployment at a # time, queued rather than cancelled, so an in-flight deploy-pages call is @@ -27,18 +35,22 @@ concurrency: permissions: contents: read - pages: write - id-token: write jobs: build: + if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest # Same rationale as ci.yml: a container image sidesteps the # allowed_actions: "selected" restriction that blocks ruby/setup-ruby. container: image: ruby:3.4.5 steps: + # workflow_run checks out the default branch by default, which would + # silently build stale docs if main has moved since CI started. Pin to + # the exact commit CI just tested. - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} - uses: actions/cache@v4 with: @@ -65,6 +77,14 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + # Job-level permissions replace the workflow-level block for this job + # entirely. Scoped here rather than at the workflow level so `build` + # -- which runs `bundle install` against a lockfile-resolved, untrusted + # dependency tree -- never holds the ability to call the Pages + # Deployments API or mint an OIDC token for this repo. + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} From 5b6aab66f6927f9086948c4f27235d779af77b4f Mon Sep 17 00:00:00 2001 From: Zach Norris Date: Wed, 19 Aug 2026 13:55:35 -0700 Subject: [PATCH 3/3] docs: trim workflow comments to the workflow itself --- .github/workflows/ci.yml | 44 ++++++++------------------------ .github/workflows/pages.yml | 51 ++++++++++++------------------------- 2 files changed, 26 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 936313b..a1362b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,22 +1,13 @@ name: CI +# Note the coverage gap: a commit pushed to a branch with no pull request open +# yet matches neither trigger, so it runs no CI until the PR exists. on: pull_request: push: branches: - main -# GitLab ran test/lint on every merge-request event AND on every branch push -# (rules: `if: $CI_PIPELINE_SOURCE == "merge_request_event"` OR -# `if: $CI_COMMIT_BRANCH`), with no top-level `workflow:` block to dedupe -# them, so a push to a branch backing an open merge request produced two -# GitLab pipelines for the same commit. Scoping `push:` to main avoids -# reproducing that duplication, at the cost of a gap GitLab didn't have: a -# commit pushed to a branch before any pull request is opened for it matches -# neither `pull_request` (opened/synchronize/reopened only) nor `push` -# (main only), so it runs no CI at all until a PR exists. Accepted here -# because it matches the same trigger shape already established in -# sv-cloud's ci.yml. concurrency: group: ci-${{ github.head_ref || github.ref }} cancel-in-progress: true @@ -25,33 +16,23 @@ permissions: contents: read jobs: - # GitLab's test and lint were already two separate jobs (both stage: test), - # each running the same before_script bundle install independently against - # the shared vendor/bundle cache. Kept as two GitHub jobs for the same - # reason: lint is allowed to fail (see allow_failure below) without - # affecting the test job's status. test: runs-on: ubuntu-latest - # Same image GitLab's `default: image: ruby:3.4` pulled, pinned to the - # exact patch in .ruby-version. A container image, not ruby/setup-ruby, - # because this repo's Actions policy is allowed_actions: "selected" and - # ruby/setup-ruby isn't on the allow-list (verified via - # actions/permissions/selected-actions) -- pulling a plain image needs no - # action allow-listing at all, and it mirrors GitLab's own `image:` - # mechanism exactly. + # A container image rather than ruby/setup-ruby: this repo's Actions policy + # is allowed_actions: "selected" and ruby/setup-ruby is not on the + # allow-list. Pulling a plain image needs no allow-listing. Pinned to the + # patch version in .ruby-version. container: image: ruby:3.4.5 steps: - uses: actions/checkout@v4 - # Reproduces GitLab's `default.cache: paths: [vendor/bundle]`. - uses: actions/cache@v4 with: path: vendor/bundle key: bundle-${{ hashFiles('Gemfile.lock') }} restore-keys: bundle- - # GitLab's default.before_script, run identically in every job. - name: bundle install run: | ruby -v @@ -62,9 +43,6 @@ jobs: - name: rspec run: bundle exec rspec --format documentation --format RspecJunitFormatter --out rspec.xml - # GitHub Actions has no native equivalent of GitLab's - # artifacts.reports.junit UI ingestion, so the JUnit file is uploaded as - # a plain artifact instead. `always()` matches GitLab's `when: always`. - name: Upload test report if: always() uses: actions/upload-artifact@v4 @@ -94,12 +72,10 @@ jobs: bundle config set --local path 'vendor/bundle' bundle install --jobs "$(nproc)" - # allow_failure: true in GitLab. Verified locally: with the repo's - # empty .rubocop.yml (full default cop set against code that predates - # RuboCop being turned on), rubocop currently reports 166 offenses - # across 69 files and exits 1, so this must not block merges. - # continue-on-error keeps the job green while the Checks UI still shows - # the step itself as failed. + # Non-blocking: .rubocop.yml is empty, so the full default cop set runs + # against code that predates RuboCop being turned on. It currently reports + # 166 offenses across 69 files and exits 1. The step shows as failed in the + # Checks UI while the job stays green. - name: rubocop continue-on-error: true run: bundle exec rubocop --format progress --format junit --out rubocop.xml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 827e2b3..2ae0948 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,34 +1,20 @@ name: Pages -# GitLab's `pages: true` job built YARD docs on every push to the default -# branch and GitLab Pages picked up the `public/` artifact automatically. -# GitHub Pages instead needs an explicit build (this job) plus a separate -# deploy job that calls actions/deploy-pages. +# Requires GitHub Pages to be enabled for the repo with Source: GitHub Actions. +# actions/deploy-pages calls the Pages Deployments API, which needs a site +# already provisioned with build_type=workflow; the action cannot create one, so +# until an admin does this once the deploy job fails. # -# NOT YET RUNNABLE AS-IS: this repo has never had GitHub Pages enabled -# (confirmed: has_pages: false, GET /repos/ScoreVision/pug-client-ruby/pages -# -> 404). actions/deploy-pages calls the Pages Deployments API, which -# requires a Pages site already provisioned with build_type=workflow; it does -# not create one. A repo admin has to do that once, out of band (Settings -> -# Pages -> Build and deployment -> Source: GitHub Actions), before the deploy -# job below will succeed. This workflow documents that requirement rather -# than provisioning it. -# -# GitLab's `pages` job was `stage: deploy`, which by default only starts -# once every stage: test job (i.e. `test`) has succeeded; `lint` has -# allow_failure: true so it never blocked that gate. Triggering on -# `workflow_run` of the CI workflow, rather than independently on `push`, -# reproduces that gate: build only runs once CI has completed for the same -# commit, and the conclusion check below reproduces the "must succeed" half. +# Triggered on CI completing rather than on push, so docs are only published for +# a commit whose tests passed. on: workflow_run: workflows: ["CI"] types: [completed] branches: [main] -# Matches GitHub's own generated Pages-deploy template: one deployment at a -# time, queued rather than cancelled, so an in-flight deploy-pages call is -# never killed mid-upload and left in an inconsistent state. +# One deployment at a time, queued rather than cancelled, so an in-flight +# deploy-pages call is never killed mid-upload. concurrency: group: pages cancel-in-progress: false @@ -40,14 +26,14 @@ jobs: build: if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest - # Same rationale as ci.yml: a container image sidesteps the - # allowed_actions: "selected" restriction that blocks ruby/setup-ruby. + # Container image for the same reason as ci.yml: ruby/setup-ruby is not on + # this repo's selected-actions allow-list. container: image: ruby:3.4.5 steps: - # workflow_run checks out the default branch by default, which would - # silently build stale docs if main has moved since CI started. Pin to - # the exact commit CI just tested. + # workflow_run checks out the default branch by default, which would build + # stale docs if main moved while CI was running. Pin to the commit CI + # actually tested. - uses: actions/checkout@v4 with: ref: ${{ github.event.workflow_run.head_sha }} @@ -67,9 +53,6 @@ jobs: - name: Generate YARD docs run: bundle exec yard doc - # No `mv doc public` step here: GitLab Pages hard-requires the artifact - # directory to be named public/, but upload-pages-artifact accepts any - # path, so doc/ (what .yardopts already writes) is uploaded directly. - uses: actions/upload-pages-artifact@v3 with: path: doc @@ -77,11 +60,9 @@ jobs: deploy: needs: build runs-on: ubuntu-latest - # Job-level permissions replace the workflow-level block for this job - # entirely. Scoped here rather than at the workflow level so `build` - # -- which runs `bundle install` against a lockfile-resolved, untrusted - # dependency tree -- never holds the ability to call the Pages - # Deployments API or mint an OIDC token for this repo. + # Scoped to this job so that build, which runs bundle install against a + # lockfile-resolved dependency tree, never holds the ability to call the + # Pages Deployments API or mint an OIDC token for this repo. permissions: pages: write id-token: write