Skip to content

Migrate pipeline from CircleCI to GitHub Actions#80

Merged
phelma merged 2 commits into
mainfrom
gha-migration
Jul 21, 2026
Merged

Migrate pipeline from CircleCI to GitHub Actions#80
phelma merged 2 commits into
mainfrom
gha-migration

Conversation

@phelma

@phelma phelma commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Cutover to GitHub Actions per the Variant A family plan (gem pilot).
Includes decommission — merging this PR completes the repo's migration.

  • main + pr workflows: check/test, prerelease, release environment gate
  • PR CI publishes a namespaced pre-release to RubyGems (see below)
  • git-crypt unlock on the runner; encrypted CI GPG key moved to .github/
  • Slack notifications via rake_slack; dependabot auto-merge job
  • Rakefile provisioning swapped to rake_github secrets/environments; rake_circle_ci dropped
  • CircleCI pipeline removed: .circleci/, scripts/ci/, the CI SSH deploy
    key pair and its keys:deploy/deploy_keys provisioning, and the stored
    CircleCI/GitHub API credentials (config/secrets/{circle_ci,github}/)

Deliberate decisions (not defects)

This cutover reproduces the CircleCI pipeline's behaviour, warts included;
fixing inherited hazards is post-migration work. In particular:

  • ./go release publishes to RubyGems before the version-bump commit is
    pushed — pre-existing ordering inside the untouched release logic.
  • Prerelease publishes on every push to main with no approval gate; only
    full releases are gated (environment: release).
  • Dependabot auto-merge accepts any update type that passes checks, and the
    merge does not trigger a release build — on CircleCI the merge commit
    carried [skip ci], so this matches. Updates ship with the next
    human-triggered release.
  • The release job pulls main at approval time, so a delayed approval
    publishes main as it stands then, not the SHA this run tested — parity with
    the old release.sh (which also pulled; prerelease.sh did not, so the
    prerelease job has no pull).
  • asdf_install@v1 is our own action (infrablocks/github-actions); we are
    happy tracking its major version tag.
  • Job scaffolding is repeated flat per job by design: the logic lives in the
    build system (./go/rake) and CI stays lean — it just triggers tasks and
    supplies secrets/context.
  • Gemfile.lock carries transitive major bumps — the unavoidable resolution
    of the targeted bundle lock --update, not scope creep.
  • Small library hunks may appear where the refreshed toolchain's rubocop
    autocorrects existing code (e.g. Style/ArgumentsForwarding) — required
    by the library:check verification gate, not drive-by refactoring.
  • Provisioning (pipeline:prepare) authenticates with the operator's ambient
    gh login (GITHUB_TOKEN fallback) instead of a stored PAT — a deliberate
    parity deviation; the stored token in config/secrets/github/config.yaml
    is deleted with the rest of the CircleCI-era credentials.

PR-CI prerelease publish (deliberate, permanent)

pr.yaml has a prerelease job that publishes a namespaced pre-release of
this gem to RubyGems from the PR branch — a permanent CI feature, not
migration-only. This is a deliberate deviation from CircleCI (which published
nothing pre-merge): it proves the publish path before merge instead of
discovering it broken on main. The version is
<committed-version>.pr<PR>.<run>.<attempt> (via the new prerelease:publish
Rakefile task), so it can never collide with main's version:bump[pre]
sequence; the task builds the gem and pushes it straight to RubyGems, then
restores version.rb, so nothing is committed, tagged, or pushed
(gem release is not used — it aborts on the uncommitted version rewrite).
The job is skipped for fork
and Dependabot PRs (they hold no secrets), and merge-pull-request does not
depend on it. PR pre-release versions accumulate permanently on RubyGems —
accepted.

Do not merge manually — the pipeline merges once checks are green.
Disabling the CircleCI project and deleting the CircleCI deploy key are
deferred to the end-of-migration sweep.


🏭 This PR was opened by Foundry, Atomic's AI software development
factory. Implementation, review, and fixes are performed by AI agents;
merges happen automatically once the review and checks gates pass.
This task migrates a Ruby gem's CI from CircleCI to GitHub Actions.

Pipeline Task pack Family Run
migrate migrate-gem gem 2026-07-20T21-05-31-118Z

atomic-foundry-pr · foundry-pipeline: migrate · foundry-task: migrate-gem · foundry-run: 2026-07-20T21-05-31-118Z

@phelma phelma left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #80 - Migrate pipeline from CircleCI to GitHub Actions

Verdict: COMMENT

Reviewed through five lenses (correctness, security, safety, standards, code quality) against the Variant A family plan. The workflow files reproduce the plan's §4.1/§4.2 templates faithfully, decommission is complete and verifiable, and the Rakefile guards (git-crypt ciphertext detection, fail-fast token resolution) are well done. Findings below separate genuine in-scope defects from concerns about decisions the plan documents as deliberate — the latter are recorded for a future plan revision, not as blockers for this diff.

Cross-Cutting Themes

  • concurrency: queue: max (flagged by: correctness, safety) — both lenses believe queue is not a recognised concurrency key. It is prescribed verbatim by plan §4.2, which cites the 2026-05-07 Actions changelog introducing it. Recorded as a plan concern: worth a one-off confirmation against a live run that the key is honoured rather than silently ignored, since if it is ignored the stated "no queued run is dropped" guarantee does not hold fleet-wide.
  • No build job on main.yaml (flagged by: correctness, safety) — library:build gates PRs but not main. Plan-conformant (§4.2 specifies check/test/prerelease/release only), so a plan concern rather than a defect, but it means a packaging regression on a direct main push is first discovered mid-release.

Tradeoff Analysis

  • Fleet consistency vs local readability: the Rakefile extracts the plan's inline token/passphrase snippets into named helper methods. Genuinely more readable, but it diverges from the shape ~103 sibling repos will land, which is what makes fleet-wide grep/diff auditing possible. Recommendation: either inline per the template, or promote the extracted form into the plan so every repo gets it.

Strengths

  • pr.yaml and main.yaml match the §4.1/§4.2 authoritative templates — job names, needs, timeout-minutes, permissions, and the template comments verbatim with nothing added.
  • merge-pull-request correctly omits prerelease from needs, avoiding the skipped-dependency freeze of the Dependabot auto-merge.
  • ✅ Untrusted PR facts (title, number, head SHA) are passed via env: and referenced as $VAR, avoiding the classic ${{ }} shell-injection sink.
  • --match-head-commit "$HEAD_SHA" closes the window between checks passing and the merge landing.
  • read_encryption_passphrase fails loudly on a locked clone rather than uploading git-crypt ciphertext as a repository secret — a failure that would otherwise surface as an opaque GPG error much later.
  • prerelease:publish restores version.rb and removes the built gem in an ensure block.
  • ✅ Decommission is thorough and verified: .circleci/ gone, scripts/ci/ retains only the two portable scripts, config/secrets/ci/ has no ssh.*, circle_ci/ and github/ secret dirs gone, git grep -i circleci clean, GPG key move is a pure rename, ruby platform correctly re-added to the lockfile.

General Findings

In-scope defects

  • 🟡 Standards / plan conformanceGemfile.lock: BUNDLED WITH jumped 2.3.264.0.16. Sibling migrated repos (rake_slack, rake_github, lino) all sit on 2.5.15. Bundler 4 is a major release with changed defaults, and the runner installs the bundler the lockfile pins. This is an operator-environment artefact riding along with the migration, not a resolution outcome sanctioned by §4.5. Recommend restoring a bundler version consistent with the fleet.
  • 🔵 StandardsRakefile:141-175: the §4.4 step 3 snippets were restructured into top-level gh_auth_token / resolve_github_token / read_encryption_passphrase methods, with reworded raise messages ("expected an unlocked clone" vs the plan's "expected a git-crypt-unlocked clone with the CI secrets present") and the combined comment split across two methods. See the tradeoff above.
  • 🔵 StandardsRakefile:133: documentation:update's commit message was refactored from "...#{skip_ci ? ' [ci skip]' : ''}" to "...#{' [ci skip]' if skip_ci}". Behaviour-preserving but out of scope — §4.4 closes with "Do not restructure anything else in the Rakefile."
  • 🔵 Security — the CircleCI API token, the GitHub PAT and the SSH deploy key are removed from the tree but remain live and present in git history. Revoking them (alongside the plan's host-side deploy-key deletion) belongs in the decommission checklist.
  • 🔵 Code qualitygh_auth_token rescues only Errno::ENOENT; the installed-but-unauthenticated case falls through to the same raise, so behaviour is correct, but the rescue reads as if it were the sole failure mode. A one-line comment would help the next reader.

Plan concerns (challenges to documented deliberate decisions — for a future plan revision, not blockers)

  • 🔵 concurrency: queue: max on prerelease/release (§4.2 verbatim) — see cross-cutting themes.
  • 🔵 No build job on main.yaml (§4.2) — see cross-cutting themes.
  • 🔵 Publish-then-push ordering in ./go release, and no git pull in the prerelease job before the irreversible RubyGems push — both explicitly listed as inherited hazards in plan §1 and D5.
  • 🔵 The PR prerelease job exposes ENCRYPTION_PASSPHRASE (and thus the RubyGems credential) to any same-repo pushable branch, with no environment: protection — D8 accepts this, gated to same-repo non-Dependabot PRs. Worth revisiting fleet-wide: a scoped push-only RubyGems key would materially shrink the blast radius.
  • 🔵 The prerelease job carries no job-level permissions: — plan §4.1 states this explicitly (top-level contents: read is all it needs).
  • 🔵 .github/gpg.private.enc uses openssl's legacy -md sha1 EVP_BytesToKey KDF — pre-existing; §4.6 says leave the openssl commands untouched. A -pbkdf2 -iter 600000 -md sha256 re-encryption is good post-migration hardening.
  • 🔵 git pull --ff-only in the release job relies on the implicit upstream from checkout ref: main (§4.2 verbatim); git pull --ff-only origin main would be more robust to a future ref: change.
  • 🔵 prerelease:publish hard-codes the gem name in three places, infers the built .gem filename by convention rather than gem build -o, and its VERSION regex matches only single-quoted literals. All three are verbatim from the §4.4 step 8 template; if they are worth fixing, fix them in the plan.
  • 🔵 prerelease:publish's ensure does not run on a hard kill, so an interrupted local invocation leaves a mutated version.rb. Harmless on a fresh CI checkout; a footgun locally.

Verified, not a defect

  • grep -E '\((\S+\.)?(pre|rc|alpha|beta)[.0-9]*\)' Gemfile.lock matches enruby (0.1.0.pre.3) — that is the gem's own committed version, not a dependency resolved to a prerelease. §5 check 10 passes.
  • arm64-darwin-25 in PLATFORMS is pre-existing on main, not introduced here; the diff correctly adds the generic ruby platform per §4.5.
  • rake_git_crypt (0.4.0), rake_github (0.17.0), rake_slack (0.3.0) all meet the plan's floors; no rake_circle_ci/rake_ssh remain.

Review generated by /accelerator:review-pr

@phelma phelma left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #80 - Migrate pipeline from CircleCI to GitHub Actions

Verdict: COMMENT

Reviewed through five lenses (correctness, security, safety, standards, code quality) against the Variant A family plan. The workflow YAML reproduces the plan's authoritative §4.1/§4.2 shape essentially verbatim, the decommission is complete, and the new Rakefile provisioning guards are genuinely well-built. I found no critical or major in-scope defects — two minor conformance nits, and a set of plan concerns that are documented-deliberate and are recorded here for the plan owner rather than as blockers.

Verification performed

I ran the plan's §5 checks locally rather than taking them on trust:

  • bundle exec rspec — 14 examples, 0 failures
  • bundle exec rubocop Rakefile — no offences
  • ✅ Both workflow files parse as YAML
  • bundle exec rake -T lists all eight required tasks (git_crypt:unlock_with_encrypted_gpg_key, slack:notify, github:secrets:*, github:environments:*, repository:set_ci_author, pipeline:prepare, prerelease:publish, keys:gpg:generate) and no circle_ci:* or keys:deploy:*
  • git grep -i circleci -- ':!docs' ':!Gemfile.lock' — no matches
  • scripts/ci/ retains only configure-rubygems.sh + install-git-crypt.sh, both trimmed as §4.7 specifies
  • config/secrets/ci/ = passphrase + gpg.private + gpg.public, no ssh.*; circle_ci/ and github/ gone
  • .github/gpg.private.enc is a byte-identical rename (git show --stat reports Bin with no delta; git log --follow traces through)
  • ✅ Lockfile: rake_git_crypt 0.4.0, rake_github 0.17.0, rake_slack 0.3.0 present; rake_circle_ci/rake_ssh gone

False positives — raised by a lens, disproven by evidence

  • "The documentation:update ternary change is an out-of-scope edit" (standards). Disproven. I reverted the line to its original form and re-ran rubocop: it fails with Style/EmptyStringInsideInterpolation — a cop from the refreshed toolchain. The change is required by the library:check gate, exactly as the PR description predicted.
  • "GITHUB_TOKEN is forced read-only for Dependabot pull_request runs, so gh pr merge will 403" (security). Not correct: GitHub explicitly supports raising a Dependabot run's token scope via the job-level permissions: key, which is precisely what this job does. This is also the shape D3 prescribes.
  • "library:build leaves an untracked .gem behind" (code quality). *.gem is on line 2 of .gitignore.

General findings — in-scope (2 minor)

  • 🔵 Standards / Code Quality: the §4.4 provisioning code was extracted into three top-level Rakefile methods rather than the plan's inline form, and the raise messages were shortened. Functionally equivalent (and arguably more readable), but it is per-repo divergence in a fleet idiom being replicated ~103 times — see inline.
  • 🔵 Standards: Gemfile.lock PLATFORMS still lists operator-local arm64-darwin-* / x86_64-darwin-* entries, which §4.5 says the section should not carry — see inline.

Plan concerns — documented-deliberate, NOT defects of this diff

The lenses surfaced 21 findings that challenge decisions the plan or PR description records as intentional. They are kept here so the plan owner can revisit them fleet-wide; none should block this PR.

Inherited hazards, explicitly listed in plan §1:

  • 🟡 Publish-before-push: ./go release runs gem release --tag --push before git push / git push --tags, so a failed push leaves a published gem with no commit or tag, wedging the next release (correctness + safety — the strongest of the plan concerns, and the one most worth a post-migration fix; git push --follow-tags would collapse the two failure points into one)
  • 🟡 The release job's checkout ref: main + git pull --ff-only publishes main as of approval time, not the SHA the approver saw (correctness + safety; parity with the old release.sh, per PR description)
  • 🔵 Prerelease publishes to RubyGems on every main push with no approval gate (safety)
  • 🔵 Dependabot auto-merge accepts any update type that passes checks (security — D3, parity, no update-type filter by design)
  • 🔵 asdf_install@v1 and actions/checkout@v4 are mutable tags rather than pinned SHAs (security — D6, our own action)
  • 🔵 Job scaffolding repeated flat across seven jobs (code quality — §1 explicit design choice; any factoring belongs in infrablocks/github-actions)

The plan's exact prescribed code (§4.4 step 8 / step 3) — all verbatim as specified:

  • 🔵 sh "gem push #{gem_file}" uses the shell-string form where the surrounding file uses multi-arg sh(...)
  • 🔵 version_pattern only matches single-quoted VERSION literals; a quote-style change breaks the publish (fails loudly, so blast radius is limited)
  • 🔵 %i[pr_number run_number run_attempt] is written twice on adjacent lines
  • 🔵 The ensure block's File.write/rm_f could mask the original publish failure
  • 🔵 gh_auth_token rescues only Errno::ENOENT, not a non-zero gh exit
  • 🔵 prerelease:publish bundles parsing, composition, mutation and shelling out with no test seam
  • 🔵 A SIGKILL mid-task skips the ensure, leaving a .prNN version in a local tree

Other documented-deliberate design:

  • 🔵 prerelease/release sharing concurrency: group: main means cross-run ordering is by queue arrival (§4.2 explicit design; worth confirming queue: max behaves as documented on the first live cutover)
  • 🔵 ENCRYPTION_PASSPHRASE is a repo-level Actions secret readable by any future workflow, not scoped to the release environment (§4.4 step 3)
  • 🔵 .github/gpg.private.enc uses the legacy openssl -md sha1 KDF with no -pbkdf2 (§4.6: leave the openssl commands untouched — a good fleet-wide follow-up)
  • 🔵 actions/checkout persists its token alongside a git-crypt-unlocked tree in the publish jobs (inherent to auto-publish)
  • 🔵 Unpinned, unretried apt-get install on the gated release critical path (§4.7 prescribes exactly this)
  • 🔵 Combined cutover + decommission means rollback needs host-side steps (plan's explicit choice)
  • 🔵 Dependabot secret store not provisioned, so dependabot runs' unlock/notify steps will fail (§7, deferred to issue 19)
  • 🔵 PR prerelease versions accumulate permanently on RubyGems and sort by run number (D8, accepted)

Strengths

  • ✅ Both workflow files reproduce the plan's authoritative YAML faithfully — job names, step names, ordering, timeouts, concurrency, permissions and comment wording all match
  • merge-pull-request correctly needs: [check, test, build] and does not list prerelease, avoiding the skipped-dependency freeze the plan warns about
  • ✅ Untrusted PR data (title, number, head SHA) is passed via env: and referenced as shell variables — no ${{ }} script injection anywhere
  • pull_request rather than pull_request_target; prerelease gated on head.repo.full_name and the immutable user.login, keeping secrets away from forks and Dependabot
  • ✅ Least-privilege default permissions: contents: read with narrow per-job escalation; release behind an environment with team reviewers
  • gh pr merge --match-head-commit "$HEAD_SHA" closes the TOCTOU window between checks passing and the merge landing
  • read_encryption_passphrase's \0GITCRYPT guard turns a silent garbage-secret upload into a fast, explicit failure — genuinely good defensive design
  • resolve_github_token normalises whitespace-only tokens and rescues a missing gh, converging all three absence paths on one actionable raise
  • prerelease:publish restores version.rb and removes the built gem in an ensure, so the task is safe to run locally
  • ✅ Every job carries timeout-minutes; Slack notifies are continue-on-error so an outage cannot block the auto-merge
  • ✅ Decommission is thorough and the GPG key move is a genuine byte-identical rename
  • ✅ The lockfile change correctly restores the missing generic ruby platform

Review generated by /accelerator:review-pr

Comment thread Rakefile
private_key: File.read('config/secrets/ci/ssh.private')
}
]
def gh_auth_token

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Standards / Code Quality (minor)

The plan's §4.4 step 3 specifies the token resolution and the passphrase ciphertext guard as inline code inside the RakeGithub.define_repository_tasks block. This implementation instead defines three top-level methods — gh_auth_token, resolve_github_token, read_encryption_passphrase — and calls them from the block. The raise messages were also shortened (e.g. "expected an unlocked clone" vs the plan's "expected a git-crypt-unlocked clone with the CI secrets present").

To be clear: the extracted form is arguably better code — each method has one responsibility and one clear failure mode, and it reads well. The concern is purely fleet consistency: this idiom is being replicated across ~103 repos, and per-repo divergence makes future scripted or agentic fleet-wide edits harder to apply uniformly. Top-level defs in a Rakefile also land on Object, unlike the plan's block-scoped locals.

Suggestion: either inline the plan's exact form here, or — if the extraction is genuinely preferred — raise it as a plan amendment so every sibling repo gets the same shape. Behaviour is equivalent either way, so this need not block the merge.

Comment thread Gemfile.lock
arm64-darwin-22
arm64-darwin-23
arm64-darwin-25
ruby

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Standards (minor)

Good catch adding the generic ruby platform back — that was missing before and would have bitten a fresh GHA runner.

However, plan §4.5 asks that the PLATFORMS section "retains the generic ruby and x86_64-linux platforms and contains no operator-local platforms (e.g. arm64-darwin-*)". The section still carries arm64-darwin-21, arm64-darwin-22, arm64-darwin-23, arm64-darwin-25, x86_64-darwin-19/20/21 and aarch64-linux.

These are all pre-existing (this PR didn't add them), and they're harmless on a x86_64-linux runner — hence minor. But since §4.5 explicitly calls for the cleanup, bundle lock --remove-platform on the darwin entries would bring the section into full conformance and stop an operator's local resolve from silently re-adding more.

@phelma
phelma marked this pull request as ready for review July 20, 2026 21:38
Copilot AI review requested due to automatic review settings July 20, 2026 21:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the repository’s CI/CD from CircleCI to GitHub Actions, re-plumbing pipeline provisioning/secrets and introducing a PR prerelease publish path while removing CircleCI-era scripts and config.

Changes:

  • Add GitHub Actions workflows for PR and main (check/test/build, prerelease publish, gated release, Dependabot auto-merge, Slack notifications).
  • Update Rake tasks and dependencies to use GitHub Actions secrets/environments and add prerelease:publish + library:build.
  • Remove CircleCI configuration and CI shell scripts; update docs and relocate the encrypted CI GPG key into .github/.

Reviewed changes

Copilot reviewed 19 out of 25 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
.github/workflows/pr.yaml New PR workflow: check/test/build + optional prerelease publish + Dependabot auto-merge.
.github/workflows/main.yaml New main workflow: check/test + prerelease + gated release via environment: release.
Rakefile Drops CircleCI provisioning, adds GitHub secrets/environments + Slack tasks + prerelease publish/build tasks.
Gemfile.lock Updates dependency graph (removes CircleCI gems, adds rake_slack, Bundler/platform updates).
enruby.gemspec Removes CircleCI/SSH dev deps; adds rake_slack.
README.md Updates key management docs from CircleCI paths to GitHub Actions paths.
.github/gpg.private.enc Adds the encrypted CI GPG key payload under .github/.
scripts/ci/common/install-git-crypt.sh Updates git-crypt install for GitHub Actions runner (sudo + gnupg).
scripts/ci/common/configure-rubygems.sh Stops attempting a git-crypt unlock inside RubyGems credential setup.
scripts/ci/common/install-slack-deps.sh Removed (no longer used after migration).
scripts/ci/common/install-gpg-key.sh Removed (replaced by GitHub Actions + .github/ encrypted key flow).
scripts/ci/common/install-asdf.sh Removed (replaced by asdf_install@v1 action).
scripts/ci/common/install-asdf-dependencies.sh Removed (replaced by asdf_install@v1 action).
scripts/ci/common/configure-asdf.sh Removed (replaced by asdf_install@v1 action).
scripts/ci/common/configure-git.sh Removed (replaced by repository:set_ci_author task).
scripts/ci/steps/build.sh Removed (job now runs ./go library:check/build directly).
scripts/ci/steps/test.sh Removed (job now runs ./go test:unit directly).
scripts/ci/steps/prerelease.sh Removed (job now runs GitHub Actions prerelease flow).
scripts/ci/steps/release.sh Removed (job now runs GitHub Actions release flow).
scripts/ci/steps/merge-pull-request.sh Removed (Dependabot merge now uses gh pr merge in Actions).
.circleci/config.yml Removed (CircleCI pipeline decommissioned in-repo).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/pr.yaml
# --match-head-commit fails the merge if a commit landed after checks passed
# [skip ci] stops the merge commit triggering a release build
# PR title via env, never interpolated
run: gh pr merge --merge --match-head-commit "$HEAD_SHA" "$PR_URL" --subject "$PR_TITLE [skip ci]"
Comment thread Rakefile
Comment on lines +247 to +249
%i[pr_number run_number run_attempt].each do |name|
raise "Missing task argument: #{name}" if args[name].to_s.empty?
end
Comment thread Rakefile
Comment on lines +266 to +267
sh 'gem build enruby.gemspec'
sh "gem push #{gem_file}"
@phelma
phelma merged commit fafba86 into main Jul 21, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants